Skip to content

Conversation

@mvaligursky
Copy link
Contributor

@mvaligursky mvaligursky commented Sep 29, 2025

Fixes #1498

Multi-draw rendering: DrawCommands and MeshInstance.setMultiDraw

What and why

This PR adds first-class multi-draw support to the engine so multiple sub-draws can be submitted with a single API call. This reduces CPU/driver overhead when rendering many disjoint index ranges (submeshes/patches) under identical state. It integrates cleanly with existing rendering paths and complements instancing and indirect rendering.

How it works

  • A new DrawCommands container holds per-draw parameters. On WebGL2, data is provided in structure-of-arrays form (counts, offsets, instanceCounts) compatible with the WEBGL_multi_draw extension. On WebGPU, data is stored as array-of-structs in a StorageBuffer for indirect multi-draw.
  • MeshInstance.setMultiDraw(camera, maxCount) allocates per-camera storage and returns a DrawCommands to populate via add(...) and finalize with update(count) when data changes.
  • To disable: call setMultiDraw(camera, 0). For indirect rendering, call setIndirect(camera, -1).

Platform support

  • WebGPU: fully supported. Indirect multi-draw is used and per-draw fields (including baseInstance/baseVertex) are honored.
  • WebGL2: supported via WEBGL_multi_draw (widely available, ~90%). Even when the extension is unavailable, multi-draw can still be used thanks to a fast internal fallback loop that issues individual draws using the same data.
  • Note: WebGL2 does not expose per-draw firstInstance, so instanced multi-draw cannot select different instance ranges per sub-draw. Use WebGPU for that scenario.

New Public API

  • MeshInstance.setMultiDraw(camera: CameraComponent|null, maxCount?: number): DrawCommands|undefined
    • Allocates per-camera multi-draw storage and returns a commands container. Pass 0 to disable for the camera (or the shared entry when camera is null).
  • MeshInstance.setIndirect(camera: CameraComponent|null, slot: number, count?: number): void
    • Existing API extended with disable semantics: pass -1 for slot to disable for the camera (or the shared entry when camera is null).
  • DrawCommands
    • Container for multi-draw parameters.
    • add(i, indexOrVertexCount, instanceCount, firstIndexOrVertex, baseVertex = 0, firstInstance = 0)
    • update(count) — sets active draw count; uploads used portion on WebGPU.

Examples

  • graphics/multi-draw.example.mjs
    • Builds a single mesh from a grid of plane patches and uses multi-draw to render visible patches. Demonstrates dynamic per-frame updates ("invisibility band" sweeping/rotating across the terrain) by repopulating the DrawCommands and calling update(visibleCount).
  • graphics/multi-draw-instanced.example.mjs (WebGPU-only)
    • Renders multiple primitives (sphere/box/cylinder) as sub-draws of a combined mesh, each with different instance ranges using baseInstance. Tagged WEBGL_DISABLED because WebGL2 lacks per-draw firstInstance.
Screenshot 2025-09-29 at 12 50 45 Screenshot 2025-09-29 at 12 51 13
multi-draw.mov

@mvaligursky mvaligursky self-assigned this Sep 29, 2025
@mvaligursky mvaligursky added enhancement Request for a new feature area: graphics Graphics related issue labels Sep 29, 2025
@mvaligursky mvaligursky requested a review from a team September 29, 2025 11:52
@AlexAPPi
Copy link
Contributor

Excellent! I'll try rewriting the terrain rendering to this technology; I think it'll be a great boost!

@mvaligursky
Copy link
Contributor Author

mvaligursky commented Sep 29, 2025

Excellent! I'll try rewriting the terrain rendering to this technology; I think it'll be a great boost!

Yep, let us know, fingers crossed it all works out.
I tried 100K sub-draw calls, and that was running well, so I have high hopes.

@AlexAPPi
Copy link
Contributor

AlexAPPi commented Sep 29, 2025

image
import { rootPath, deviceType } from 'examples/utils';

// @config DESCRIPTION Multi-draw instanced rendering of multiple primitives in a single call.
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
window.focus();

