Draw Calls & Instancing
Draw the same object ten thousand times and watch where the time actually goes.
What you should come away with: Why the number of draw calls matters more than the number of triangles.
Assumes: Compute & Particles. It will still make sense without it, but that one comes first.
Why this API: The lesson is CPU cost per draw call, which is precisely where WebGPU differs most from WebGL.
Ten thousand cubes, a hundred and twenty thousand triangles, one shader module and one pipeline. You can have that picture for one or for ten thousand, and it is the same picture either way — the same vertices, out of the same buffer, lit by the same code, pixel for pixel. The readout under the canvas measures what the second option costs.
This lab is about the price of asking. Not the price of drawing, which is identical on both sides, but the price of the CPU turning to the GPU and saying: this, now.
The two modes differ by a loop#
Inside the render pass, the instanced mode records four commands. Set the pipeline. Bind the scene — the camera matrix and the holding every cube’s position, tint and scale. Point the per-object binding at object zero. Then draw thirty-six vertices, ten thousand times over, in one call.
The per-object mode records the first two and turns the last two into a loop: point the binding at object i, draw thirty-six vertices once, go round again. Nothing else moves — the same shader module, the same pipeline object, the same instance buffer, the same back-face culling and the same depth test.
Both lists hand the GPU the same work: 360,000 vertex shader invocations and 120,000 triangles, shaded by the same twelve lines of . What differs is how long the CPU spent writing the list down.
The geometry is deliberately negligible, and that is what makes the measurement mean anything. No vertex buffer is bound at all: each cube is thirty-six vertices assembled inside the shader from @builtin(vertex_index) — six faces of six vertices, two triangles apiece — and the per-cube data is a 32-byte record of position, phase, tint and scale, read out of one storage buffer that is bound once a frame in both modes. Nothing on the GPU side is heavy enough to hide what the CPU is doing.
The counting moves from one side of an addition to the other#
The vertex shader reads its per-cube data on a single line: let inst = instances[instanceIndex + objectRef.index];. Both terms exist in both modes, and exactly one of them is ever non-zero.
instanceIndex is , which the hardware supplies: ask for ten thousand instances and it counts from zero to 9,999 on its own, with the CPU no longer involved once the call is made. objectRef.index is a four-byte integer in a uniform buffer, which the CPU supplies by rebinding.
So is not a fast path bolted onto the side of the API. It is the loop counter moved from the CPU’s side of the boundary to the GPU’s, and the boundary is the expensive part.
The cost is the rebinding, not the draw#
Look again at what the loop does per cube. It issues two commands, not one, and the draw is the cheaper of them.
The per-object binding carries a single unsigned integer — which cube this call is about. Rather than write that integer into a buffer ten thousand times a frame, the lab writes all ten thousand of them once at start-up and selects one with a dynamic offset: setBindGroup(1, objectBind, [i * align]). That third argument is a byte offset, and it has to be a multiple of the alignment the device reports through device.limits.minUniformBufferOffsetAlignment. Most report 256, which is the WebGPU default, so a four-byte number occupies a 256-byte slot.
The per-object path is therefore already the cheapest per-object change that can be expressed. No new pipeline, no new vertex buffer, no upload, no texture — one integer, selected by arithmetic on an offset. And it still costs, because every setBindGroup has to be checked and written into the command buffer: is the offset inside the buffer, is it aligned, does the binding fit. Every draw has to be checked and written too. Do that twenty thousand times and the list becomes the work.
On the machine this was written on, ten thousand cubes encode in 0.10 ms as one call and 1.38 ms as ten thousand — about fourteen times, which works out at roughly 130 nanoseconds per cube for a rebind and a draw. Your figure will be different; browsers, drivers and processors all disagree, and the number in the readout is measured on your hardware rather than stored here.
Because the per-object step has been made as small as it can be, that ratio is a floor rather than a ceiling. A real renderer changes a material bind group between objects, frequently a vertex buffer, and sometimes the pipeline itself — a switch this lab never once asks for.
Read the ratio, not the millisecond#
The CPU figure is bracketed narrowly and deliberately. The timer starts before the command encoder is created and stops immediately after queue.submit, so it covers building the render pass, recording every command in it, and handing the finished buffer to the driver. It contains no GPU work at all: submit posts a list and returns without waiting for anything to be drawn.
submit. The drawing itself happens on the GPU’s own timeline, after the list has been handed over and the CPU has moved on.#This is why the frame rate can sit perfectly still while the CPU figure moves fourteenfold, and the honest thing is to expect that rather than hide it. Ten thousand cubes is a small scene on a current machine, those milliseconds are coming out of a budget nothing else here is competing for, and the frame-rate readout is a smoothed average of requestAnimationFrame deltas that are clamped at fifty milliseconds — so it cannot report below twenty even when the truth is worse. Both readings are exponential averages weighted a tenth towards the newest frame, which is why they slide to a new value over a few dozen frames instead of jumping when you switch modes.
None of this transfers to a WebGL reading, which is why the lab refuses to fall back to one when WebGPU is missing. The two APIs do not charge the same price for a draw call; running the comparison on WebGL would answer a different question and print the answer under this question’s label.
The number that never moves is the triangle count: 120,000 in both modes, and the readout says so while the millisecond figure changes by an order of magnitude. That is the thing to carry out of here. A scene’s cost is not read off its polygon budget — Compute & Particles puts its entire particle field on screen with one draw and no instance count at all, multiplying the vertex count instead. , merged materials, texture atlases and instanced foliage all exist to shorten the list, not to shrink the geometry.
Now move all of it at once#
Below is the instrument with every control exposed: the count from one cube to ten thousand, the mode, the size and spin of the cubes, and whether they animate. Size and spin are two floats in an eighty-byte uniform buffer written once a frame — they change the picture and cannot change the encoding cost, and they are there so the field stays readable while you move the count. Raising the count grows the spiral outward rather than reshuffling it, so the arrangement you were looking at stays where it was. Drag the canvas to orbit.
Start with the presets. The first two set up the comparison this essay has been describing and ask you to change exactly one thing between them; the third finds the count at which a thousand separate calls is already a measurable slice of a frame.
Checking for WebGPU…
Drag or arrow keys to orbit · 1 draw call per frame
What the frame cost
Switch the mode and watch which number moves. The triangle count does not change, the shader does not change, and the buffer does not change — the GPU is asked for exactly the same picture either way, and produces it. What changes is how many times it was asked, and on a real scene that is usually the number standing between you and the frame budget. This is why engines batch, why materials get merged, and why “reduce your poly count” is so often the wrong advice.
CPU per frame is measured around encoding and submitting the pass, so it is the cost of asking rather than of drawing. It is a real measurement from your machine, and it will differ from anyone else’s.
On a fast machine the frame rate may not move at all, and that is worth understanding rather than hiding: ten thousand cubes is a small scene, and the milliseconds here are being spent out of a budget nothing else is competing for. The figure to carry away is the ratio, not the absolute — whatever the CPU cost of one call is on your hardware, per-object drawing pays it ten thousand times, and a real frame has a game in it as well.
Where that leaves you
You can now explain why one call and ten thousand produce the identical picture at very different cost, and read the ratio between the two modes rather than trusting a millisecond figure measured on somebody else’s machine.
This did not teach you what the GPU does with the list once it has been handed over. The clock here stops the moment the command buffer is submitted, before anything is drawn, so overdraw, shading cost and memory bandwidth sit entirely outside the measurement — a scene can be slow for all three while its call count is one this lab would call healthy.