1294 MODELS INDEXED Account
GUIDE · THREE.JS PERFORMANCE

InstancedMesh vs merged geometry vs BatchedMesh: what to use for 10,000 props

UPDATED
21 MIN READ · BY 3D ASSET

For many copies of a few models, use InstancedMesh: it took our 10,000-prop yard from 26 ms to 2.2 ms a frame and stays cheap to move and pick. Merge geometry only for static props nobody clicks — just as fast, but it copies every vertex. Use BatchedMesh when many different meshes share one material and per-object culling matters.

The short version

Your sceneUse
Many copies of a few models (trees, rocks, crates, lamps)InstancedMesh, one per mesh part
The same, spread over a large world with the camera inside itInstancedMesh per spatial chunk, so culling still works
Static, never moved or picked, many small piecesMerged geometry per chunk
Many different models with one material, culling mattersBatchedMesh; for opaque materials, turn sortObjects off
A few hundred objects or fewerLeave them as separate meshes

The reasons, the numbers and the traps are below; the full decision guide is at the end. Everything here is about WebGLRenderer; WebGPURenderer has its own instancing and batching paths and was not measured.

The test: 10,000 props, 20 types, one material

A large supply yard filled with crates, barrels, pallets, sandbags and generators in neat blocks
The test scene: 10,000 supply props from our Apocalyptic pack — crates, barrels, pallets, sandbags, generators, cable spools — in blocks across a 336 m yard. 1.24 M triangles in total. This frame is rendered with one InstancedMesh per prop type.

“Should I use instancing, merging or BatchedMesh?” is usually answered with “instancing for identical objects, merging for static ones, batching for everything else”. That is roughly right, and it hides most of what actually decides the frame: how much work three.js does on the CPU per object, what the GPU has to process when only part of the scene is visible, how much memory each option duplicates, and what it costs to move, hide or click one prop afterwards.

So we built a scene that looks like a real use case and drew it seven ways. The props are 20 real models from our Apocalyptic pack. They share one texture atlas, so all of them can use a single material — the condition under which merging and batching can collapse the whole scene into one draw. Each prop has between 42 and 360 triangles, each placement has a random rotation and a 0.9–1.15 scale.

VariantWhat it isDraw calls
10,000 MeshOne THREE.Mesh per prop. Geometry and material are shared, nothing is copied. The baseline everybody starts with.9,555
10,000 Mesh, frozenThe same, with matrixAutoUpdate and matrixWorldAutoUpdate off — the usual first “optimisation”.9,555
InstancedMeshOne InstancedMesh per prop type: 20 objects, each holding a 4×4 matrix per copy.21
InstancedMesh, chunkedThe same, split into 36 squares of 64 m, so each square can be culled separately.667
MergedEvery prop’s vertices transformed and copied into one BufferGeometry with mergeGeometries.2
Merged, chunkedOne merged mesh per 64 m square.36
BatchedMeshAll 20 geometries stored once in one BatchedMesh, 10,000 instances. Defaults: per-instance culling and sorting on.2
BatchedMesh, no sortThe same with sortObjects = false.2

The draw-call column counts the main pass from the overview camera, as reported by renderer.info.render.calls. The ground plane is one extra call in every row; separate meshes outside the view are culled, which is why the first two rows show fewer than 10,000.

How we measured. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome, ANGLE → Direct3D 11, three.js r180, 1600×900, no vsync. For each variant the page is reloaded and the scene rebuilt, then 120 frames are timed; each variant was measured on three fresh page loads, interleaved, and we report the median. We record three numbers per frame: the main-thread time spent inside renderer.render(), the GPU time from a EXT_disjoint_timer_query_webgl2 query, and the whole frame — from the start of render() until a one-pixel readPixels returns, which forces the browser to finish both. Two cameras: an overview with almost the whole yard in view (96% of the props; the rest fall just outside the frame), and a person-height camera inside the yard whose view frustum — the pyramid of space the camera can see — contains 60% of them, most of those far away and tiny.

Two caveats on reading the numbers. The “frame” is serialised: the CPU work finishes, then we wait for the GPU. In a real requestAnimationFrame loop the two overlap, so a frame costs closer to the larger of the two than to their sum. And the GPU timer measures elapsed time on the GPU’s timeline, including the gaps where it waits for the CPU to send the next command — so for 10,000 separate meshes much of the “GPU time” is the GPU idling between draws, not work.

Ground-level view across the supply yard with crates, a cable spool, sandbags and pallets in the foreground
The ground camera (sky cropped). 40% of the props are behind it or outside the field of view — which is where the four options stop behaving alike.

What each option sends to the GPU

A draw call is one request to the GPU: “draw these triangles with this shader and these settings”. Before each one, three.js does work on the main thread — frustum test, sorting, picking the program, uploading uniforms such as the object’s matrices, binding buffers — and the browser translates the call for the native driver (ANGLE turns it into Direct3D on Windows). That per-call cost is the thing all three techniques exist to remove. They remove it in different ways:

