The single biggest performance trick in real-time 3D is don't issue one draw call per object. The CPU side of WebGL/WebGPU spends most of its budget setting up state for each draw call; reducing 9,000 draw calls to 300 takes a slideshow back to 60 fps without changing visual quality. This post covers the four standard tools — InstancedMesh (the "clone trick" for many copies of one shape), BatchedMesh (many different shapes, one material), geometry merging (one giant mesh), and LOD (less detail for distant objects) — and the draw-call budget rules to know when to reach for each.

Series map: 00 Foundations → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows → 04 Textures & PBR → 05 Asset Pipeline → 06 Animation → 07 Instancing & Batching (this post) → 08 Low-End Perf → 09 Shaders → 10 Post-Processing → 11 Physics → 12 R3F → 13 AI 3D.


Learning Outcome

  • Recognise the draw-call budget and how to measure it.
  • Use InstancedMesh to render N copies of one geometry with per-instance transforms + colours.
  • Use BatchedMesh (since r156) when geometries differ but the material is shared.
  • Merge static geometries into one with BufferGeometryUtils.mergeGeometries.
  • Use LOD (Level of Detail) so distant objects render simplified versions.
  • Pick the right tool for "10,000 trees", "1,000 unique buildings", "a city of 50,000 windows".

Chapter 1 — The draw-call budget

Every Mesh in the scene typically produces one draw call. Each draw call has CPU overhead: bind shader, set uniforms, set attribute pointers, issue the GPU command. Modern GPUs draw millions of triangles per frame easily; CPUs choke on thousands of draw calls per frame.

Rough targets for 60 fps:

Device Safe draw-call count
Desktop / high-end laptop ≤ 1,000
Mid-range mobile (mid-2020s) ≤ 100-200
Low-end mobile / older hardware ≤ 50-100

Triangle counts matter much less than draw-call counts. 1 mesh with 500k triangles renders fine; 5,000 meshes with 100 triangles each will not.

Measure with renderer.info

1
2
3
console.log(renderer.info.render);
// { frame: ..., calls: 87, triangles: 152340, points: 0, lines: 0 }

If calls ≫ 200 and the scene is laggy, the problem is the draw-call count, not GPU rasterisation. The fix is one of the patterns in this post.

Plain meshes, InstancedMesh, BatchedMesh and merged geometry compared by draw calls, with draw-call budgets


Chapter 2 — InstancedMesh — the "clone trick"

InstancedMesh draws N copies of one geometry with one material in one draw call, each copy with its own 4×4 matrix (transform) and optionally its own colour.

This is the answer to "I have 10,000 trees / rocks / windows / grass blades / particles".

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import * as THREE from 'three';

const COUNT = 5000;
const geo = new THREE.BoxGeometry(0.4, 0.4, 0.4);
const mat = new THREE.MeshStandardMaterial({ color: 0x88aa66 });
const mesh = new THREE.InstancedMesh(geo, mat, COUNT);

const dummy = new THREE.Object3D(); // reusable for matrix math
for (let i = 0; i < COUNT; i++) {
dummy.position.set(
(Math.random() - 0.5) * 200,
0,
(Math.random() - 0.5) * 200
);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.setScalar(0.5 + Math.random() * 1.5);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;

mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);

That entire scene = 1 draw call regardless of COUNT.

Per-instance colour

1
2
3
4
5
6
7
const color = new THREE.Color();
for (let i = 0; i < COUNT; i++) {
color.setHSL(Math.random(), 0.5, 0.6);
mesh.setColorAt(i, color);
}
mesh.instanceColor.needsUpdate = true;

The material respects per-instance colour automatically.

Animating instances

