Three.js From Zero · Article 09

Performance: Instancing, LOD, Draw Calls

Article 09 · Three.js From Zero

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.

drag to orbit

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. InstancedMesh is one geometry.
  • You need frustum culling per-instance. InstancedMesh is 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

InstancedMeshBatchedMesh
Draw calls11
Same geometry requiredYesNo — can register many
Per-instance cullingNoYes
Added inForeverr164 (2024)
Use whenAll instances identical shapeMixed geometries or wide-spread world
Still use InstancedMesh by default — it's simpler, more widely supported by addons (drei, etc.), and "good enough" for typical use cases. Reach for BatchedMesh when 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 diagram: a camera with a pyramid-shaped view, green boxes inside the pyramid labeled 'drawn', grey dashed boxes outside labeled 'skipped', plus a before/after comparison showing cull ON = 487/5000 calls vs cull OFF = 5000/5000 calls
The camera sees a pyramid of space. Boxes inside are drawn; boxes outside are skipped. Skipping is free performance.

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:

StrategyCull granularityEffect
Individual meshesPer meshHuge. Each box is tested independently. Off-screen → skipped entirely.
InstancedMeshThe whole instance groupAlmost none. If any part of the mesh bounds is in the frustum, ALL instances draw, even ones behind you.
BatchedMeshPer instance, GPU-sidePartial. 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, set mesh.frustumCulled = false so 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 = false to 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:

  1. Open Chrome DevTools → Performance tab.
  2. Click Record. Let the scene run for 3 seconds. Stop.
  3. Look at the flame graph. The two things that kill Three.js frames:
    • WebGLRenderer.render taking 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.
  4. 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

  1. Check renderer.info.render.calls. Target < 100 mobile, < 500 desktop.
  2. If high: identify the 10 biggest contributors (most repeated mesh type). Convert them to InstancedMesh.
  3. Turn off shadows on non-hero geometry.
  4. Cap pixel ratio at 2.
  5. For very dense scenes (> 5k instances spread across a big world): BatchedMesh + LOD.
  6. KTX2-compress all textures. Use glTF-Transform.
  7. For static scenes: render on demand.
  8. 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 = true after setMatrixAt calls.
  • Instances at wrong positions. dummy.updateMatrix() missing — you modified position but didn't compose the matrix.
  • BatchedMesh throws "out of budget". maxVertices / maxIndices too 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 = true but the bounding sphere is stale. geometry.computeBoundingSphere() after any vertex edits.

Exercises

  1. Take the Article 06 solar system and rebuild the asteroid belt (15,000 rocks) with InstancedMesh. Measure the draw-call count before and after.
  2. Build a grass field: 50,000 grass blades, instanced, per-instance random rotation + scale, a vertex shader that sways them based on uTime.
  3. 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.