10,000 props painted in thousands of different colours, one per draw call
Separate meshes: one colour per draw call — 9,555 of them.
The same props painted in 20 colours, one per prop type
InstancedMesh: one draw per prop type — 20.
Props painted in colours that change per type and per 64 metre square
Chunked instancing: one draw per type per 64 m square.
All props painted in one colour
Merged: the whole yard is one draw.
Props painted in 36 colours, one per square
Merged per chunk: 36 draws.
All props painted in one colour
BatchedMesh: one multi-draw call for everything.

InstancedMesh keeps one copy of a geometry and a buffer of per-copy matrices (16 floats, 64 bytes each). The vertex shader reads the matrix for the copy it is drawing. One call draws every copy, but only of one geometry with one material: 20 prop types means 20 calls, and a model built from three meshes means three InstancedMesh objects.

Merged geometry bakes each prop’s transform into its vertices and concatenates them. The GPU sees one ordinary mesh. Nothing about the original props survives: there is no per-prop matrix, visibility or id unless you build that bookkeeping yourself.

BatchedMesh stores each distinct geometry once in a shared buffer and keeps per-instance matrices in a texture. Every frame, before drawing, it walks all its instances in JavaScript: it frustum-culls each one, optionally sorts them, and writes the list of visible ranges. Then it issues them in one multiDrawElementsWEBGL call. That makes it the only option that combines one draw call, different geometries and per-object culling — and the only one with a per-frame loop over every object.

Separate MeshInstancedMeshMergedBatchedMesh
Different geometries in one drawnonoyesyes
Frustum cullingper objectwhole meshwhole meshper instance
Move / hide one propset transformsetMatrixAtrewrite verticessetMatrixAt, setVisibleAt
Raycast returnsthe objectinstanceIdfaceIndex onlybatchId
Per-prop colourown materialsetColorAtvertex colourssetColorAt
Geometry memoryonce per typeonce per typeonce per proponce per type
GPU data for 10,000 props here0.2 MB0.8 MB87 MB0.8 MB
JS heap added by building14 MB1.2 MB88 MB1.7 MB

Separate meshes upload almost nothing extra to the GPU — their matrices travel as uniforms with every draw — but 10,000 Object3Ds with their matrices and bookkeeping cost 14 MB of JavaScript heap.

Results: the draw-call wall is real, the rest is close

Frame time, render() + GPU, median of 360 frames (lower is better)

  • Overview: 96% of props in view
  • Ground camera: 60% of props in the view frustum
0 ms2 ms4 ms6 ms8 ms10 ms10,000 MeshOverview: 96% of props in view, 10,000 Mesh: 26 ms» 26 msGround camera: 60% of props in the view frustum, 10,000 Mesh: 18 ms» 18 ms10,000 Mesh, frozen matricesOverview: 96% of props in view, 10,000 Mesh, frozen matrices: 23 ms» 23 msGround camera: 60% of props in the view frustum, 10,000 Mesh, frozen matrices: 17 ms» 17 msInstancedMesh × 20Overview: 96% of props in view, InstancedMesh × 20: 2.2 ms2.2 msGround camera: 60% of props in the view frustum, InstancedMesh × 20: 1.8 ms1.8 msInstancedMesh, 64 m chunksOverview: 96% of props in view, InstancedMesh, 64 m chunks: 3.9 ms3.9 msGround camera: 60% of props in the view frustum, InstancedMesh, 64 m chunks: 3.5 ms3.5 msMerged, 1 meshOverview: 96% of props in view, Merged, 1 mesh: 2.0 ms2.0 msGround camera: 60% of props in the view frustum, Merged, 1 mesh: 1.5 ms1.5 msMerged, 64 m chunksOverview: 96% of props in view, Merged, 64 m chunks: 2.6 ms2.6 msGround camera: 60% of props in the view frustum, Merged, 64 m chunks: 1.3 ms1.3 msBatchedMeshOverview: 96% of props in view, BatchedMesh: 5.1 ms5.1 msGround camera: 60% of props in the view frustum, BatchedMesh: 3.4 ms3.4 msBatchedMesh, sortObjects offOverview: 96% of props in view, BatchedMesh, sortObjects off: 3.2 ms3.2 msGround camera: 60% of props in the view frustum, BatchedMesh, sortObjects off: 2.2 ms2.2 ms
Each frame is timed from the start of renderer.render() until a one-pixel readPixels returns, so it includes both the JavaScript and the GPU work. The bars for 10,000 separate meshes are clipped at 10 ms. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome, ANGLE → Direct3D 11, three.js r180, 1600×900.
All values: main-thread time inside render(), GPU time, whole frame
MethodDraw callsJS (overview)GPU (overview)Frame (overview)Frame (ground)
10,000 Mesh9,55521 ms17 ms26 ms18 ms
10,000 Mesh, frozen matrices9,55520 ms16 ms23 ms17 ms
InstancedMesh × 20210.08 ms1.5 ms2.2 ms1.8 ms
InstancedMesh, 64 m chunks6671.4 ms1.6 ms3.9 ms3.5 ms
Merged, 1 mesh20.05 ms1.4 ms2.0 ms1.5 ms
Merged, 64 m chunks360.09 ms1.8 ms2.6 ms1.3 ms
BatchedMesh22.4 ms1.6 ms5.1 ms3.4 ms
BatchedMesh, sortObjects off20.57 ms1.7 ms3.2 ms2.2 ms

