Three.js From Zero · Article 09
Performance: Instancing, LOD, Draw Calls
Performance: Instancing, LOD, Draw Calls
Every article before this has been about making things look good. This one is about making them run fast. The goal: understand the real cost of a 3D scene, what's actually slow, and the three tools that solve 95% of performance problems — InstancedMesh, BatchedMesh, and LOD.
The demo renders a field of boxes using three different strategies. Flip the dropdown: individual meshes (one per object, the naïve approach), InstancedMesh (one draw call for all N), and BatchedMesh (one draw call, individual culling). Crank the count to 10,000 and watch the FPS difference.
Why this matters: the real bottleneck
When a Three.js scene runs slow, 9 times out of 10 the bottleneck is not the GPU struggling with triangles. It's the CPU spending more time talking to the GPU than the GPU spends drawing. That "talking" happens once per draw call — the command that says "render this mesh with these parameters".
Every mesh in your scene tree costs, at minimum, one draw call per frame.
Shadows cost extra. Translucent objects cost extra. A scene with 2,000 individual meshes
will submit 2,000+ draw calls per frame — the CPU becomes the ceiling and the GPU sits
idle.
Fix it by batching: fewer draw calls for the same number of triangles.
Reading renderer.info
Three.js exposes a live counter of exactly what's happening. The demo's stats panel reads straight from it.
console.log(renderer.info.render);
// { calls, triangles, points, lines, frame }
console.log(renderer.info.memory);
// { geometries, textures }
Watch two numbers:
calls— draw calls this frame. Budget: < 100 for mobile, < 500 for desktop. Anything more and the CPU is your ceiling.triangles— total triangles rasterized. Budget: depends on your GPU. 1M is fine on desktop, 200k on low-end mobile.
Notice triangles is constant across all three strategies in the demo — same
geometry, same count. Only calls changes. That's the whole story.
InstancedMesh — one call, N objects
An InstancedMesh is one geometry + one material + N transform matrices.
The GPU draws the geometry N times with different matrices, all in a single draw call.
const mesh = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
dummy.position.set(randX(), randY(), randZ());
dummy.rotation.set(randR(), randR(), randR());
dummy.scale.setScalar(randS());
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
The Object3D dummy is a trick: composing a matrix from position + rotation
+ scale manually is messy. Using a temporary Object3D, calling
updateMatrix(), and copying the matrix is the clean pattern.
Per-instance color
mesh.setColorAt(i, new THREE.Color().setHSL(i / count, 0.7, 0.5));
// after the loop:
mesh.instanceColor.needsUpdate = true;
Every instance is one draw call still — the color is a per-instance attribute the shader
reads. Works with any MeshStandardMaterial, MeshPhysicalMaterial,
ShaderMaterial, etc. — the material is shared.
Animating instances
renderer.setAnimationLoop((t) => {
for (let i = 0; i < mesh.count; i++) {
mesh.getMatrixAt(i, dummy.matrix);
dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
dummy.rotation.y += 0.01;
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
});
Updating 10,000 matrices per frame is CPU work. If your instances don't need per-frame updates, compute them once and leave them. If they do, see below — there's a better path.
When not to use InstancedMesh
- Instances need to be different geometries.
InstancedMeshis one geometry. - You need frustum culling per-instance.
InstancedMeshis all-or-nothing — if one instance is visible, the whole mesh draws. - Instances need to be clickable individually. You can still raycast — the hit result has
instanceId— but it's trickier to manage visibility.
BatchedMesh — one call, N different meshes
Added in r164. BatchedMesh solves the same problem as
InstancedMesh but with a superpower: each instance can have its own
geometry. One draw call still; individual geometries; per-instance frustum culling.
const batched = new THREE.BatchedMesh(
maxInstances, // how many instances max
maxVertices, // total vertex budget across all geometries
maxIndices, // total index budget
material,
);
// Register each unique geometry you'll batch:
const boxGeomId = batched.addGeometry(new THREE.BoxGeometry());
const cylGeomId = batched.addGeometry(new THREE.CylinderGeometry());
// Add instances referencing those geometry IDs:
for (let i = 0; i < N; i++) {
const id = batched.addInstance(i % 2 === 0 ? boxGeomId : cylGeomId);
dummy.position.set(...);
dummy.updateMatrix();
batched.setMatrixAt(id, dummy.matrix);
}
scene.add(batched);
Per-instance culling — BatchedMesh tests each instance's bounding box against the view frustum and skips invisible ones without a CPU loop. This matters at 10k+ instances spread across a wide world.
InstancedMesh vs BatchedMesh
| InstancedMesh | BatchedMesh | |
|---|---|---|
| Draw calls | 1 | 1 |
| Same geometry required | Yes | No — can register many |
| Per-instance culling | No | Yes |
| Added in | Forever | r164 (2024) |
| Use when | All instances identical shape | Mixed geometries or wide-spread world |
Still useInstancedMeshby default — it's simpler, more widely supported by addons (drei, etc.), and "good enough" for typical use cases. Reach forBatchedMeshwhen you have 5k+ instances of varying geometry spread across a big scene.
Frustum culling
What it is, visually
A camera can't see the whole world. It sees a pyramid-shaped region of space in front of it — the frustum. Objects inside that pyramid appear on screen; objects outside it are literally behind, above, below, or to the side of the camera and can't possibly show up in the final image.
Frustum culling is the trick of not sending draw calls for objects outside the frustum. It's free performance — you weren't going to see those pixels anyway — but only if the engine knows to check.
Three.js checks automatically for every mesh, as long as this is true:
mesh.frustumCulled = true; // the default
Internally it builds a cheap bounding sphere around each mesh's geometry once, then tests that sphere against the six planes of the frustum every frame. Spheres against planes is trivial math — a few dozen sphere tests are free compared to rendering a single triangle, so it's always worth doing for anything that might be off-screen.
Why it only helps with individual meshes
This is the critical bit the demo shows:
| Strategy | Cull granularity | Effect |
|---|---|---|
| Individual meshes | Per mesh | Huge. Each box is tested independently. Off-screen → skipped entirely. |
| InstancedMesh | The whole instance group | Almost none. If any part of the mesh bounds is in the frustum, ALL instances draw, even ones behind you. |
| BatchedMesh | Per instance, GPU-side | Partial. One draw call, but off-screen instances skip the vertex transform. |
That's the tradeoff: Instancing kills draw calls but gives up per-object culling. For dense localized clusters (cubes around a statue), InstancedMesh wins. For huge spread-out worlds (grass across a hillside), BatchedMesh is better because it combines both.
See it in the demo
In the demo above, set strategy to Individual meshes and toggle cull. The "boxes drawn" readout will jump from the count matching what's in view (~10–40% of total, depending on camera angle) up to 100% when you turn cull off. Switch to InstancedMesh — the cull toggle has almost no effect because the whole mesh is one draw call.
Two things that break frustum culling
- Stale bounding volumes. If you've mutated vertex positions in a custom geometry (the wavy plane in Article 02, or any displacement in the vertex shader), the cached bounding sphere is wrong. The mesh might get culled even when it's visible. Fix:
geometry.computeBoundingSphere()after any CPU-side vertex edits. For shader-side displacement, setmesh.frustumCulled = falseso it never gets culled. - Huge meshes that shouldn't be culled. A sky dome at the origin or a massive terrain may have a bounding volume that's smaller than its visible extent. Set
frustumCulled = falseto force it to always render.
When should I turn cull off?
Almost never. The default (true) is right for 99% of meshes. You only flip
it off when:
- The mesh animates beyond its bounding sphere (shader displacement).
- The mesh is larger than its geometry suggests (a sky dome using a small sphere stretched huge).
- You're doing your own custom culling and want to bypass Three's.
LOD — different geometry by distance
A rock 200m away doesn't need 10,000 triangles. Swap it for a 200-triangle version. That's Level Of Detail:
const lod = new THREE.LOD();
lod.addLevel(highPolyMesh, 0); // close: high detail
lod.addLevel(mediumMesh, 10); // from 10 units out
lod.addLevel(lowPolyMesh, 30); // from 30 units out
lod.addLevel(impostorSprite, 100); // very far: a sprite
scene.add(lod);
Each addLevel takes an object and a distance threshold. Three.js swaps which
level is visible based on camera distance automatically. The swap is instant (no blending) —
fine at distance, but up close you can see the pop. Mitigation: make the LOD distances
large enough that the user doesn't notice.
Auto-LOD from a single model
Build LODs manually in Blender, or use SimplifyModifier from addons for
runtime mesh simplification (expensive; do once at load time, not per frame).
The profiling workflow
When a scene runs slow, don't guess. Measure:
- Open Chrome DevTools → Performance tab.
- Click Record. Let the scene run for 3 seconds. Stop.
- Look at the flame graph. The two things that kill Three.js frames:
WebGLRenderer.rendertaking most of the frame — draw-call bound. Batch.- A custom animation function taking most of the frame — JS work too heavy. Optimize the loop, move to instancing, reduce geometry.
- Also look at the Rendering tab → Frame Rendering Stats for the GPU-side story.
For Three-specific stats, use stats.js:
import Stats from 'three/addons/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
renderer.setAnimationLoop(() => {
stats.begin();
renderer.render(scene, camera);
stats.end();
stats.update();
});
Click the little panel to cycle through FPS, ms, memory.
Other wins
Share materials and geometries
Reusing instances is easier than you'd think. If two meshes have the same look:
const sharedMat = new THREE.MeshStandardMaterial({ color: 0xff6600 });
const sharedGeom = new THREE.BoxGeometry();
for (let i = 0; i < 100; i++) {
scene.add(new THREE.Mesh(sharedGeom, sharedMat));
}
They still each count as one draw call (unless you batch) — but they share the GPU vertex buffer and the compiled shader program. Less memory, less shader recompile time.
Texture compression
Covered in Article 04. KTX2 keeps textures compressed in GPU memory — 4–8× smaller than a decoded JPG, no quality loss at realistic sizes.
Render on demand
For static scenes (product viewer, 3D icon that only animates on hover), don't run a loop at all:
controls.addEventListener('change', () => {
renderer.render(scene, camera);
});
renderer.render(scene, camera); // initial draw
The scene renders only when the user interacts. Battery-friendly. Free performance.
Shadow cost
Every shadow-casting light has its own render pass over every shadow-casting mesh.
Directional = 1 extra pass. Point = 6 passes (cubemap). Turn off castShadow on
anything that doesn't need it.
scene.traverse((o) => {
if (o.isMesh && o.name.startsWith('Background_')) {
o.castShadow = false;
o.receiveShadow = false;
}
});
Pixel ratio cap
Set in Article 01 but worth restating:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
A phone reporting devicePixelRatio = 3.5 renders 12× more pixels than a
desktop at 1×. Cap it at 2. Save 60% of fragment work on every frame.
A real performance checklist
- Check
renderer.info.render.calls. Target < 100 mobile, < 500 desktop. - If high: identify the 10 biggest contributors (most repeated mesh type). Convert them to
InstancedMesh. - Turn off shadows on non-hero geometry.
- Cap pixel ratio at 2.
- For very dense scenes (> 5k instances spread across a big world):
BatchedMesh+ LOD. - KTX2-compress all textures. Use
glTF-Transform. - For static scenes: render on demand.
- Profile before/after with DevTools + stats.js. If a change doesn't measurably help, revert it.
Common first-time pitfalls
- Switching to InstancedMesh made no difference. You were GPU-bound already, or your old scene was already batching via merged geometry.
- InstancedMesh renders nothing. Forgot
instanceMatrix.needsUpdate = trueafter setMatrixAt calls. - Instances at wrong positions.
dummy.updateMatrix()missing — you modified position but didn't compose the matrix. - BatchedMesh throws "out of budget".
maxVertices/maxIndicestoo small for the geometries you added. Set generously. - Per-instance colors don't show. Material doesn't support vertexColors; InstancedMesh colors auto-route, but RawShaderMaterial ignores them.
- Frustum culling "isn't working" — your mesh has
frustumCulled = truebut the bounding sphere is stale.geometry.computeBoundingSphere()after any vertex edits.
Exercises
- Take the Article 06 solar system and rebuild the asteroid belt (15,000 rocks) with
InstancedMesh. Measure the draw-call count before and after. - Build a grass field: 50,000 grass blades, instanced, per-instance random rotation + scale, a vertex shader that sways them based on
uTime. - Add LOD to the demo here: high-poly box close, low-poly box at 30 units, point sprite at 100 units. Measure the triangle count at different zoom levels.
What's next
Article 10 — React Three Fiber + Shipping. The final episode. Port a
vanilla Three.js scene to R3F in ~40 lines, meet @react-three/drei and
@react-three/postprocessing, and deploy the whole series' hero scene to
Vercel / Cloudflare Pages.