const assets = {
    helipad: new pc.Asset(
        'helipad-env-atlas',
        'texture',
        { url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` },
        { type: pc.TEXTURETYPE_RGBP, mipmaps: false }
    )
};

const gfxOptions = {
    deviceTypes: [deviceType],
    glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`,
    twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`
};

const device = await pc.createGraphicsDevice(canvas, gfxOptions);
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);

const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem];
createOptions.resourceHandlers = [pc.TextureHandler];

const app = new pc.AppBase(canvas);
app.init(createOptions);

// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);

// Ensure canvas is resized when window changes size
const resize = () => app.resizeCanvas();
window.addEventListener('resize', resize);
app.on('destroy', () => window.removeEventListener('resize', resize));

const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
assetListLoader.load(() => {
    app.start();

    // setup skydome
    app.scene.skyboxMip = 2;
    app.scene.exposure = 0.3;
    app.scene.envAtlas = assets.helipad.resource;
    app.scene.ambientLight = new pc.Color(0.1, 0.1, 0.1);

    // camera
    const camera = new pc.Entity();
    camera.addComponent('camera', { toneMapping: pc.TONEMAP_ACES });
    app.root.addChild(camera);
    camera.translate(0, 0, 16);

    // material
    const material = new pc.StandardMaterial();
    material.gloss = 0.6;
    material.metalness = 0.7;
    material.useMetalness = true;

    // build 3 primitive geometries (unit size)
    const sphereGeom = new pc.SphereGeometry({ radius: 0.5, latitudeBands: 24, longitudeBands: 24 });
    const boxGeom = new pc.BoxGeometry();
    const cylGeom = new pc.CylinderGeometry({ radius: 0.5, height: 1, heightSegments: 1, radialSegments: 32 });

    // combine into single geometry
    const combine = new pc.Geometry();
    const pushGeom = (g, vertexOffset) => {
        // positions / normals / uvs
        combine.positions.push(...g.positions);
        if (g.normals) combine.normals ??= []; if (g.normals) combine.normals.push(...g.normals);
        if (g.uvs) combine.uvs ??= []; if (g.uvs) combine.uvs.push(...g.uvs);

        // indices with offset
        const base = vertexOffset;
        const srcIdx = g.indices;
        for (let i = 0; i < srcIdx.length; i++) combine.indices.push(srcIdx[i] + base);
    };

    // initialize arrays
    combine.positions = [];
    combine.normals = [];
    combine.uvs = [];
    combine.indices = [];

    // vertex offsets and firstIndex tracking (in indices)
    const vtxCounts = [
        sphereGeom.positions.length / 3,
        boxGeom.positions.length / 3,
        cylGeom.positions.length / 3
    ];
    const idxCounts = [
        sphereGeom.indices.length,
        boxGeom.indices.length,
        cylGeom.indices.length
    ];
    const firstIndex = [0, idxCounts[0], idxCounts[0] + idxCounts[1]];

    // append geometries
    pushGeom(sphereGeom, 0);
    pushGeom(boxGeom, vtxCounts[0]);
    pushGeom(cylGeom, vtxCounts[0] + vtxCounts[1]);

    // create mesh
    const mesh = pc.Mesh.fromGeometry(app.graphicsDevice, combine);

    // MeshInstance
    const meshInst = new pc.MeshInstance(mesh, material);

    // entity to render our MeshInstance
    const entity = new pc.Entity('MultiDrawEntity');
    entity.addComponent('render', { meshInstances: [meshInst] });
    app.root.addChild(entity);

    // instancing
    const ringCounts = [8, 15, 25];
    const totalInstances = ringCounts[0] + ringCounts[1] + ringCounts[2];

    const matrices = new Float32Array(totalInstances * 16);
    const instanceIndexes = new Uint32Array(totalInstances);
    const drawOffsets = new Float32Array(ringCounts.length);

    // populate matrices on 3 concentric rings; assign groups sequentially
    const tmpPos = new pc.Vec3();
    const tmpRot = new pc.Quat();
    const tmpScl = new pc.Vec3(1, 1, 1);
    const m = new pc.Mat4();

    let write = 0;
    let prevInstanceMaxCount = 0;
    const radii = [2, 4, 6];
    for (let ring = 0; ring < 3; ring++) {
        const n = ringCounts[ring];
        const r = radii[ring];
        for (let i = 0; i < n; i++) {
            const a = (i / n) * Math.PI * 2;
            tmpPos.set(Math.cos(a) * r, 0, Math.sin(a) * r);
            tmpRot.setFromEulerAngles(0, (a * 180) / Math.PI, 0);
            m.setTRS(tmpPos, tmpRot, tmpScl);
            matrices.set(m.data, write);
            instanceIndexes[write / 16] = write / 16;
            write += 16;
        }
        drawOffsets[ring] = prevInstanceMaxCount;
        prevInstanceMaxCount += n;
    }

    let vbFormat;
    let vbData;

    if (app.graphicsDevice.isWebGL2 && app.graphicsDevice.getExtension('WEBGL_multi_draw')) {

        // update material transform instancing chunk
        material.shaderChunks.glsl.set('transformInstancingVS', `

            #ifdef GL_ANGLE_multi_draw

                attribute int aInstanceId;

                uniform float uDrawOffsets[10];
                uniform sampler2D uInstanceMatrices;

                // We use a texture to store the instance's transformation matrix.
                mat4 getInstancedMatrix(int index) {
                    int size = textureSize(uInstanceMatrices, 0).x;
                    int j = index * 4;
                    int x = j % size;
                    int y = j / size;
                    vec4 v1 = texelFetch(uInstanceMatrices, ivec2(x    , y), 0);
                    vec4 v2 = texelFetch(uInstanceMatrices, ivec2(x + 1, y), 0);
                    vec4 v3 = texelFetch(uInstanceMatrices, ivec2(x + 2, y), 0);
                    vec4 v4 = texelFetch(uInstanceMatrices, ivec2(x + 3, y), 0);
                    return mat4(v1, v2, v3, v4);
                }

                mat4 getModelMatrix() {
                    // using gl_InstanceID leads to a system error, we will use a hack with vertices
                    // We take the maximum offset for the previous instance types and add the current one.
                    int drawOffset = int(uDrawOffsets[gl_DrawID]);
                    int instanceIndex = drawOffset + aInstanceId;
                    return matrix_model * getInstancedMatrix(instanceIndex);
                }

            #endif
            
        `);

        // Store matrices in texture
        const matricesDataTexture = new pc.Texture(app.graphicsDevice, {
            width: totalInstances * 16 / 4, // write rbga as vec4
            height: 1,
            format: pc.PIXELFORMAT_RGBA32F,
            mipmaps: false,
            numLevels: 1,
            levels: [matrices]
        });

        vbData = instanceIndexes;
        vbFormat = new pc.VertexFormat(app.graphicsDevice, [
            { semantic: pc.SEMANTIC_ATTR11, components: 1, type: pc.TYPE_INT32, asInt: true },
        ]);

        material.setAttribute('aInstanceId', pc.SEMANTIC_ATTR11);
        material.setParameter('uDrawOffsets[0]', drawOffsets);
        material.setParameter('uInstanceMatrices', matricesDataTexture);
        material.update();
    }
    else {
        vbData = matrices;
        vbFormat = pc.VertexFormat.getDefaultInstancingFormat(app.graphicsDevice);
    }

    const vb = new pc.VertexBuffer(app.graphicsDevice, vbFormat, totalInstances, { data: vbData });
    meshInst.setInstancing(vb);

    // multi-draw: 3 draws (sphere, box, cylinder) with different instance counts
    // provide firstInstance (instances are packed sequentially per ring) - this is WebGPU only
    const firstInstance = [0, ringCounts[0], ringCounts[0] + ringCounts[1]];
    const cmd = meshInst.setMultiDraw(null, 3);
    cmd.add(0, idxCounts[0], ringCounts[0], firstIndex[0], 0, firstInstance[0]);
    cmd.add(1, idxCounts[1], ringCounts[1], firstIndex[1], 0, firstInstance[1]);
    cmd.add(2, idxCounts[2], ringCounts[2], firstIndex[2], 0, firstInstance[2]);
    cmd.update(3);


    // draw helper lines around each ring to visualize distribution
    const linesPositions = [];
    const linesColors = [];
    const ringColor = [pc.Color.RED, pc.Color.GREEN, pc.Color.YELLOW];
    for (let ring = 0; ring < 3; ring++) {
        const n = ringCounts[ring];
        const r = radii[ring];
        const col = ringColor[ring];
        for (let i = 0; i < n; i++) {
            const a0 = (i / n) * Math.PI * 2;
            const a1 = ((i + 1) % n) / n * Math.PI * 2;
            const p0 = new pc.Vec3(Math.cos(a0) * r, 0, Math.sin(a0) * r);
            const p1 = new pc.Vec3(Math.cos(a1) * r, 0, Math.sin(a1) * r);
            linesPositions.push(p0, p1);
            linesColors.push(col, col);
        }
    }

    // orbit camera
    let angle = 0;
    app.on('update', (dt) => {

        angle += dt * 0.2;
        camera.setLocalPosition(15 * Math.sin(angle), 7, 15 * Math.cos(angle));
        camera.lookAt(pc.Vec3.ZERO);

        app.drawLines(linesPositions, linesColors);
    });
});

export { app };

@mvaligursky
Copy link
Contributor Author

Can you explain what the comment is about please?

@AlexAPPi
Copy link
Contributor

Can you explain what the comment is about please?

This example demonstrates the use of multi-draw instancing in both WebGPU and WebGL2.

@mvaligursky mvaligursky merged commit ae7d209 into main Sep 30, 2025
7 checks passed
@mvaligursky mvaligursky deleted the mv-multidraw branch September 30, 2025 08:26
@mvaligursky
Copy link
Contributor Author

Can you explain what the comment is about please?

This example demonstrates the use of multi-draw instancing in both WebGPU and WebGL2.

Ah I see, yep nice, the solution ChatGPT was suggesting. I looked at that and hoped that would work without API changes, but it does not, the instancing data needs to go to texture and be manually fetched.

I'm open to adjust engine shader chunks to make this easier for people to do as needed, and have an example to demonstrate as well. Feel free to create a PR here please.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: graphics Graphics related issue enhancement Request for a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WEBGL_multi_draw new extension, potential huge CPU-GPU overhead optimisation.

4 participants