Ten thousand separate meshes cost 26 ms a frame from the overview camera, with 96% of them in view — over the 16.7 ms budget of a 60 Hz display before a single other system has run. Almost all of it is per-call overhead: 21 ms of main-thread time inside render(). The alternatives bring the frame down by 5.0× (BatchedMesh) to 13× (merged geometry).

Freezing the matrices barely helped (23 ms against 26 ms, within the run-to-run noise of this row). Recomposing 10,000 matrices costs a fraction of a millisecond; the time goes into culling, sorting, binding and issuing 10,000 draws — about 2.2 µs of main-thread time per draw here. Freezing is a good habit for static objects, but it is not a fix for object count.

Among the three, the differences are small from the overview camera: merged geometry is the fastest at 2.0 ms, instancing follows at 2.2 ms, BatchedMesh is last at 5.1 ms. The first gap is a tenth of a millisecond of GPU time (1.5 ms against 1.4 ms), close to the noise. If it is real, the likely cause is that every instanced vertex also reads its instance matrix (an extra vertex fetch, i.e. memory read) and multiplies by it, and that 20 draws replace 2. The batch is last for a different reason, covered below: its main-thread time is 2.4 ms, against 0.08 ms for instancing.

Our numbers measure a fast desktop GPU behind Chrome’s Direct3D translation layer. On a phone or an integrated GPU everything is slower, and the per-call overhead of separate meshes grows faster than GPU time — so the gap between the first rows and the rest widens. Measure on the weakest device you support.

Culling: InstancedMesh draws everything or nothing

From the ground camera, 40% of the props are outside the view. Here the options stop being equivalent. three.js frustum-culls an InstancedMesh as one object, using a bounding sphere around all its instances. Our 20 instanced meshes each span the whole yard, so all 20 always pass, and the GPU receives every one of the 1.24 M triangles even when 4,024 props are behind the camera or outside the view.

Top-down map of the yard with every prop marked orange
InstancedMesh × 20: 10,000 of 10,000 props submitted.
Top-down map with the props in the squares that touch the view wedge marked orange
Chunked: 7,842 submitted.
Top-down map with only the props inside the view wedge marked orange
BatchedMesh: 5,976 submitted.

Orange: props whose triangles reach the GPU from the ground camera (white wedge). Grey: culled on the CPU. Computed with the same frustum and bounding spheres three.js uses.

Ground cameraTriangles submittedGPU timeFrame
10,000 Mesh0.74 M11 ms18 ms
InstancedMesh × 201.24 M1.3 ms1.8 ms
InstancedMesh, 64 m chunks0.97 M1.2 ms3.5 ms
Merged, 1 mesh1.24 M1.0 ms1.5 ms
Merged, 64 m chunks0.96 M0.88 ms1.3 ms
BatchedMesh0.74 M0.93 ms3.4 ms

Separate meshes and BatchedMesh cull per prop and send 0.74 M triangles; whole-yard instancing and merging send all of them. Splitting into 64 m chunks sits in between — and with a 64 m grid it still sends 0.97 M, because a chunk is drawn in full as soon as its bounding sphere — which is larger than the square — touches the view frustum.

And yet the frame barely cares: 1.3 ms of GPU time for instancing against 0.93 ms for the batch. Small, cheaply shaded props cost the GPU very little per triangle, and off-screen triangles are thrown away after the vertex shader without shading a pixel. Culling pays off when what you skip is expensive: dense meshes, heavy vertex shaders (skinning, wind on foliage), and above all shadow maps, where each cascade or shadow-casting light draws the scene again.

Chunking is not the only way to get culling back. Libraries such as @three.ez/instanced-mesh (InstancedMesh2) cull each instance on the CPU and draw only the visible ones, optionally with a BVH and per-instance LOD. Without a BVH that is the same kind of per-instance loop BatchedMesh runs; with one, culling skips whole branches of the tree, so its cost grows with what is visible rather than with the total. We did not measure it here.

We hit the opposite trade in our city viewer. Folding repeated buildings, trees and lamps into one InstancedMesh per run removed draw calls, but a 260 m row of trees was then submitted whenever any tree was on screen. Chunking the runs — one instanced mesh per 8 copies — was the compromise. On our benchmark city, against plain clones:

Copies per InstancedMeshDraw calls, worst cameraTriangles submitted, worst camera
2−35%+13%
4−53%+43%
8 (our default)−61%+85%
unlimited−58%+138%

Each cell is the least favourable of four cameras, relative to the same camera with plain clones — so it reads worse than the trade is. The +85% lands on the cheapest view, which submitted few triangles to begin with. What decides the worst frame is the busiest camera, and across all four the peak triangle count rose only 9% at chunks of 8 while the peak draw count fell 65%. Note also that “unlimited” is not even the draw-call optimum: with nothing left to cull, views that used to skip whole runs now submit them.

There is a second, quieter culling trap. The bounding sphere of an InstancedMesh (and of a BatchedMesh) is computed once, on first use. If you later move instances outside it, the whole mesh can be culled while those instances are still on screen — they vanish at the edge of the view. Call computeBoundingSphere() after moving instances far, or after adding them.

