{{ title }}
{{ excerpt }}
// this note could not be loaded. Back to field notes →
// drafted by our agent pipeline · reviewed and edited by a human engineer
{{ excerpt }}
// this note could not be loaded. Back to field notes →
// drafted by our agent pipeline · reviewed and edited by a human engineer
We needed to render several thousand identical objects in a Three.js scene. Our first implementation created a standard THREE.Mesh for each object, which dropped the frame rate below an acceptable threshold on most devices.
This is a common performance bottleneck. The fix involves changing how we tell the graphics card what to draw, moving from many small instructions to one large one.
A draw call is an instruction from the CPU to the GPU to render a set of vertices. Each call has overhead. When rendering thousands of objects, the CPU can spend more time preparing and sending these calls than the GPU spends rendering them. This starves the GPU and lowers the frame rate.
Our initial, slow approach looked like this. For each object, we created a new Mesh and added it to the scene.
// The slow approach: one mesh per object
import * as THREE from 'three';
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const objectCount = 10000;
for (let i = 0; i < objectCount; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100
);
scene.add(mesh);
}
This code creates 10,000 meshes. Since each Mesh is a distinct object in the scene graph, the renderer issues a separate draw call for each one. With 10,000 objects, we get 10,000 draw calls per frame. This is not sustainable.
You can verify the number of draw calls using a browser extension like Spector.js, which intercepts and reports WebGL commands. The number of calls to gl.drawElements or gl.drawArrays will correspond to the number of objects.
The correct approach for this problem is instanced rendering. Instancing allows the GPU to render the same geometry multiple times in a single draw call. Each instance can have unique properties like position, rotation, and scale, which are passed to the GPU as a block of data.
Three.js provides the InstancedMesh class for this purpose. Instead of creating 10,000 Mesh objects, we create one InstancedMesh and tell it how many instances to render.
// The fast approach: one InstancedMesh for all objects
import * as THREE from 'three';
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const count = 10000;
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
scene.add(instancedMesh);
const matrix = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
const position = new THREE.Vector3(
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100,
(Math.random() - 0.5) * 100
);
// A default quaternion and scale
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3(1, 1, 1);
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
Here, we create a single InstancedMesh for all 10,000 objects. We then loop through, but instead of creating a Mesh, we create a Matrix4 transformation matrix for each instance. The instancedMesh.setMatrixAt(i, matrix) method packs this transformation data into an internal buffer. The result is one object in the scene graph and one draw call.
Instancing trades the convenience of the Three.js scene graph for raw performance. We no longer have individual Mesh objects whose .position or .rotation properties we can manipulate directly. State is now managed by us, inside the InstancedMesh.
To move, rotate, or scale an individual instance, you must:
InstancedMesh to update its internal buffer.Updating the position of a single instance looks like this:
function updateInstancePosition(mesh, index, newPosition) {
const matrix = new THREE.Matrix4();
mesh.getMatrixAt(index, matrix); // Get the current matrix
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
matrix.decompose(position, quaternion, scale);
// Recompose the matrix with the new position
matrix.compose(newPosition, quaternion, scale);
mesh.setMatrixAt(index, matrix);
// Mark the instance matrix buffer for update
mesh.instanceMatrix.needsUpdate = true;
}
Forgetting to set mesh.instanceMatrix.needsUpdate = true is a common mistake. If you call setMatrixAt but do not see your changes reflected on screen, check that this flag is set. The scene will not re-render the changes without it, and it will not produce a console error.
This manual state management is more verbose than mesh.position.set(). This is the price of performance. For scenes with a small number of objects, the overhead of instancing is not worth the added complexity.
Instancing is a specific tool for a specific problem. It is not always the right solution.
Use instancing when you have a large number of objects that share the same geometry and material.
Do not use instancing if:
BufferGeometry. If geometries differ, you must use separate meshes. For static unique objects, geometry merging can be an alternative.InstancedMesh uses a single material. You can create multiple InstancedMesh objects (one per material), but this adds draw calls and may negate the performance benefit.Mesh objects is preferable.We do not have a hard number for when to switch. A good rule of thumb is to start with simple meshes and only optimize with instancing if you can measure a significant, unacceptable frame rate drop.