Indirect Buffers

Indirect Draw and Draw-Indexed-Indirect Buffers

pass.drawIndirect(buffer, offset) and pass.drawIndexedIndirect(buffer, offset) take their arguments from a GPU buffer instead of JavaScript, as consecutive 32-bit values; dispatchWorkgroupsIndirect() does the same for compute passes:

Indirect argument layouts
Call Values at offset (32-bit each)
drawIndirect vertexCount, instanceCount, firstVertex, firstInstance
drawIndexedIndirect indexCount, instanceCount, firstIndex, baseVertex (signed), firstInstance
dispatchWorkgroupsIndirect workgroupCountX, workgroupCountY, workgroupCountZ

The buffer needs INDIRECT usage (without it: "usage (BufferUsage::Storage) doesn't include BufferUsage::Indirect"), usually with STORAGE so a compute shader can write it. The offset must be a multiple of 4 ("Indirect offset (2) is not a multiple of 4") and the arguments must fit inside the buffer. A nonzero firstInstance requires the indirect-first-instance feature; without it the spec turns the draw into a no-op rather than an error, a silent failure worth knowing (this GTX 1650 offers the feature). The values themselves are read only on the GPU timeline, so a wrong count gives an empty or partial draw, never a JavaScript error.

drawIndirect() and drawIndexedIndirect() reading their counts from GPU buffers that change every secondHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="340"></canvas>
  <canvas id="labels" width="600" height="340"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const code = /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@location(0) corner: vec2f, @builtin(instance_index) i: u32) -> Out {
  let row = select(0.0, 1.0, i >= 100u);                 // instances 100+ belong to the indexed row
  let k = f32(i % 100u);
  let p = vec2f(-0.94 + k * 0.155 + corner.x * 0.12, 0.1 - row * 0.9 + corner.y * 0.5);
  let tints = array(vec3f(.12, .37, .55), vec3f(.36, .25, .60), vec3f(.88, .60, .06), vec3f(.71, .27, .18));
  return Out(vec4f(p, 0, 1), tints[(i % 100u) % 4]);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const firstInstanceOK = adapter.features.has('indirect-first-instance');
  const device = await adapter.requestDevice({ requiredFeatures: firstInstanceOK ? ['indirect-first-instance'] : [] });
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const B = GPUBufferUsage;
  const upload = (data, usage) => { const b = device.createBuffer({ size: data.byteLength, usage: usage | B.COPY_DST }); device.queue.writeBuffer(b, 0, data); return b; };

  // Vertices: a quad as a triangle list (6) for drawIndirect, then 4 corners for the indexed draw.
  const vertices = upload(new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1,   0, 0, 1, 0, 1, 1, 0, 1]), B.VERTEX);
  const indices = upload(new Uint16Array([0, 1, 2, 0, 2, 3]), B.INDEX);
  // Argument buffers: INDIRECT usage, 32-bit values, read on the GPU timeline.
  const drawArgs = device.createBuffer({ size: 16, usage: B.INDIRECT | B.COPY_DST });   // vertexCount, instanceCount, firstVertex, firstInstance
  const indexedArgs = device.createBuffer({ size: 20, usage: B.INDIRECT | B.COPY_DST }); // indexCount, instanceCount, firstIndex, baseVertex, firstInstance

  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', fragment: { module, targets: [{ format }] },
    vertex: { module, buffers: [{ arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] }] } });

  let tick = -1, a = [], b = [];
  function frame(now) {
    const t = Math.floor(now / 1000);
    if (t !== tick) {                                   // new arguments once a second; no draw call changes
      tick = t;
      a = [6, 3 + (t * 5) % 10, 0, 0];
      b = [6, 2 + (t * 3) % 11, 0, 6, firstInstanceOK ? 100 : 0];   // baseVertex 6: the 4-corner quad
      device.queue.writeBuffer(drawArgs, 0, new Uint32Array(a));
      device.queue.writeBuffer(indexedArgs, 0, new Int32Array(b));   // baseVertex is signed
    }
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(pipeline);
    pass.setVertexBuffer(0, vertices);
    pass.drawIndirect(drawArgs, 0);
    pass.setIndexBuffer(indices, 'uint16');
    pass.drawIndexedIndirect(indexedArgs, 0);
    pass.end();
    device.queue.submit([encoder.finish()]);

    ink.clearRect(0, 0, 600, 340);
    ink.font = '12.5px ui-monospace, monospace'; ink.fillStyle = '#222';
    ink.fillText(`drawIndirect(args):        [${a.join(', ')}]`, 12, 24);
    ink.fillText(`drawIndexedIndirect(args): [${b.join(', ')}]`, 12, 176);
    ink.font = '11.5px system-ui, sans-serif'; ink.fillStyle = '#555';
    ink.fillText('vertexCount, instanceCount, firstVertex, firstInstance', 12, 42);
    ink.fillText('indexCount, instanceCount, firstIndex, baseVertex (signed), firstInstance', 12, 194);
    ink.fillStyle = '#1f4f8a';
    ink.fillText(firstInstanceOK ? 'indirect-first-instance is enabled, so firstInstance 100 works (without it: a silent no-op).'
                                 : 'No indirect-first-instance here: a nonzero firstInstance would make the draw a silent no-op.', 12, 330);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>