BatchedMesh: one draw call, plus a loop you pay for

From the overview camera, BatchedMesh spent 2.4 ms of main-thread time per frame, against 0.08 ms for instancing. The time goes into its onBeforeRender: for each instance it reads the matrix, transforms a bounding sphere, tests it against the frustum, computes a depth, and finally sorts the visible list. That runs every frame, whether anything changed or not, and it runs again for each shadow-casting light (through onBeforeShadow).

Sorting is the largest part. Opaque instances are sorted front to back so the depth test can reject hidden pixels early and reduce overdraw — shading the same pixel several times. For our props, which are cheap to shade, that saving is smaller than the sort itself: turning sortObjects off cut the main-thread time to 0.57 ms and the frame from 5.1 ms to 3.2 ms. That applies to opaque materials only. With a transparent material the same sort orders instances back to front so they blend correctly; switch it off there and the result is visibly wrong.

import * as THREE from 'three';

// types: [{ geometry }], all with the same attributes and one material.
// placements: [{ type, matrix }]
export function buildBatch(types, placements, material) {
  const vertices = types.reduce((n, t) => n + t.geometry.attributes.position.count, 0);
  // All indexed or all non-indexed; a non-indexed batch needs no index space.
  const indices = types.reduce((n, t) => n + (t.geometry.index ? t.geometry.index.count : 0), 0);
  // Size capacities up front. setInstanceCount() and setGeometrySize() can grow
  // them later, but each call reallocates and re-uploads the buffers.
  const batch = new THREE.BatchedMesh(placements.length, vertices, indices, material);
  const geometryIds = types.map((t) => batch.addGeometry(t.geometry));
  const instanceIds = placements.map((p) => {
    const id = batch.addInstance(geometryIds[p.type]);
    batch.setMatrixAt(id, p.matrix);
    return id;
  });
  // Opaque props with a cheap shader: the per-frame sort costs more than it saves.
  // Keep sorting on for transparent materials — it orders them for blending.
  if (!material.transparent) batch.sortObjects = false;
  return { batch, instanceIds };
}

Two more switches matter. With both perObjectFrustumCulled and sortObjects set to false, and no visibility change since the last frame, the loop is skipped entirely — useful for a batch that is always fully on screen. And the single call depends on the WEBGL_multi_draw extension. Without it, three.js issues one drawElements per visible range inside the same object: none of the per-object three.js work of separate meshes (no extra culling, sorting, program or buffer binding), but still one GL call per instance through the browser and driver. On Chrome for Windows the extension is present, but ANGLE runs much the same loop internally (see tiny meshes below). Check renderer.extensions.has('WEBGL_multi_draw') on the devices you target.

With a shadow-casting sun the gap grows, because the shadow pass repeats the loop over all 10,000 instances: BatchedMesh spent 5.8 ms per frame on the main thread (1.1 ms without sorting), while instancing stayed at 0.13 ms. Separate meshes doubled to 41 ms.

With shadows (overview)JS in render()GPUFrame
10,000 Mesh41 ms38 ms46 ms
InstancedMesh × 200.13 ms2.7 ms3.5 ms
Merged, 1 mesh0.09 ms2.5 ms3.0 ms
BatchedMesh5.8 ms3.5 ms11 ms
BatchedMesh, sortObjects off1.1 ms3.4 ms6.1 ms

One r180 detail touches these numbers. When three.js decides whether a BatchedMesh needs a different shader program, it checks object.colorTexture, but the batch keeps its colours in _colorsTexture. For a batch without colours the check always reports a change, so the renderer re-selects the program — from its cache, without compiling — on every pass, main and shadow. The bug is in r180–r185 and fixed in r186; on those versions the setColorAt call recommended below also removes this per-pass work. We isolated it on a 20-instance batch, where the culling loop costs next to nothing: one setColorAt before the first render saved up to about 15 µs a frame without shadows and about 75 µs with a shadow pass (the largest differences between noisy runs, not a strict bound). So a few percent of the JavaScript times above is this bug; the rest is the loop.

Our own production result is a useful warning. In the city viewer we tried folding repeated buckets that hold several different geometries into BatchedMesh. On counts it did exactly what it promises: fewer draws than instancing and far less triangle inflation. In time, on an Intel Iris Xe, the frame got 2.2 ms slower, and three of four test cameras regressed: each batch draw — one multi-draw call carrying its list of ranges — cost more than an ordinary instanced draw, and the per-frame loop added a fixed cost on top. The one camera that improved, by about 10%, was fill-bound — limited by how many pixels the GPU shades, with heavy overdraw and few calls — where per-instance culling saves real GPU work. Batching is off by default in our viewer.

How we split the cost per draw

Within each build we fitted frame time against the number of draws across cameras. The instanced build came out at about 7.1 µs per draw plus about 2.9 ms of fixed cost per frame; the batched build at about 9.6 µs per draw plus about 5.2 ms fixed. Fewer, dearer draws on a higher floor: the batched build removed draws and still lost time.

