Three.js instancing: what one draw call actually means
Instancing helps when many objects share geometry and material but have different transforms. It lets one draw submission describe many copies. The important qualifier is “share”: ten geometry families, two materials, and a shadow pass are not one draw call simply because every object uses InstancedMesh.
The current Lab groups its ice blocks by geometry family. Each group has an instance matrix buffer and per-instance variation. That is a useful example of the real trade-off: fewer submissions than individual meshes, while keeping different silhouettes where the scene needs them.
Count batches and passes
For one opaque material and one geometry without material groups, a visible instance batch normally contributes one draw in a particular pass. A shadow map renders additional geometry. A reflection renders from another camera. Some transparent double-sided materials require additional draws. Post-processing adds its own fullscreen work.
Instancing does not reduce the triangle count of each copy or automatically reduce overdraw. A thousand overlapping transparent crystals can remain expensive even with few submissions. Before optimizing, inspect whether the scene is limited by draw submission, vertex processing, pixel work, or something else.
| Objects | A useful grouping |
|---|---|
| Same shape and material | One batch with different transforms |
| Different shape families | One batch per geometry family |
| Different materials | Separate batches, or a carefully designed shared shader |
| Objects spread across a large world | Spatial batches that can be culled independently |
Fill the matrices before the first frame
This minimal setup assumes THREE, a geometry, a material, and a scene are already available. It creates an 8-by-8 grid, sets every transform, and uploads once:
const capacity = 64;
const batch = new THREE.InstancedMesh(geometry, material, capacity);
const pose = new THREE.Object3D();
for (let i = 0; i < capacity; i++) {
pose.position.set((i % 8) * 2, 0, Math.floor(i / 8) * 2);
pose.rotation.set(0, (i % 5) * 0.2, 0);
pose.scale.setScalar(0.8);
pose.updateMatrix();
batch.setMatrixAt(i, pose.matrix);
}
batch.instanceMatrix.needsUpdate = true;
batch.computeBoundingSphere();
scene.add(batch); The constructor allocates capacity. batch.count selects how many instances are drawn and can be reduced or increased within that allocation. Setting it beyond the allocated matrix buffer does not create storage for new instances. For a larger population, rebuild or allocate enough capacity in advance.
Each matrix uses 16 floating-point values. At four bytes each, 1,000 matrices require 64,000 bytes before any other instance attributes, geometry, or driver overhead. Instancing removes many JavaScript objects and draw submissions, but its data is not free.
Separate per-vertex and per-instance data
A normal BufferAttribute advances with vertices. An InstancedBufferAttribute normally advances with instances. Using the wrong type does not mean every vertex safely receives the first value; it changes how the buffer is addressed and can produce incorrect or insufficient data.
const phases = new Float32Array(capacity);
for (let i = 0; i < capacity; i++) phases[i] = i * 0.37;
geometry.setAttribute(
"aPhase",
new THREE.InstancedBufferAttribute(phases, 1)
); For a custom ShaderMaterial, declare that attribute in the shader. In an instanced shader, instanceMatrix is an attribute, not a uniform. The matrix differs between copies; a uniform would be common to the draw.
Animate locally, then apply the instance transform
This is a vertex-shader excerpt for uniformly pulsing copies. It assumes a fragment shader is supplied separately and the material receives uTime. It demonstrates the transform order without pretending to include the Lab’s lighting or ice shader:
attribute float aPhase;
uniform float uTime;
void main() {
float scale = 1.0 + 0.04 * sin(uTime + aPhase);
vec3 localPosition = position * scale;
vec4 meshPosition = instanceMatrix * vec4(localPosition, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * meshPosition;
} The instance matrix places the copy in the parent mesh’s local space. modelViewMatrix then takes that mesh to view space. Calling the intermediate value “world position” and later applying the model transform again is a common source of space-mixing errors.
Uniform scale is intentionally simple here. Lit shaders with nonuniform scale need correctly transformed normals. A visual pulse can run on the GPU with a single time update, but the current Lab also changes instance transforms on the CPU during assembly. Its DynamicDrawUsage hint matches that behavior.
Bounds must include animation
Three.js can cull an entire batch using its bounds. It does not perform arbitrary per-instance visibility selection for you. If instance transforms move, recompute an appropriate bound or maintain a conservative one. If a shader displaces vertices, the CPU cannot infer that displacement from the unmodified geometry.
The 4% pulse above requires a bound that covers its maximum extent. The current ice groups disable frustum culling while their transforms unfold. That trades additional rendering for predictable visibility. Larger worlds usually benefit from smaller spatial batches with conservative bounds.
Do not call distance culling “provably pop-free” just because it roughly matches the fog distance. Account for the entire object’s extent, camera field of view, reflections, and shadows. Check the transition with the camera moving in both directions.
Measure a complete frame
To compare approaches, hold camera, resolution, material, object count, and pass configuration constant. Record draw calls and triangles as well as frame pacing. A different view or a disabled shadow pass can make an instancing experiment look more effective than it is.
With multiple rendering passes, renderer.info may reset between render calls. Set renderer.info.autoReset = false, reset it immediately before the frame’s passes, and inspect it after all of them when you need a frame total. Restore the intended accounting policy afterward. A counter from one pass is not the budget of the entire scene.
Use instancing where repeated structure justifies it, then verify the remaining cost. The DPR worksheet helps separate the pixel budget from the batching problem.
Sources & revision
Read the dated source excerpt. It includes original file paths, line numbers, and SHA-256 hashes for the version used in this guide. The public repository may contain a different revision.
- Three.js r160: InstancedMesh implementation
- Three.js r160: InstancedBufferAttribute
- MDN: drawElementsInstanced
28 Oct 2026: Removed the obsolete 1,600-crystal headline and corrected instanceMatrix, buffer capacity, attribute divisors, and multi-pass draw-call claims.
These notes use AI assistance. Read the editorial method and corrections policy. Found an error? Send a correction with this article’s title.