WebGPU Instancing and Indirect Draws
Draw one mesh thousands of times in a single call, and let the GPU itself decide how many vertices, instances, or workgroups to process by reading draw parameters from a buffer instead of from JavaScript.
Quick Reference
WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+).
| Indirect call | Buffer size | u32 values, in exact order |
|---|---|---|
drawIndirect |
16 bytes | vertexCount, instanceCount, firstVertex, firstInstance |
drawIndexedIndirect |
20 bytes | indexCount, instanceCount, firstIndex, baseVertex, firstInstance |
dispatchWorkgroupsIndirect |
12 bytes | workgroupCountX, workgroupCountY, workgroupCountZ |
All values are tightly packed little-endian u32. The GPU reads EXACTLY this many
bytes starting at indirectOffset.
| Method | Signature |
|---|---|
| Non-indexed instanced | draw(vertexCount, instanceCount, firstVertex, firstInstance) |
| Indexed instanced | drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance) |
| Non-indexed indirect | drawIndirect(indirectBuffer, indirectOffset) |
| Indexed indirect | drawIndexedIndirect(indirectBuffer, indirectOffset) |
| Indirect dispatch | dispatchWorkgroupsIndirect(indirectBuffer, indirectOffset) |
Rules that ALWAYS hold:
- Indirect buffer usage MUST include
GPUBufferUsage.INDIRECT. The common combination isCOPY_DST | INDIRECT. For a GPU-written buffer, addSTORAGE. indirectOffsetMUST be a multiple of 4.- A non-zero
firstInstancein an INDIRECT draw requires theindirect-first-instancefeature. Without it the value is forced to 0 (silent no-op, not an error). firstInstancein a DIRECTdraw/drawIndexedcall works without any feature.
Decision Tree
Drawing many copies of one mesh?
├─ Counts and per-instance data known on the CPU
│ └─ Direct instanced draw: draw / drawIndexed with instanceCount.
│ Per-instance data → vertex buffer stepMode "instance"
│ OR storage buffer indexed by @builtin(instance_index).
│
├─ Counts decided on the GPU (culling, particle spawn, LOD)
│ └─ Indirect draw: a compute pass writes the indirect buffer,
│ the render pass reads it with drawIndirect / drawIndexedIndirect.
│ No CPU readback of the count.
│
├─ Compute dispatch size decided on the GPU
│ └─ dispatchWorkgroupsIndirect, buffer written by a prior pass.
│
└─ Hundreds of distinct meshes, each with its own indirect record
└─ One indirect record per mesh in an array buffer; loop
drawIndexedIndirect with a stepping indirectOffset.
Chrome 131+ only: multiDrawIndexedIndirect collapses the loop
into one call. Feature-gate it, never assume availability.
Instancing answers "how do I draw the same thing many times". Indirect answers "how do I draw without the CPU knowing the count". GPU-driven rendering combines both.
Core Patterns
Pattern 1: ALWAYS pass instanceCount, never loop draw calls
NEVER call draw() once per object in a loop when the objects share a mesh. ALWAYS
issue one instanced draw.
// 5000 copies of a 36-vertex cube in ONE call.
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, cubeVertices);
pass.draw(36, 5000, 0, 0); // vertexCount, instanceCount, firstVertex, firstInstance
Pattern 2: ALWAYS choose one per-instance data path
Per-instance attributes (model matrix, color) live EITHER in a vertex buffer with
stepMode: "instance" OR in a storage buffer read by @builtin(instance_index).
// Vertex-buffer path: advances once per instance, not per vertex.
const layout = {
arrayStride: 16, // one vec4 per instance
stepMode: "instance", // NEVER "vertex" for per-instance data
attributes: [{ shaderLocation: 2, offset: 0, format: "float32x4" }],
};
// Storage-buffer path: index the buffer by the instance.
@group(0) @binding(0) var<storage, read> instances: array<mat4x4f>;
@vertex fn vs(@builtin(instance_index) i: u32, @location(0) pos: vec3f)
-> @builtin(position) vec4f {
return instances[i] * vec4f(pos, 1.0);
}
instance_index ALWAYS includes the draw's firstInstance base offset.
Pattern 3: ALWAYS write the full indirect record
NEVER leave indirect buffer bytes uninitialized. The GPU reads every byte of the
record. Write all 4 (or 5, or 3) u32 values, including zeros.
// drawIndirect: 4 values, 16 bytes. All four MUST be written.
const params = new Uint32Array([36, 5000, 0, 0]);
const indirectBuffer = device.createBuffer({
size: 16,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.INDIRECT,
});
device.queue.writeBuffer(indirectBuffer, 0, params);
pass.drawIndirect(indirectBuffer, 0); // offset MUST be a multiple of 4
Pattern 4: ALWAYS match the stride to the draw kind
A non-indexed drawIndirect record is 16 bytes. An indexed drawIndexedIndirect
record is 20 bytes. NEVER reuse one stride for the other call.
// Indexed indirect: 5 values, 20 bytes.
const indexed = new Uint32Array([indexCount, 5000, 0, 0, 0]);
const buf = device.createBuffer({ size: 20,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.INDIRECT });
device.queue.writeBuffer(buf, 0, indexed);
pass.setIndexBuffer(indexBuffer, "uint32");
pass.drawIndexedIndirect(buf, 0);
Pattern 5: ALWAYS feature-gate firstInstance in indirect draws
A non-zero firstInstance inside an indirect record requires the
indirect-first-instance feature. ALWAYS request it conditionally and keep
firstInstance at 0 when it is absent.
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice({
requiredFeatures: adapter.features.has("indirect-first-instance")
? ["indirect-first-instance"] : [],
});
Pattern 6: ALWAYS let the GPU write the indirect buffer for GPU-driven rendering
A compute pass culls and writes the indirect buffer. The render pass in the SAME
queue.submit reads it. NEVER read the count back to the CPU.
const indirectBuffer = device.createBuffer({
size: 20, // drawIndexedIndirect record
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const encoder = device.createCommandEncoder();
const cull = encoder.beginComputePass();
cull.setPipeline(cullPipeline); // shader writes instanceCount into the buffer
cull.setBindGroup(0, cullBindGroup);
cull.dispatchWorkgroups(Math.ceil(objectCount / 64));
cull.end();
const draw = encoder.beginRenderPass(renderPassDesc);
draw.setPipeline(drawPipeline);
draw.setIndexBuffer(indexBuffer, "uint32");
draw.drawIndexedIndirect(indirectBuffer, 0);
draw.end();
device.queue.submit([encoder.finish()]); // encoder orders compute before render
Common Anti-Patterns
Using a 20-byte stride for a non-indexed
drawIndirect(or 16 for indexed). WHY it fails: the GPU reads exactly 16 bytes fordrawIndirectand exactly 20 fordrawIndexedIndirect. A wrong stride misaligns the next record, sofirstVertex/baseVertex/firstInstanceare read from garbage, producing corrupt geometry or a validation error.Leaving indirect buffer bytes uninitialized. WHY it fails: writing only
vertexCountandinstanceCountleavesfirstVertexandfirstInstanceas undefined memory; the GPU still reads them, causing out-of-range vertex fetches.A non-zero
firstInstancein an indirect draw without theindirect-first-instancefeature. WHY it fails: the value is silently forced to- The draw renders, no error fires, and instances appear at the wrong base offset with no diagnostic.
Critical Warnings
- NEVER call
draw()in a per-object CPU loop for objects that share a mesh. UseinstanceCount. - NEVER pass an
indirectOffsetthat is not a multiple of 4. It fails validation and invalidates the pass encoder. - NEVER reuse a 16-byte stride for
drawIndexedIndirector a 20-byte stride fordrawIndirect. - NEVER omit any
u32field from an indirect record. Write zeros explicitly. - NEVER assume
multiDrawIndirect/multiDrawIndexedIndirectexist. They are experimental, Chrome 131+, behind thechromium-experimental-multi-draw-indirectfeature. Always feature-detect and provide the loop-of-drawIndirect path. - NEVER read the GPU-written indirect count back to the CPU in the render frame. The command encoder already orders the compute pass before the render pass.
- NEVER set
stepMode: "vertex"on a vertex buffer that holds per-instance data. The attribute would advance per vertex and every instance would look identical.
Reference Files
references/methods.md: full signatures fordraw,drawIndexed,drawIndirect,drawIndexedIndirect,dispatchWorkgroupsIndirect, exact buffer layouts, theindirect-first-instancefeature, and experimentalmultiDrawIndirect.references/examples.md: verified working code for instanced draw, indirect draw with a populated buffer, and a GPU-driven cull plus indirect render.references/anti-patterns.md: mistakes with WHY-it-fails analysis.
Related skills: webgpu-syntax-render-pipeline (vertex buffer layouts, stepMode),
webgpu-syntax-compute-pipeline (compute passes that write indirect buffers),
webgpu-wgsl-vertex-shaders (@builtin(instance_index), per-instance attributes),
webgpu-impl-performance (when GPU-driven rendering pays off, render bundles),
webgpu-impl-compute-usecases (particle systems feeding indirect draws).