The lesson is not “BatchedMesh is slow”. A draw call is not a fixed unit of time: its cost depends on what is being drawn and how. Removing draws is only a win if the replacement is cheaper in total — measure the frame, not the counter.

Moving props: who pays for an update

Frame time while props move (overview camera, lower is better)

  • Nothing moves
  • 1,000 props move
  • All 10,000 move
0 ms3 ms6 ms9 ms12 ms15 ms18 ms10,000 MeshNothing moves, 10,000 Mesh: 27 ms» 27 ms1,000 props move, 10,000 Mesh: 26 ms» 26 msAll 10,000 move, 10,000 Mesh: 27 ms» 27 msInstancedMesh × 20Nothing moves, InstancedMesh × 20: 2.3 ms2.3 ms1,000 props move, InstancedMesh × 20: 2.8 ms2.8 msAll 10,000 move, InstancedMesh × 20: 3.1 ms3.1 msInstancedMesh, 64 m chunksNothing moves, InstancedMesh, 64 m chunks: 4.2 ms4.2 ms1,000 props move, InstancedMesh, 64 m chunks: 11 ms11 msAll 10,000 move, InstancedMesh, 64 m chunks: 15 ms15 msMerged, 1 meshNothing moves, Merged, 1 mesh: 1.9 ms1.9 ms1,000 props move, Merged, 1 mesh: 7.8 ms7.8 msAll 10,000 move, Merged, 1 mesh: 10 ms10 msMerged, 64 m chunksNothing moves, Merged, 64 m chunks: 2.0 ms2.0 ms1,000 props move, Merged, 64 m chunks: 9.5 ms9.5 msAll 10,000 move, Merged, 64 m chunks: 13 ms13 msBatchedMeshNothing moves, BatchedMesh: 5.5 ms5.5 ms1,000 props move, BatchedMesh: 5.5 ms5.5 msAll 10,000 move, BatchedMesh: 6.3 ms6.3 msBatchedMesh, sortObjects offNothing moves, BatchedMesh, sortObjects off: 3.9 ms3.9 ms1,000 props move, BatchedMesh, sortObjects off: 3.3 ms3.3 msAll 10,000 move, BatchedMesh, sortObjects off: 3.6 ms3.6 ms
Every 10th prop (or every prop) bobs up and down each frame. The time includes writing the new transforms and uploading them. The rows for separate meshes are clipped at 18 ms. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome, ANGLE → Direct3D 11, three.js r180, 1600×900.
All values
MethodStill1,000 moving: update JS1,000 moving: frameAll moving: update JSAll moving: frame
10,000 Mesh27 ms0.07 ms26 ms0.42 ms27 ms
InstancedMesh × 202.3 ms0.08 ms2.8 ms0.47 ms3.1 ms
InstancedMesh, 64 m chunks4.2 ms0.15 ms11 ms0.79 ms15 ms
Merged, 1 mesh1.9 ms0.37 ms7.8 ms2.1 ms10 ms
Merged, 64 m chunks2.0 ms0.42 ms9.5 ms2.9 ms13 ms
BatchedMesh5.5 ms0.06 ms5.5 ms0.38 ms6.3 ms
BatchedMesh, sortObjects off3.9 ms0.05 ms3.3 ms0.39 ms3.6 ms

Static yards are the easy case. Once props move — physics debris, a crowd of barrels on a conveyor, swaying trees — the cost of writing the new transforms and getting them to the GPU decides the result:

  • InstancedMesh barely notices: writing 1,000 matrices took 0.08 ms, and the whole frame went from 2.3 ms to 2.8 ms. Setting instanceMatrix.needsUpdate uploads the whole matrix buffer — 0.6 MB for 10,000 props, which is nothing.
  • BatchedMesh is equally cheap to write — its matrices live in a texture that is re-uploaded when changed — and keeps its usual loop cost.
  • Merged geometry has no matrices. Moving a prop means rewriting its vertices and re-uploading them: 7.8 ms a frame with 1,000 moving props, because a few scattered props dirty the whole position buffer: 1.59 million vertices, 18 MB, uploaded every frame. Rotating would also mean rewriting normals.
  • Chunked instancing was the surprise: 11 ms with 1,000 moving props. The moving props are spread over the yard, so each of the roughly 700 small instanced meshes uploads its buffer every frame. Many small uploads cost far more than one large one. Keep moving objects out of chunked static batches.

For a few changes in a large InstancedMesh, upload only what changed. Each range is a separate upload call, so this pays off for a handful of contiguous instances, not for 1,000 scattered ones:

mesh.setMatrixAt(i, matrix);
mesh.instanceMatrix.addUpdateRange(i * 16, 16); // 16 floats per instance
mesh.instanceMatrix.needsUpdate = true;
// three.js clears the ranges after the upload.

Hiding or removing an instance is cheap too, if you keep your own id map. The standard trick is to move the last instance into the freed slot and shrink count:

