Boids From Scratch With Three.js InstancedMesh
Reynolds' three flocking rules are simple. Rendering 5,000 of them
at 60 fps is the hard part. This tutorial builds a complete boids
simulation using THREE.InstancedMesh — one draw call,
a uniform-grid spatial hash for neighbour search, and quaternion
slerp for jitter-free turning.
1. Why InstancedMesh
A regular Mesh issues one draw call per object. For
5,000 boids that is 5,000 draw calls — the GPU spends more time on
call overhead and state changes than on actual triangles, and frame
rate collapses well below 60 fps.
THREE.InstancedMesh renders N copies of one geometry
and material in a single draw call, with per-copy
data (position, rotation, scale, even colour) supplied as a buffer
the GPU reads per-instance. This is exactly the geometry-instancing
feature underlying gl.drawArraysInstanced /
gl.drawElementsInstanced in WebGL.
2. Setting Up InstancedMesh
Create one shared geometry and material, then instantiate
InstancedMesh with a fixed capacity. Boids are usually
a small cone or a simple low-poly "fish" shape pointing along +Z.
import * as THREE from "three";
const COUNT = 5000;
const geometry = new THREE.ConeGeometry(0.15, 0.6, 6);
geometry.rotateX(Math.PI / 2); // point along +Z instead of +Y
const material = new THREE.MeshStandardMaterial({
color: 0x60a5fa,
roughness: 0.4,
metalness: 0.1,
});
const boidMesh = new THREE.InstancedMesh(geometry, material, COUNT);
boidMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // hint: updated every frame
scene.add(boidMesh);
// Reusable scratch objects — avoid allocating inside the render loop
const dummy = new THREE.Object3D();
const targetQuat = new THREE.Quaternion();
const upAxis = new THREE.Vector3(0, 1, 0);
setUsage(THREE.DynamicDrawUsage) so the underlying
WebGL buffer is allocated for frequent updates instead of the
static default.
3. Boid State and Reynolds' Rules
Each boid needs only position and velocity — orientation is
derived from velocity every frame, never stored independently.
Positions and velocities live in flat Float32Arrays
for cache-friendly iteration rather than an array of objects.
const positions = new Float32Array(COUNT * 3);
const velocities = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 40;
positions[i * 3 + 1] = (Math.random() - 0.5) * 40;
positions[i * 3 + 2] = (Math.random() - 0.5) * 40;
const a = Math.random() * Math.PI * 2;
velocities[i * 3 + 0] = Math.cos(a);
velocities[i * 3 + 2] = Math.sin(a);
}
const MAX_SPEED = 6, MAX_FORCE = 10;
const SEP_R = 1.2, VIEW_R = 3.5;
const W_SEP = 1.6, W_ALIGN = 1.0, W_COH = 1.0;
4. Uniform Grid Spatial Hash
Brute-force neighbour search is O(N²) — 25 million distance checks per frame at 5,000 boids. A uniform grid keyed by cell coordinates brings this down to roughly O(N): rebuild the hash every frame, then each boid only tests the ~27 cells around it.
const CELL = VIEW_R; // cell size = perception radius
const grid = new Map(); // "x,y,z" -> number[] of boid indices
function cellKey(x, y, z) {
return `${Math.floor(x / CELL)},${Math.floor(y / CELL)},${Math.floor(z / CELL)}`;
}
function rebuildGrid() {
grid.clear();
for (let i = 0; i < COUNT; i++) {
const key = cellKey(positions[i*3], positions[i*3+1], positions[i*3+2]);
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(i);
}
}
function forEachNeighbor(i, callback) {
const cx = Math.floor(positions[i*3] / CELL);
const cy = Math.floor(positions[i*3+1] / CELL);
const cz = Math.floor(positions[i*3+2] / CELL);
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
for (let dz = -1; dz <= 1; dz++) {
const bucket = grid.get(`${cx+dx},${cy+dy},${cz+dz}`);
if (bucket) for (const j of bucket) if (j !== i) callback(j);
}
}
VIEW_R. Any true neighbour within
that radius is guaranteed to fall in one of the 27 surrounding
cells, so no correctness is lost versus brute force — only speed
is gained.
5. Composing the Per-Instance Matrix
Each frame, after computing new positions and velocities for every
boid, we must write a 4×4 transform matrix into the
InstancedMesh's instance buffer. Three.js's Object3D
is a convenient scratch container: set its position/quaternion/
scale, call updateMatrix(), then copy the result into
the instance slot.
function writeInstance(i) {
dummy.position.set(
positions[i * 3],
positions[i * 3 + 1],
positions[i * 3 + 2]
);
dummy.quaternion.copy(orientations[i]); // see section 6
dummy.scale.setScalar(1);
dummy.updateMatrix();
boidMesh.setMatrixAt(i, dummy.matrix);
}
// after the loop over all boids:
boidMesh.instanceMatrix.needsUpdate = true;
instanceMatrix.needsUpdate = true after writing all
instances means Three.js never re-uploads the buffer to the GPU —
boids will appear frozen even though your JS-side positions are
updating correctly.
6. Smooth Orientation With Quaternion.slerp
Snapping directly to the velocity direction every frame causes
visible jitter when boids turn sharply — a boid that reverses
course instantly rotates 180° in a single frame. Spherical linear
interpolation (slerp) between the previous orientation
and the velocity-derived target orientation smooths this into a
natural banking turn.
const orientations = Array.from({ length: COUNT }, () => new THREE.Quaternion());
const TURN_SPEED = 6; // higher = snappier turning
function updateOrientation(i, dt) {
const vx = velocities[i*3], vy = velocities[i*3+1], vz = velocities[i*3+2];
const speed = Math.hypot(vx, vy, vz);
if (speed < 1e-4) return; // keep last orientation if nearly still
dummy.position.set(0, 0, 0);
dummy.lookAt(vx / speed, vy / speed, vz / speed);
targetQuat.copy(dummy.quaternion);
// spherical interpolation toward the target, framerate-independent
const t = 1 - Math.exp(-TURN_SPEED * dt);
orientations[i].slerp(targetQuat, t);
}
Using 1 - exp(-TURN_SPEED * dt) instead of a fixed
t keeps the turn rate consistent regardless of frame
rate — a common exponential-smoothing trick that avoids the
"faster machine turns faster" bug of naive lerp(pos, target, 0.1)
code.
7. The Full Update Loop
Putting it together: rebuild the spatial hash, compute separation/alignment/cohesion forces per boid using only its grid neighbours, integrate velocity and position, update orientation, then write every instance matrix.
function step(dt) {
rebuildGrid();
for (let i = 0; i < COUNT; i++) {
let sepX=0,sepY=0,sepZ=0;
let avgVX=0,avgVY=0,avgVZ=0;
let comX=0,comY=0,comZ=0, n=0;
forEachNeighbor(i, (j) => {
const dx = positions[i*3]-positions[j*3];
const dy = positions[i*3+1]-positions[j*3+1];
const dz = positions[i*3+2]-positions[j*3+2];
const d2 = dx*dx + dy*dy + dz*dz;
if (d2 > VIEW_R*VIEW_R || d2 < 1e-6) return;
if (d2 < SEP_R*SEP_R) { sepX+=dx/d2; sepY+=dy/d2; sepZ+=dz/d2; }
avgVX+=velocities[j*3]; avgVY+=velocities[j*3+1]; avgVZ+=velocities[j*3+2];
comX+=positions[j*3]; comY+=positions[j*3+1]; comZ+=positions[j*3+2];
n++;
});
let ax = sepX*W_SEP, ay = sepY*W_SEP, az = sepZ*W_SEP;
if (n > 0) {
ax += (avgVX/n - velocities[i*3]) * W_ALIGN + (comX/n - positions[i*3]) * W_COH;
ay += (avgVY/n - velocities[i*3+1]) * W_ALIGN + (comY/n - positions[i*3+1]) * W_COH;
az += (avgVZ/n - velocities[i*3+2]) * W_ALIGN + (comZ/n - positions[i*3+2]) * W_COH;
}
// clamp force, integrate velocity, clamp speed, integrate position
const fLen = Math.hypot(ax, ay, az) || 1;
const fScale = Math.min(1, MAX_FORCE / fLen);
velocities[i*3] += ax * fScale * dt;
velocities[i*3+1] += ay * fScale * dt;
velocities[i*3+2] += az * fScale * dt;
const vLen = Math.hypot(velocities[i*3], velocities[i*3+1], velocities[i*3+2]) || 1;
const vScale = Math.min(1, MAX_SPEED / vLen);
velocities[i*3] *= vScale;
velocities[i*3+1] *= vScale;
velocities[i*3+2] *= vScale;
positions[i*3] += velocities[i*3] * dt;
positions[i*3+1] += velocities[i*3+1] * dt;
positions[i*3+2] += velocities[i*3+2] * dt;
updateOrientation(i, dt);
writeInstance(i);
}
boidMesh.instanceMatrix.needsUpdate = true;
}
8. Performance Notes
-
Batch the buffer flag:
instanceMatrix.needsUpdate = truemust be set exactly once per frame after all instances are written — never inside the per-boid loop, or you upload the whole buffer N times. -
Avoid allocations in the hot loop: the
dummyObject3D, target quaternion and grid key strings should be reused, not created fresh every frame — GC pressure is the single biggest cause of stutter in JS particle systems. -
Bounding sphere: call
boidMesh.computeBoundingSphere()once after setup (or setfrustumCulled = false) — Three.js cannot infer per-instance bounds automatically, and an incorrect default bounding volume can cause the whole flock to pop in and out of view. -
Color per instance (optional): use
InstancedMesh.instanceColorandsetColorAt(i, color)to tint boids by speed or group without extra draw calls. - Scaling further: beyond ~20,000 boids, move the neighbour search and integration into a GPU compute pass (WebGPU compute shader or a ping-pong texture in WebGL2) — see the Boids Algorithm article's note on GPU parallelism.
Frequently Asked Questions
What will I learn in this tutorial?
Build a 3D boids flocking simulation from scratch using Three.js InstancedMesh: separation/alignment/cohesion, a uniform-grid spatial hash for neighbour search, and per-instance matrix updates with quaternion orientation.
What topics are covered in this tutorial?
This tutorial covers: Why InstancedMesh, Setting Up InstancedMesh, Boid State and Reynolds' Rules, Uniform Grid Spatial Hash, Composing the Per-Instance Matrix, Smooth Orientation With Quaternion.slerp, The Full Update Loop, Performance Notes.
How long does this tutorial take?
This tutorial takes approximately 30 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.