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:
| 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.
<!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>