const tmp = new THREE.Matrix4();
// idOfSlot[slot] -> your prop id, slotOfId[id] -> slot
export function removeInstance(mesh, slot, idOfSlot, slotOfId) {
  const last = mesh.count - 1;
  if (slot !== last) {
    mesh.getMatrixAt(last, tmp);
    mesh.setMatrixAt(slot, tmp);
    // copy instanceColor the same way if you use setColorAt
    const moved = idOfSlot[last];
    idOfSlot[slot] = moved;
    slotOfId[moved] = slot;
  }
  mesh.count = last;
  mesh.instanceMatrix.needsUpdate = true;
}

BatchedMesh has this built in: setVisibleAt(id, false) to hide, deleteInstance(id) to free the slot, and setGeometryIdAt(id, geometryId) to swap the geometry of one instance — a per-object LOD switch without leaving the batch. A merged mesh can only hide a prop by collapsing its vertices or by rebuilding.

One trap when you start colouring or highlighting instances: per-instance colour is a shader variant. three.js compiles an InstancedMesh with and without instanceColor as two different programs, and a BatchedMesh with and without its colour texture likewise. So the first setColorAt after the mesh has been drawn makes the next frame compile a new program — a hitch the moment the user hovers the first prop. Create the buffer before the first render, with one white entry: mesh.setColorAt(0, new THREE.Color(1, 1, 1)). The buffer starts out white, so that call changes nothing visible. On a BatchedMesh make it after addInstance, with an id that exists, or it throws. When you change colours later, an InstancedMesh also needs mesh.instanceColor.needsUpdate = true — easy to forget, and nothing changes on screen without it; a BatchedMesh flags its colour texture itself. Our article on shader recompilation measures what such a compile costs.

Picking: the cost of losing the objects

MethodOne raycastWhat the hit tells you
10,000 Mesh0.69 msthe prop’s Mesh
InstancedMesh × 201.4 msinstanceId within the type
InstancedMesh, 64 m chunks0.13 msinstanceId within the chunk
Merged, 1 mesh84 msa triangle (faceIndex) — you map it back
Merged, 64 m chunks8.3 msa triangle in the chunk
BatchedMesh1.4 msbatchId (instance id)

A raycast against one merged mesh took 84 ms — 61× slower than instancing. Every other option first rejects whole props by their bounding volumes and only tests the triangles of the few it hits. The merged mesh has one bounding sphere around everything, so the ray tests all 1.24 M triangles. Do that on every mouse move and it is the slowest part of your frame. Merging per chunk brought it to 8.3 ms; a bounding-volume hierarchy library such as three-mesh-bvh brings it far lower, at the cost of more memory and build time.

The hit also only says which triangle was hit. To know which prop that was, record where each prop’s vertices start while merging, and look the hit up:

import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

export function mergeProps(placements, geometries) {
  const firstVertex = new Uint32Array(placements.length);
  let vertex = 0;
  const parts = placements.map((p, i) => {
    const g = geometries[p.type].clone().applyMatrix4(p.matrix);
    firstVertex[i] = vertex;
    vertex += g.attributes.position.count;
    return g;
  });
  const merged = mergeGeometries(parts);
  parts.forEach((g) => g.dispose());

  // hit -> index of the placement it came from
  function propAt(hit) {
    const v = hit.face.a; // a vertex of the hit triangle, indexed or not
    let lo = 0, hi = firstVertex.length - 1;
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1;
      if (firstVertex[mid] <= v) lo = mid; else hi = mid - 1;
    }
    return lo;
  }
  return { geometry: merged, propAt };
}

Memory and load time: merging copies everything

Instancing and batching store each of the 20 geometries once. Merging stores every vertex of every prop: 87 MB of vertex data against 0.8 MB for instancing — 109× more — and the same again on the JavaScript heap (88 MB measured), unless you drop the CPU copy after upload: call onUpload(function () { this.array = null; }) on every attribute and on the index, or you only save part of it. Compute the bounding sphere before that: once the arrays are dropped, raycasting the mesh throws and its bounds can no longer be recomputed. Building it took 386 ms on the main thread, against 5.0 ms for the instanced version.

With low-poly props that is survivable. With a 5,000-triangle prop it is not: the merged buffer grows with vertices × copies, the instanced one with copies × 64 bytes. We ran into this with far-distance proxies of repeated buildings in our city viewer: baking the copies into the merged proxy geometry would have taken it from about 17 MB to about 160 MB, while one more instance matrix is sixteen floats.

Frame time against prop count, overview camera (log scale)

  • Mesh
  • InstancedMesh × 20
  • Merged, 1 mesh
  • BatchedMesh
  • BatchedMesh, sortObjects off
