Core Idea
The diagnosis lives on the CPU/GPU-bound page: every draw call is one order the CPU must prepare and the driver must validate, at a cost nearly independent of how much it draws — one pebble costs almost as much to order as two thousand. This page is the cure. Flip the modes above and watch both numbers: the same 2,000 pebbles cost 2,000 orders, or exactly one — and the measured CPU submit time collapses with them.
Not the Data — the Commands
The fix is fewer commands, not less data, because the data never travels per frame: geometry lives in GPU-side buffers, uploaded once. What crosses each frame is commands and state — and moving an object costs no extra call either (its model matrix is 16 floats riding the call it already has). A draw call is spent per (geometry + material) batch, moving or not: hundreds of static objects drawn as separate meshes are hundreds of calls, even though nothing moves. A rough budget: a mobile-friendly web scene wants ~100–200 calls; scenes issuing 800+ are common and pay for it on every frame.
Instancing — Copies That Stay Individuals
InstancedMesh uploads one geometry plus a buffer of per-instance matrices, and the GPU draws all copies in one call:
const forest = new THREE.InstancedMesh(geometry, material, 2000)
forest.setMatrixAt(i, matrix) // each copy keeps its own transform
forest.setColorAt(i, color) // ...and its own tint
Switch the demo to instanced: one call, and the gold pebble keeps bobbing — per-instance matrices are updatable every frame (instanceMatrix.needsUpdate). The trade: every instance shares one geometry and one material; variation must arrive as per-instance attributes (matrix, color) rather than as different objects.
Merging — Clutter Welded Once
BufferGeometryUtils.mergeGeometries bakes many static pieces into one geometry, drawn once. Switch to merged: one call again — but the gold pebble freezes mid-bob. Its vertices are welded into the shared buffer; there is no object left to move, and none to frustum-cull individually either (the whole blob draws even when only its corner is visible — merging things that should have been culled inflates triangle cost). One material for everything is part of the deal, which is why merging travels with texture atlases — many images packed into one so one material can dress many things (texturing).
The Decision, and When to Do Nothing
| The pieces… | Use |
|---|---|
| move or recycle independently | instancing |
| are static clutter, glued down forever | merging |
| number a dozen | nothing — the overhead is trivial |
| need individual frustum culling | instancing (or nothing), never merging |
And after cutting calls, re-measure: the bottleneck moves. A scene can drop from hundreds of calls to a handful with the frame time barely moving — because it was only partly CPU-bound, and the wall is now fill or vertices, which batching does not touch (LOD and mobile optimization hold those levers).
Connections
Batching is the remedy half of the CPU-bound story — fewer orders from the manager, same factory output. Instancing is a per-instance buffer doing what per-object uniforms otherwise would; merging is mesh surgery performed once at load. Both ship in Three.js as InstancedMesh and BufferGeometryUtils, and both leave the GPU-side budgets — triangles and pixels — exactly where level of detail found them.