Cut draw calls in Three.js with InstancedMesh
Render thousands of identical objects with one draw call using Three.js InstancedMesh, but watch the aggregate frustum-culling limit.
16 Sept 2026, 10:16 UTC

One draw call for a thousand crates
You are building a scene with a thousand identical crates. Each crate is a THREE.Mesh built from the same box geometry and material. The scene loads, but the frame rate stutters as soon as the crates appear. The likely cause is not polygon count; it is draw calls. Three.js submits one draw call per mesh per frame, so the CPU spends its time issuing a thousand nearly identical commands while the GPU waits.
The practical fix is InstancedMesh. It draws the same geometry and material many times in a single draw call by storing a per-instance transform matrix in a buffer attribute. Think of it as one mesh object that knows how to place itself in many locations.
How InstancedMesh works
An InstancedMesh extends Mesh. Instead of a single world matrix, it keeps an instance matrix buffer. You tell it how many copies to draw, assign a matrix to each copy, and Three.js issues one draw call for the whole set.
Two terms worth knowing:
- Draw call: a command from the CPU to the GPU to render geometry. Fewer draw calls usually means less main-thread overhead.
- Frustum culling: skipping objects outside the camera's view. InstancedMesh culls by the bounding box of all instances together, not each instance individually.
Worked example: 1,000 boxes
Save this as instanced.html and serve it locally (for example, python3 -m http.server). No special permissions are required. Replace YOUR_CDN_URL with the Three.js build you are using.
<!DOCTYPE html>
<html>
<body>
<script type="importmap">
{
"imports": {
"three": "YOUR_CDN_URL/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 5, 15);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const material = new THREE.MeshStandardMaterial({ color: 0x55aa55 });
const count = 1000;
const boxes = new THREE.InstancedMesh(geometry, material, count);
scene.add(boxes);
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
matrix.makeTranslation(
(Math.random() - 0.5) * 20,
0,
(Math.random() - 0.5) * 20
);
boxes.instanceMatrix.setMatrixAt(i, matrix);
}
boxes.instanceMatrix.needsUpdate = true;
renderer.setAnimationLoop(() => {
console.log('draw calls this frame:', renderer.info.render.calls);
renderer.render(scene, camera);
});
</script>
</body>
</html>
Where to run: Open the file in a browser and open DevTools > Console. Required permissions: none beyond normal page execution. Expected check: renderer.info.render.calls should stay near one draw call for the boxes plus a small fixed renderer overhead. If you create 1,000 separate Mesh objects instead, that number climbs by roughly one per box.
Risk: If you call setMatrixAt but forget instanceMatrix.needsUpdate = true, the instances will not move or update on screen. Set the flag once per frame after all matrix writes.
Per-instance color
You can vary color per instance by enabling vertexColors: true on the material, then writing to instancedMesh.instanceColor and setting instancedMesh.instanceColor.needsUpdate = true. This tints geometry using vertex colors; it does not give each instance its own material or texture.
The culling trap
InstancedMesh is not free. Because frustum culling uses one bounding box for every instance, a widely scattered group can force the GPU to draw all 1,000 boxes even when only one is visible. If your instances cover a huge area, consider spatial partitioning, multiple InstancedMesh objects by region, or a custom culling pass.
InstancedMesh requires WebGL2 or WebGL1 with the appropriate extensions depending on your Three.js version; verify target browser support before shipping.
Updating thousands of matrices every frame on the CPU can also become the new bottleneck. Batch your matrix writes and set needsUpdate once per frame rather than once per instance.
When to use it
Use InstancedMesh when many objects share geometry and material and only differ by transform, and optionally color. Avoid it when each object needs a different material, unique texture, or independent animation that cannot be expressed as a matrix.
Practical verification: In your own scene, log renderer.info.render.calls before and after switching from individual meshes to InstancedMesh. Then move the camera so the instances leave the view and confirm the draw call drops. If it does not, the aggregate bounding box is likely intersecting the frustum.
InstancedMesh trades CPU command overhead for a single larger draw call. For grids, foliage, and crowds, that trade usually wins—provided you remember needsUpdate and design around the culling boundary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.