1.0 ms2.0 ms5.0 ms10 ms20 ms50 ms100 ms200 ms1k2.5k5k10k20k40kMesh, 1k: 2.2 msMesh, 2.5k: 7.5 msMesh, 5k: 15 msMesh, 10k: 29 msMesh, 20k: 55 msMesh, 40k: 103 msInstancedMesh × 20, 1k: 1.5 msInstancedMesh × 20, 2.5k: 1.6 msInstancedMesh × 20, 5k: 1.9 msInstancedMesh × 20, 10k: 2.4 msInstancedMesh × 20, 20k: 3.6 msInstancedMesh × 20, 40k: 5.8 msMerged, 1 mesh, 1k: 0.9 msMerged, 1 mesh, 2.5k: 1.1 msMerged, 1 mesh, 5k: 1.3 msMerged, 1 mesh, 10k: 2.3 msMerged, 1 mesh, 20k: 3.1 msMerged, 1 mesh, 40k: 4.8 msBatchedMesh, 1k: 1.8 msBatchedMesh, 2.5k: 2.9 msBatchedMesh, 5k: 3.4 msBatchedMesh, 10k: 6.6 msBatchedMesh, 20k: 11 msBatchedMesh, 40k: 26 msBatchedMesh, sortObjects off, 1k: 1.1 msBatchedMesh, sortObjects off, 2.5k: 1.9 msBatchedMesh, sortObjects off, 5k: 2.3 msBatchedMesh, sortObjects off, 10k: 3.1 msBatchedMesh, sortObjects off, 20k: 4.8 msBatchedMesh, sortObjects off, 40k: 8.3 msprops →
Same yard and the same 20 prop types, grown from 1,000 to 40,000 props. Median of three page loads × 60 frames; the sortObjects-off series comes from a later run on the same machine, so read it against the others only where the gaps exceed about a millisecond — from 10,000 props up, not at 1,000–5,000. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome, ANGLE → Direct3D 11, three.js r180, 1600×900.
All values: frame time and GPU memory for geometry and instance data
PropsMeshInstancedMeshMerged, 1 meshBatchedMeshBatchedMesh, sortObjects offMerged: memory
1,0002.2 ms1.5 ms0.89 ms1.8 ms1.1 ms8.6 MB
2,5007.5 ms1.6 ms1.1 ms2.9 ms1.9 ms22 MB
5,00015 ms1.9 ms1.3 ms3.4 ms2.3 ms44 MB
10,00029 ms2.4 ms2.3 ms6.6 ms3.1 ms87 MB
20,00055 ms3.6 ms3.1 ms11 ms4.8 ms175 MB
40,000103 ms5.8 ms4.8 ms26 ms8.3 ms350 MB

The scaling run shows the same picture over a range, and one more. Separate meshes grow linearly with the object count and cross the 60 Hz budget between 5,000 and 10,000 props (103 ms at 40,000). Instancing and merging stay cheap — 5.8 ms and 4.8 ms at 40,000 — but the merged buffer grows to 350 MB. BatchedMesh with its default sorting grows fastest of the three: 26 ms at 40,000, 4.6× the instanced frame. Most of that is the sort. With sortObjects off the same batch took 8.3 ms — 1.4× the instanced frame — though its loop still grows with the count (2.3 ms of main-thread time at 40,000, and again for every shadow-casting light).

Tiny meshes: where instancing is weakest

Instancing has a known weak spot on the GPU: meshes with very few vertices. Each instance is a small, separate piece of vertex work, and GPUs process vertices in fixed-size groups (warps or wavefronts of 32 or 64 threads), so tiny instances leave part of the hardware idle. We drew 12-triangle cubes in large numbers to see how much it matters here:

CubesMethodGPU timeFrame
100,000InstancedMesh1.9 ms2.9 ms
100,000Merged, 1 mesh1.2 ms2.1 ms
100,000BatchedMesh, sortObjects off8.8 ms17 ms
400,000InstancedMesh6.9 ms7.7 ms
400,000Merged, 1 mesh3.7 ms4.3 ms
400,000BatchedMesh, sortObjects off42 ms73 ms

BatchedMesh collapsed here: 73 ms a frame at 400,000 cubes, 27 ms of it in its JavaScript loop and 42 ms on the GPU — for what the counter reports as a single draw call. The GPU part grows with the number of ranges in the multi-draw. Direct3D 11 has no native multi-draw, and ANGLE’s D3D11 back end expands the call into a loop: for every range it sets the gl_DrawID uniform and issues an ordinary draw (MULTI_DRAW_BLOCK in Context11.cpp). So on Chrome for Windows one BatchedMesh “draw” of 400,000 ranges is 400,000 draws to the driver.

At 400,000 cubes the instanced draw needed 6.9 ms of GPU time against 3.7 ms for the same cubes merged. For tiny, static, numerous pieces — voxels, bricks, gravel, tiles — merging (per chunk) beats instancing on the GPU. That is also how our viewer draws Tiny Cubes scenes: tiles that share one material are merged into one mesh per map chunk, so a whole region renders in one draw call.

What to use: a decision guide

Your situationUseWhy
Many copies of a few models (trees, rocks, crates, lamps, fences)InstancedMesh, one per mesh partLowest CPU cost, tiny memory, cheap to move and pick.
Copies spread over a large world, camera inside itInstancedMesh per spatial chunkGets culling back. Size chunks by measuring; we use 64 m for small props and 8 copies for repeated runs.
Static, never picked or moved, few unique vertices (terrain decoration, voxels, distant city blocks)Merged per chunkCheapest GPU path, no per-frame CPU work. Watch memory and give up picking.
Many different models with one material, each appearing a few times, culling mattersBatchedMeshThe only option with one draw, mixed geometries and per-object culling. For opaque materials, turn off sortObjects unless overdraw is heavy; keep it on for transparent ones.
A few hundred unique objectsSeparate meshesA few hundred draws are fine; keep the editing and the simplicity.
Per-object LOD with the same materialBatchedMesh with setGeometryIdAt, or one InstancedMesh per LOD tierA batch swaps one instance’s geometry in place; with instancing you move the instance between tier meshes.
Objects that change materials or skeletons independentlySeparate meshes, or instancing per stateInstances share one material and one geometry; batches share one material. Skinned meshes cannot be instanced this way at all.

