Render Thousands of Identical Objects with One Draw Call in Three.js
Use Three.js InstancedMesh to draw many identical objects with one draw call, reduce CPU overhead, and pick instances by ID.
04 Sept 2025, 01:28 UTC

The problem: too many draw calls
If you add one thousand Mesh objects to a Three.js scene, the renderer issues roughly one thousand draw calls per frame. Even when every object uses the same box geometry and material, Three.js treats each Mesh as a separate draw instruction. On a typical desktop that may still run, but on mobile or with complex geometry the CPU spends more time submitting draw commands than updating your scene.
The useful takeaway: InstancedMesh lets you draw the same geometry and material many times with a single draw call. Each instance still gets its own position, rotation, and scale through a 4x4 matrix, so the objects can be scattered across a level without sacrificing the batching benefit.
How InstancedMesh batches geometry
An InstancedMesh is created with a geometry, a material, and an instance count. Three.js uploads one copy of the geometry to the GPU, then stores a matrix for every instance in a separate buffer. During rendering, the GPU reads that matrix buffer and draws all instances in one pass.
You update an instance with setMatrixAt(index, matrix). After changing any matrices, set instancedMesh.instanceMatrix.needsUpdate = true so Three.js knows to refresh the GPU buffer before the next frame. Without that flag, changed matrices are silently ignored by the renderer.
Worked example: one thousand boxes
Paste this into a local HTML file that already loads Three.js from a CDN or your module bundler. No special permissions are required; it runs in the browser's main thread.
import * as THREE from 'three';
// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 50;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Shared geometry and material
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
// One InstancedMesh for 1000 boxes
const instanceCount = 1000;
const boxes = new THREE.InstancedMesh(geometry, material, instanceCount);
scene.add(boxes);
const matrix = new THREE.Matrix4();
for (let i = 0; i < instanceCount; i++) {
matrix.set(
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
1
);
boxes.setMatrixAt(i, matrix);
}
// Mark the instance matrix buffer as dirty
boxes.instanceMatrix.needsUpdate = true;
// Lighting
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(10, 20, 10);
scene.add(light);
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
// Diagnostic: check draw calls in the browser console
console.log('Draw calls this frame:', renderer.info.render.calls);
}
animate();
Where to run: In a local HTML file served over HTTP or opened directly, depending on your module setup. Permissions: none beyond normal browser execution. Expected check: open the browser console and watch renderer.info.render.calls. With one thousand separate Mesh objects you would expect roughly one thousand plus overhead; with this InstancedMesh you should see a small, stable number, typically one draw call for the instances plus any additional passes for lights or shadows.
Risk: if you move an instance later by calling setMatrixAt but forget instanceMatrix.needsUpdate = true, the object will not appear to move. Always set the flag after batch matrix changes.
Picking a specific instance
Because every instance shares one object in the scene graph, a normal click test returns the InstancedMesh itself, not the individual box. Three.js raycasting can report instanceId so you know which instance was hit.
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.addEventListener('pointerdown', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(boxes);
if (intersects.length > 0) {
const hit = intersects[0];
console.log('Clicked instance:', hit.instanceId);
}
});
Expected check: click different boxes and confirm instanceId changes. This works with standard materials and shadows in current Three.js releases, but raycasting details can vary by version, so verify against your installed release.
Limitations and how to verify them
- Shared geometry and material only. All instances use the same geometry and material. If you need different shapes, create separate
InstancedMeshobjects or use a custom shader with per-instance attributes. - Frustum culling is object-level. In many Three.js versions, the entire
InstancedMeshis culled or drawn based on its bounding sphere. A cluster partially outside the camera may still be fully drawn, or fully culled if the center is outside. There is no automatic per-instance culling without extra work. - Per-instance color requires opt-in. To use
setColorAt, enable per-instance color on the material depending on your Three.js version.
Practical verification: after rendering, inspect renderer.info.render in the developer tools. If calls stays near one for your instanced objects while frame time remains low, batching is working. If you add per-instance logic and draw calls rise, you have likely left the single-geometry, single-material path.
Closing checklist
- Use one
InstancedMeshper shared geometry/material combination. - Call
setMatrixAtfor every instance, then setinstanceMatrix.needsUpdate = true. - Confirm draw-call reduction with
renderer.info.render.calls. - Use
raycaster.intersectObjectand readinstanceIdfor interaction. - Test on your target device; frustum culling behavior varies by Three.js release.
InstancedMesh is not a universal replacement for individual Mesh objects, but for crowds, repeated architectural pieces, or particle-like visuals it is one of the most effective ways to keep draw calls low in Three.js.