1
2
3
4
5
6
7
8
9
10
11
renderer.setAnimationLoop(() => {
const t = clock.getElapsedTime();
for (let i = 0; i < COUNT; i++) {
dummy.position.y = Math.sin(t + i * 0.1) * 0.5;
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
});

Reading/writing every instance every frame is fine up to ~10k instances on the main thread. For 100k+ instances, move the update to a Web Worker or use a vertex shader (Post 09) that computes positions from a base attribute + instance id.

Per-instance picking + visibility

1
2
3
4
5
6
7
8
9
10
const hits = raycaster.intersectObject(mesh);
if (hits.length) {
const i = hits[0].instanceId; // which instance was clicked
// hide it:
dummy.scale.setScalar(0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.instanceMatrix.needsUpdate = true;
}

(Three.js doesn't have first-class per-instance visibility; scale = 0 is the common trick.)


Chapter 3 — BatchedMesh — many shapes, one material

InstancedMesh requires every instance to share the same geometry. BatchedMesh (added in r156) lifts that restriction — many different geometries can be rendered in one draw call as long as they share a material.

Use when: a city of unique buildings, a furniture catalogue, a forest with several tree species.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import * as THREE from 'three';

const SHARED = new THREE.MeshStandardMaterial({ color: 0xcccccc });

const batch = new THREE.BatchedMesh(
/* maxInstanceCount */ 200,
/* maxVertexCount */ 200_000,
/* maxIndexCount */ 600_000,
SHARED
);

const cube = new THREE.BoxGeometry(1, 1, 1);
const cone = new THREE.ConeGeometry(0.5, 1, 16);
const torus = new THREE.TorusGeometry(0.5, 0.2, 12, 24);

const cubeId = batch.addGeometry(cube);
const coneId = batch.addGeometry(cone);
const torusId = batch.addGeometry(torus);

for (let i = 0; i < 200; i++) {
const id = batch.addInstance([cubeId, coneId, torusId][i % 3]);
const m = new THREE.Matrix4().compose(
new THREE.Vector3((i - 100) * 1.5, 0, 0),
new THREE.Quaternion(),
new THREE.Vector3(1, 1, 1)
);
batch.setMatrixAt(id, m);
}

scene.add(batch);

200 different-shape objects, 1 draw call. The original cube/cone/torus geometries are merged into the batch's internal buffer.

When BatchedMesh underperforms

The current Three.js BatchedMesh emulates instancing via multiDraw rather than multiDraw*Instanced. For workloads of "many copies of the same geometry", InstancedMesh is faster. For "many different geometries, same material", BatchedMesh wins.

Decision:

  • All instances identical geometry → InstancedMesh.
  • Different geometries, same material → BatchedMesh.
  • Different materials → can't batch directly; merge materials (Chapter 6) or accept separate draw calls.

Chapter 4 — Geometry merging (one big mesh)

For static scenery where you don't need per-object updates, merge geometries into a single big mesh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { BufferGeometryUtils } from 'three/addons/utils/BufferGeometryUtils.js';

const pieces = [];
for (let i = 0; i < 100; i++) {
const g = new THREE.BoxGeometry(1, 1, 1);
g.translate(Math.random() * 50, 0, Math.random() * 50);
pieces.push(g);
}

const merged = BufferGeometryUtils.mergeGeometries(pieces);
const mesh = new THREE.Mesh(merged, new THREE.MeshStandardMaterial({ color: 0xaaaaaa }));
scene.add(mesh);

pieces.forEach((g) => g.dispose());

1 draw call, fully baked. No per-object animation possible — to move one cube, you'd modify the whole merged geometry. Use for static decoration (walls, ground details, fixed environment).

Multi-material merge

1
2
3
const merged = BufferGeometryUtils.mergeGeometries(pieces, /* useGroups */ true);
const mesh = new THREE.Mesh(merged, [matA, matB, matC]);

Groups define which sub-range uses which material — still one geometry but multiple draw calls (one per group). Useful for merging objects that genuinely need different materials while keeping vertex data shared.


Chapter 5 — LOD (Level of Detail)

A model 100 m away doesn't need 50k triangles. LOD swaps to a simpler version automatically:

1
2
3
4
5
6
7
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0); // < 10 m
lod.addLevel(midDetailMesh, 10); // 10-30 m
lod.addLevel(lowDetailMesh, 30); // 30-100 m
lod.addLevel(impostorMesh, 100); // > 100 m (often a billboard)
scene.add(lod);

Each tier is a different mesh (different geometry). Three.js picks based on camera distance per frame.

Generate LODs

  • Manual in Blender (decimate modifier).
  • gltf-transform simplify with --ratio 0.5 etc.
  • meshoptimizer has automatic LOD generation; some glTF pipelines run it server-side.

Impostor billboards

For very distant objects: replace the 3D model with a Sprite (textured 2D billboard) of a pre-rendered angle. Used heavily in open-world games for forests / cities.


Chapter 6 — Merging materials with a texture atlas

To batch objects that look different (different colours, different decals), pack their textures into a single atlas + remap UVs. Then they share one material → can be merged or instanced.

Tools: Blender bake to atlas; gltf-transform dedup; manual UV editing.

This combination — atlas + InstancedMesh — is how you draw a forest with many different leaf colours in 1 draw call.


Chapter 7 — Frustum culling + occlusion culling

Three.js culls per-mesh: meshes whose bounding sphere is outside the camera frustum are skipped.

1
2
mesh.frustumCulled = true;       // default

For InstancedMesh and BatchedMesh, frustum culling tests the entire batch's bounding sphere — if any part is in view, the whole batch renders. To get per-instance culling, you have two choices:

  1. Manual chunking — split a 10k-instance forest into 100 chunks of 100 instances. Each chunk has its own InstancedMesh with a tighter bounding sphere.
  2. GPU culling with BatchedMesh.setVisibleAt(i, bool) — newer Three.js supports per-instance visibility for BatchedMesh.

For occlusion culling (skipping objects hidden by walls), Three.js doesn't ship native support. Libraries like three-mesh-bvh can accelerate visibility queries; production engines often use a custom hierarchical-Z buffer approach.


Chapter 8 — Putting it together — recipes

Scenario Pattern
10,000 grass blades InstancedMesh + vertex shader to animate sway
Forest of 3 tree species BatchedMesh, or 3 InstancedMeshes (one per species)
City of 1,000 unique buildings BatchedMesh with one shared atlas material
Static room (walls, floor, props) merge geometries into one mesh per material
Open-world terrain LOD with 4-5 levels + impostor billboards for distant chunks
Particle system InstancedMesh or Points with custom shader
Skinned crowd (many animated characters) InstancedMesh + GPU-skinning shader (advanced)

Chapter 9 — Hidden cost: tools that hurt batching

Thing Cost
Different materials breaks batching — each material is a separate draw call
castShadow = true per mesh doubles the draw calls (one for camera, one for shadow camera)
Transparency each transparent mesh sorted + drawn separately
Postprocess passes each pass = full-screen draw
Per-instance materials impossible — instances share material

Plan visual variation that doesn't break batching: per-instance colour, texture atlas, vertex-shader-driven variation rather than separate materials.


Chapter 10 — Profiling tools

Tool What
renderer.info numerical counters per frame (calls, triangles, programs)
stats-gl overlay (FPS, CPU ms, GPU ms)
r3f-perf (R3F) in-scene perf overlay with breakdown by mesh
Spector.js full WebGL frame capture; see every command
Chrome DevTools Performance panel flame graph of JS + GPU
WebGPU Inspector (extension) frame capture for WebGPU

The workflow: notice slow framerate → check calls in renderer.info → if high, apply the patterns in this post → re-measure.


Practice Exercises

Exercise 1 — 10,000 cubes — naive vs instanced

Render 10,000 cubes as individual new THREE.Mesh(...). Note the FPS and renderer.info.calls. Replace with an InstancedMesh. Compare.

Exercise 2 — Animated InstancedMesh

The 10,000 cubes from Exercise 1: animate their Y positions with a sin wave per-instance. Confirm framerate stays high.

Exercise 3 — BatchedMesh zoo

Build a BatchedMesh holding 100 mixed shapes (cube, cone, sphere, torus) all using the same material. Confirm 1 draw call.

Exercise 4 — LOD swap

Take any character model. Generate 3 LODs (high/mid/low) — use the decimate modifier in Blender or just three boxes for the placeholder. Wire up THREE.LOD. Walk away from the model in OrbitControls; watch the swap.

Exercise 5 — Per-instance click + hide

Implement Chapter 2's per-instance picking. Click an instance → scale to 0. Bonus: animate the scale to 0 with a tween.

Exercise 6 — Chunked InstancedMesh

For a 200×200 forest, split into 10×10 chunks of 20×20 trees. Each chunk = one InstancedMesh. Confirm frustum culling now skips chunks behind the camera.


Cheat-sheet

What How
Draw-call target < 100 mobile, < 1000 desktop
Measure renderer.info.render.calls
One geo many copies new THREE.InstancedMesh(geo, mat, N)
Set instance transform setMatrixAt(i, matrix) + instanceMatrix.needsUpdate = true
Per-instance colour setColorAt(i, color) + instanceColor.needsUpdate = true
Per-instance pick raycaster.intersectObject(mesh) → hit.instanceId
Different geos same material BatchedMesh
Static scenery BufferGeometryUtils.mergeGeometries(arr)
Distance-based detail THREE.LOD with multiple addLevel(mesh, dist)
Atlas trick one material across many "different-looking" objects
Frustum cull a forest split into chunks
Batched shadows mesh.castShadow = true still works for instanced/batched
Profile stats-gl + Spector.js

What's next

Post 08 focuses on squeezing Three.js onto low-end and mobile devices — pixel-ratio capping, texture/triangle budgets, on-demand rendering, shader-cost awareness, and the diagnostic flow for "my scene runs at 12 fps on a phone".


References

Note: this post is in progress. Planned additions: GPU-skinned crowd implementation; baking animations into a texture; impostor billboard pipeline; comparison benchmarks (10k instances on desktop vs phone).