Draw-Call Batching

The cure for a CPU-bound frame: send fewer commands, not less data. Instancing draws thousands of independent copies in one call; merging welds static clutter into one buffer. Each buys the same thing and trades away something different — the demo's gold pebble shows exactly what.

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.