And the limits that decide the grouping in every case: one material per InstancedMesh or BatchedMesh (use a texture atlas to merge materials, as the Apocalyptic props do), identical vertex attributes for everything in a batch or a merge, and no transparency sorting between instances of an InstancedMesh — its copies draw in buffer order.

If your models come as GLB files with several meshes each, instance each mesh separately and compose its node transform with each placement:

import * as THREE from 'three';

// One InstancedMesh per mesh inside the model; placements: Matrix4[]
export function instanceModel(model, placements) {
  const group = new THREE.Group();
  const m = new THREE.Matrix4();
  model.updateMatrixWorld(true);
  model.traverse((node) => {
    if (!node.isMesh) return;
    // A SkinnedMesh is also isMesh, but its skeleton cannot be instanced like this.
    if (node.isSkinnedMesh) throw new Error(`${node.name}: skinned, cannot instance`);
    const mesh = new THREE.InstancedMesh(node.geometry, node.material, placements.length);
    placements.forEach((placement, i) => {
      mesh.setMatrixAt(i, m.multiplyMatrices(placement, node.matrixWorld));
    });
    mesh.castShadow = node.castShadow;
    mesh.receiveShadow = node.receiveShadow;
    mesh.computeBoundingSphere(); // culling uses it; recompute after big moves
    group.add(mesh);
  });
  return group;
}

Pass gltf.scene as model, after it has loaded. For chunking, group placements by Math.floor(x / size) and Math.floor(z / size) and call the same function once per chunk.

Checklist

  1. Count draw calls first: renderer.info.render.calls after a frame. Hundreds are fine; thousands on the main pass are the problem these tools solve. The counters reset at each render() after the shadow pass, so shadow draws are not in the total, and after an EffectComposer frame you read only its last pass. For the full frame, set renderer.info.autoReset = false and call renderer.info.reset() once per frame.
  2. Group by geometry and material. Repeated models → InstancedMesh; one material shared by many different models → atlas them and consider BatchedMesh or merging.
  3. Merge only what is static and never addressed individually, and merge per chunk, not per level.
  4. Chunk large instanced or merged sets so culling still works — especially if the scene casts shadows.
  5. On BatchedMesh with an opaque material, set sortObjects = false. Budget for its per-instance loop, which runs every frame and again for each shadow-casting light, and check WEBGL_multi_draw on your target devices.
  6. Give instances their colour buffer before the first render — mesh.setColorAt(0, new THREE.Color(1, 1, 1)), after addInstance on a batch — so the first highlight does not compile a new shader. After later changes, set instanceColor.needsUpdate = true on an InstancedMesh.
  7. Recompute bounding spheres after moving instances far, or they will be culled while visible.
  8. Measure frame time — main thread and GPU — on your weakest target, not draw counts on your fastest.

Frequently asked questions

Is InstancedMesh always faster than separate meshes?

For more than a few dozen copies of the same mesh, yes in practice: it replaces one draw call per copy with one per geometry. For two or three copies there is nothing to gain, and separate meshes are simpler to edit, select and animate.

Can one InstancedMesh draw different models?

No. It draws one geometry with one material. Different models need one InstancedMesh each, or a BatchedMesh, which can hold several geometries that share a material and the same vertex attributes.

Does InstancedMesh support different colours per instance?

Yes. setColorAt(index, color) creates an instance colour buffer that multiplies the material colour. BatchedMesh has the same method. Call it once before the first render, e.g. mesh.setColorAt(0, new THREE.Color(1, 1, 1)): adding the colour buffer later switches the mesh to a different shader program, which compiles mid-frame. After changing colours on an InstancedMesh, set mesh.instanceColor.needsUpdate = true. Anything beyond colour — different textures, roughness per instance — needs a texture atlas or a custom shader attribute.

Why are my instances disappearing at the edge of the screen?

The bounding sphere of an InstancedMesh is computed once, on first use. If instances have moved outside it since, the renderer culls the whole mesh while some of them are still visible. Call computeBoundingSphere() after moving or adding instances, or set frustumCulled = false for small sets.

Is merging geometry still worth it?

For static, numerous, rarely addressed pieces — yes, especially per spatial chunk: it was the cheapest option on the GPU in our test and has no per-frame CPU cost. You pay in memory, which grows with every copy, in build time, and in picking, which needs your own bookkeeping.

How many draw calls is too many in three.js?

There is no fixed number, because a draw call has no fixed cost. As a rough guide from our measurements, a few hundred are cheap on desktop and a few thousand start to dominate the frame; separate meshes cost us about 2.2 µs of main-thread time each at 10,000 on a fast laptop. Measure frame time on your slowest target device.

Sources

More guides