InstancedMesh in Three.js: Batching Thousands of Identical Objects
Learn how InstancedMesh reduces draw calls for thousands of identical Three.js objects, with a minimal example, verification steps, and memory trade-offs.
17 Apr 2026, 19:16 UTC

The Draw-Call Problem With Thousands of Copies
When a scene contains thousands of identical primitives—pine trees on a slope, crates in a warehouse, grass tufts across a field—the straightforward approach is to create one THREE.Mesh per copy. Each mesh triggers its own draw call: the CPU tells the GPU to bind buffers, set uniforms, and execute the shader program. The overhead of those state changes, not the triangles themselves, often becomes the bottleneck. Frame rates drop even though each object is geometrically simple.
How InstancedMesh Batching Works
THREE.InstancedMesh removes that overhead by storing a single BufferGeometry and a single material, then supplying an array of 4×4 transformation matrices—one per instance. The GPU draws all instances in a single call (or a small number of calls if you use multiple InstancedMeshes). The per-instance data lives in an InstancedBufferAttribute on the geometry, so the vertex shader can apply the correct matrix to each copy.
Because the geometry and material are shared, you cannot give one instance a different shape or a different shader without splitting it into another InstancedMesh. You can, however, vary per-instance color and transform without a custom shader: setMatrixAt handles position, rotation, and scale, and setColorAt writes an RGB value into an instance color attribute that the standard materials respect.
Minimal Setup
The following snippet creates a field of colored boxes. Run it in a browser after loading Three.js from a package manager or CDN.
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
// One shared geometry and material
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
const count = 50000;
const mesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
const color = new THREE.Color();
for (let i = 0; i < count; i++) {
matrix.makeTranslation(
THREE.MathUtils.randFloatSpread(80),
0,
THREE.MathUtils.randFloatSpread(80)
);
matrix.scale(new THREE.Vector3(1, 1 + Math.random() * 2, 1));
mesh.setMatrixAt(i, matrix);
color.setHSL(0.3, 0.6, 0.3 + Math.random() * 0.4);
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
camera.position.set(0, 30, 60);
camera.lookAt(0, 0, 0);
renderer.setAnimationLoop(() => renderer.render(scene, camera));
Key points: the geometry is created once. setMatrixAt and setColorAt write into GPU buffers. After the loop, set needsUpdate = true so the renderer uploads the changed data. frustumCulled is true by default, but note that it tests the bounding volume of the entire InstancedMesh on the CPU; it does not cull individual instances on the GPU.
Verifying the Result
Open your browser's developer tools and inspect the rendering statistics. In Three.js, renderer.info.render.calls reports the number of draw calls for the last frame. With one InstancedMesh in the scene, that value should be small (often 1, plus any additional passes such as shadows or post-processing). If you replace the InstancedMesh with 50,000 individual Mesh objects, the draw-call count will rise sharply and the frame rate will typically fall, confirming the bottleneck.
Because results depend on the GPU, driver, and browser, treat any specific frame-rate number as environment-specific. The reliable check is the draw-call count and whether the animation remains smooth on your target hardware.
Trade-offs and Limits
InstancedMesh is not a universal replacement for Mesh. The main costs are memory and update granularity:
- Memory: each instance matrix is 16 floating-point numbers (64 bytes). 100,000 instances therefore consume roughly 6.4 MB of GPU memory for matrices alone, before geometry and color data. Very large counts can strain low-end mobile GPUs.
- Shared material: all instances use the same material. If copies need different textures or shading models, split them into separate InstancedMesh objects.
- Whole-mesh culling: frustum culling applies to the combined bounding volume. If the bounding sphere is large, the entire InstancedMesh is drawn even when most instances are off-screen. You can improve this by splitting a world into spatial chunks, each with its own InstancedMesh.
- Dynamic updates: changing an instance's transform or color requires calling
setMatrixAtorsetColorAtand flagging the attribute for re-upload. Frequent per-frame updates to millions of instances can saturate the PCIe bus.
When to Use It
Use InstancedMesh when your scene is dominated by many copies of the same geometry and material: vegetation, debris, architectural details, or particle-like fields. Keep regular Mesh objects for unique geometry, objects that need independent materials, or small counts where draw-call overhead is irrelevant. For mixed scenes, group identical primitives into one InstancedMesh per type and keep unique objects as individual meshes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.