Draw calls: the real performance model
The instinct to optimize 3D by shaving triangles is almost always aimed at the wrong number — a draw call is a CPU-to-GPU command with a fixed dispatch cost, most scenes are bottlenecked by how many of those commands the CPU can issue per frame rather than by how much the GPU has to shade, and every real optimization technique in Three.js exists to send the same picture with fewer commands.
Draw calls: the real performance model
"Reduce your triangle count" is the performance advice everyone has heard about 3D graphics, and for most scenes running on modern hardware it is close to irrelevant. GPUs shade millions of vertices and fragments per frame as their entire job, in parallel, and shaving a mesh from 50,000 triangles to 40,000 rarely moves the needle. The number that actually predicts whether a scene is slow is a completely different one: how many draw calls it issues per frame. This lesson is about that number — what a draw call physically is, why having many of them is expensive in a way that has nothing to do with how complex any single one is, and the handful of techniques Three.js gives you to send the same picture with far fewer of them.
What a draw call actually is
The WebGL state-machine lesson walked through the full sequence needed to draw one triangle: bind a buffer, use a program, describe attributes, set uniforms, and only then call something like gl.drawArrays or gl.drawElements. That final call — the one that takes no data, just a primitive type and a count, and tells the GPU to actually run the pipeline against whatever's currently bound — is the draw call. It is a single command sent from the CPU (your JavaScript, via the browser, via the driver) to the GPU: "draw the currently-bound geometry, with the currently-bound program and state, right now."
A frame with one mesh needs, roughly, one draw call. A frame with a thousand separate meshes — even simple ones, even sharing the same material — needs, by default, a thousand separate draw calls, because each mesh has to individually go through bind-state-then-draw before the next one can start. The triangle count didn't necessarily change much between those two scenes. The number of times the CPU had to talk to the GPU changed enormously, and that number, it turns out, is what the frame budget actually feels.
Why many draw calls are slow: it's the calls, not the pixels
Each draw call carries a fixed cost that has nothing to do with how many vertices or fragments it produces. The driver has to validate the current state, the CPU has to package up a command and hand it across to the GPU, and if anything changed since the last draw — a different shader program, a different bound texture, a different buffer — the GPU pipeline has to actually reconfigure itself before it can start. None of that scales down just because the mesh being drawn is small. A tiny two-triangle quad drawn on its own pays almost the same per-call overhead as a mesh with ten thousand triangles; the overhead is per-command, not per-triangle.
State changes make this worse, not just repetitive. Switching the active shader program, rebinding a different texture, or pointing at a different vertex buffer between two draw calls forces the GPU to flush whatever it had queued and reconfigure for the new state before it can proceed. A scene that alternates materials mesh-by-mesh — wood, then metal, then wood, then metal — pays a reconfiguration cost on almost every single draw, on top of the fixed per-call overhead, purely because nothing about the state is being reused from one call to the next.
The bottleneck is the CPU, not the GPU
This is the detail that makes "just get a faster GPU" the wrong fix. The GPU itself can be sitting almost entirely idle, perfectly capable of shading everything instantly, while the CPU is the thing working — validating state, walking your scene graph, packaging up the next command, handing it across the driver boundary — one draw call at a time, sequentially, on the main thread. A scene bottlenecked on draw calls is bottlenecked on how fast your JavaScript and the browser's driver can dispatch commands, not on how fast the GPU can execute them.
That dispatch work happens on the same main thread the frame budget lesson already put a hard 16.6ms ceiling on. Every draw call renderer.render issues is main-thread time spent before the frame can be considered done — thousands of tiny CPU-side dispatches, competing for the same budget as your own animation code, style, and layout work would if this were a DOM scene instead of a WebGL one. A GPU that could render the whole scene in 2ms is irrelevant if the CPU needs 20ms just to hand it all the commands.
Reducing draw calls: the actual techniques
Since the cost is per-call, not per-triangle, the fix is always the same shape: do more drawing per call, even if that means each call now describes more geometry or more instances than before.
Merge static geometry. If a hundred small rocks never move independently of each other, there's no requirement that each one be its own Mesh. Combining their geometry into a single BufferGeometry — one big vertex buffer holding all hundred rocks' vertices — turns a hundred draw calls into one. The GPU doesn't care that the buffer represents a hundred conceptually separate objects; it's still just "draw this geometry" once. The cost is flexibility: merged geometry can't be moved or hidden independently anymore, which is exactly why this technique is for things that are genuinely static relative to each other.
Instancing. For many copies of the same geometry that do need independent positions — a forest of trees, a field of grass, a crowd — THREE.InstancedMesh draws the same base geometry thousands of times in a single draw call, feeding each copy its own transform (and optionally color or other per-copy data) through a special per-instance attribute rather than a per-vertex one. Where a normal attribute advances to its next value once per vertex, an instanced attribute advances once per instance — the vertex shader reads the same base geometry every time but multiplies each instance's vertices by that instance's own model matrix, pulled from the per-instance data instead of a separate uniform per mesh.
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x8899aa });
// One draw call for 1000 boxes, instead of 1000 draw calls.
const mesh = new THREE.InstancedMesh(geometry, material, 1000);
const dummy = new THREE.Object3D();
for (let i = 0; i < 1000; i++) {
dummy.position.set(
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100,
);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix); // writes into the per-instance buffer
}
scene.add(mesh);That loop never calls render, never issues 1000 draw calls — it's writing 1000 matrices into one buffer, ahead of time, that a single draw call will read from a thousand times over on the GPU side.
Share materials. Every distinct Material (and, by extension, the compiled shader program and bound textures it implies) that appears in a frame is a potential state change between draws. Reusing the same Material instance across many meshes means the renderer doesn't have to re-bind a program or re-bind textures between them, even if it still issues one draw call per mesh — cheaper reconfiguration on the way to fewer calls entirely.
Frustum culling. The scene-graph lesson mentioned that renderer.render culls objects outside the camera's view before drawing anything. A mesh that never gets culled because it's genuinely off-screen costs nothing that frame — no draw call, no state setup. Structuring a scene so culling can actually discard large chunks of it (grouping distant geometry, avoiding one giant mesh that spans the whole world and is never fully off-screen) is a draw-call reduction technique as much as a triangle one.
Counting draw calls instead of guessing
Three.js tracks this number for you, per frame, so "reduce draw calls" doesn't have to be a guess:
renderer.render(scene, camera);
console.log(renderer.info.render.calls); // draw calls issued this frame
console.log(renderer.info.render.triangles); // triangles drawn this framerenderer.info roughly maps one draw call per mesh/material combination in the scene, which is exactly why merging and instancing work: they collapse many mesh/material entries into fewer distinct ones before render ever walks the tree. Watching calls drop from 1000 to 1 after switching to an InstancedMesh is the single most direct way to see this lesson's whole point measured, rather than argued.
The rule of thumb
Batch aggressively, and measure the thing that's actually expensive. Merge what's static, instance what's repeated, share materials to avoid state churn, and let culling throw away what the camera can't see anyway. None of these change what's on screen — they change how many times the CPU has to tap the GPU on the shoulder to produce it, which is the number the frame budget actually cares about.
Where this goes next
Every technique in this lesson assumed the geometry and material were already correct, and focused entirely on how many times you submit them. The last lesson in this module turns back to what a Material actually is and how it decides the color of every pixel it draws — the fragment-shader machinery that runs once per surviving fragment, no matter how few draw calls it took to get there. Materials, lighting, and textures: how a surface gets its color closes out the module.
Go deeper
- Three.js docs — InstancedMesh — The full API for per-instance matrices and colors this lesson's code sample uses, including setMatrixAt and instanceMatrix.needsUpdate.
- Three.js manual — Optimizing for many objects — A worked comparison of naive per-mesh draws versus merged geometry versus instancing, with the same draw-call counting this lesson recommends.
- WebGL Fundamentals — Instanced Drawing — How instancing works at the raw WebGL level (gl.drawArraysInstanced and per-instance attributes), underneath what InstancedMesh automates.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Define a draw call precisely, in terms of the bind-then-draw sequence from the WebGL state-machine lesson.
- Why does a two-triangle mesh drawn alone cost nearly as much per draw call as a ten-thousand-triangle mesh drawn alone?
- Explain why switching materials between consecutive draw calls is more expensive than drawing two meshes that share a material.
- Why is a draw-call-bound scene described as 'CPU-bound' even though the GPU is the thing doing the actual shading?
- Contrast merging static geometry with instancing: what does each optimize for, and why would you pick one over the other for a field of grass versus a pile of rocks that never move?
- How does a per-instance attribute differ from a normal per-vertex attribute in how often it advances to its next value?
- What does renderer.info.render.calls actually count, and why does merging or instancing reduce that number even when the total triangle count stays the same?