Rendering Thousands of Repeating Meshes in Three.js with InstancedMesh
Replace thousands of individual Mesh objects with one THREE.InstancedMesh: setup, per-instance transforms and colors, the needsUpdate pitfall, frustum culling fixes, and raycast picking.
14 Aug 2025, 06:08 UTC

The problem: one draw call per mesh does not scale
A scene with 5,000 individually created THREE.Mesh objects typically issues thousands of draw calls per frame. Even with trivial geometry, the CPU overhead of submitting those calls dominates frame time long before the GPU is busy. The useful takeaway: if your meshes share one geometry and one material, THREE.InstancedMesh renders all of them in a single draw call, with each copy positioned by its own matrix. This guide walks through setting it up, updating instances, and diagnosing the two failures everyone hits: instances stuck at the origin and instances vanishing when the camera moves.
Prerequisites
- Three.js r110 or newer (InstancedMesh was introduced around r109–r110; examples below assume a modern revision, r150+, via ES modules).
- A working renderer, scene, and camera loop.
- One shared
BufferGeometryand oneMaterial— instancing cannot mix geometries or materials in a single InstancedMesh. - A known maximum instance count. The count is fixed at construction time, so allocate for the worst case up front.
Setting up the InstancedMesh
Create the mesh with your geometry, material, and maximum count, then write a Matrix4 per instance. Compose each matrix from position, rotation (as a quaternion), and scale rather than building matrices by hand.
import * as THREE from 'three';
const COUNT = 5000;
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);
const dummy = new THREE.Object3D(); // scratch object for composing transforms
const color = new THREE.Color();
for (let i = 0; i < COUNT; i++) {
const x = (i % 25) * 2 - 24;
const z = Math.floor(i / 25) % 20 * 2 - 19;
const y = Math.floor(i / 500) * 2;
dummy.position.set(x, y, z);
dummy.rotation.set(0, Math.random() * Math.PI, 0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
color.setHSL(Math.random(), 0.6, 0.55);
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;Run this during scene setup, before the first render. No special permissions are involved — it is plain client-side code. Three things to note:
setMatrixAt(index, matrix)writes into a CPU-side buffer. Nothing reaches the GPU until you setinstanceMatrix.needsUpdate = true. Forgetting this flag is the single most common reason instances appear frozen at identity positions (all stacked at the origin).setColorAtallocates theinstanceColorbuffer on first call, so check it exists before settingneedsUpdate. Per-instance color works with materials that respect instance colors, such asMeshStandardMaterial; multiply-friendly base color (white) gives predictable tinting.- Reusing one scratch
Object3Davoids allocating 5,000 throwaway matrices.
Updating instances at runtime
To move a single instance — say, the one a user clicked — rewrite only its matrix and flag the buffer:
function moveInstance(mesh, index, newPosition) {
const m = new THREE.Matrix4();
mesh.getMatrixAt(index, m);
m.setPosition(newPosition); // keep existing rotation/scale
mesh.setMatrixAt(index, m);
mesh.instanceMatrix.needsUpdate = true;
}Flagging needsUpdate re-uploads the whole instance buffer, not just one entry. That is fine for occasional edits, but rewriting all 5,000 matrices every frame costs real CPU time. If you need every instance animated continuously (waves, particles, flocking), the established alternative is a custom ShaderMaterial (or onBeforeCompile patch) with instanced attributes, computing transforms on the GPU. Use per-frame setMatrixAt loops only for hundreds of instances, not tens of thousands.
Expected checks
- Draw calls: after rendering, read
renderer.info.render.calls. The instanced group should add roughly one call (plus shadow-pass calls if shadows are enabled). If you see thousands, you are still rendering separate meshes somewhere. - Single-instance update: call
moveInstanceon one index and confirm only that box moves. If nothing moves, you missedneedsUpdate. If everything moves, you are writing to the same index repeatedly. - Picking: raycasting works against InstancedMesh and reports which copy was hit:
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointerNDC, camera);
const hits = raycaster.intersectObject(mesh);
if (hits.length > 0) {
console.log('hit instance', hits[0].instanceId);
}The instanceId maps directly to the index you used in setMatrixAt, so selection logic needs no per-mesh bookkeeping.
When instances vanish: frustum culling
Three.js frustum-culls an InstancedMesh as one object, using a bounding volume derived from the base geometry — which knows nothing about where you placed the instances. Spread instances across a wide field, rotate the camera, and whole sections can pop out of view while still on screen. Two fixes:
- Simple:
mesh.frustumCulled = false;— always draw, correct for most scenes at this scale. - Thrifty: compute a bounding sphere that encloses all instance positions and assign it to
geometry.boundingSphere, keeping culling for genuinely off-screen fields.
Verification: pan the camera so part of the instance field leaves the frustum edge. If instances pop incorrectly, toggle frustumCulled and re-test — the popping should stop immediately.
Limitations and recovery
- One material per InstancedMesh. Per-instance visual variation beyond color requires vertex colors, custom shaders, or splitting into multiple InstancedMesh objects.
- The count is fixed at creation. If you under-allocate, create a larger InstancedMesh and copy matrices across; hiding unused slots by scaling their matrices to zero is the common workaround.
- Behavior details (culling defaults, color support) have shifted across releases — confirm against your installed revision with the checks above rather than assuming.
Recovery is straightforward because setup is declarative: if the scene looks wrong, dispose the InstancedMesh (mesh.dispose()), rebuild it with corrected matrices, and re-add it. No renderer state is permanently altered.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.