Tutorial · Three.js · Algorithms · WebGL
📅 July 2026 ⏱ ≈ 30 min 🎯 Intermediate

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.

Rule of thumb: once you need more than a few hundred copies of the same mesh, switch to InstancedMesh. Below that, the draw-call overhead is negligible and plain meshes are simpler to reason about.

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);
DynamicDrawUsage: since we overwrite every instance's matrix every frame, tell Three.js up front via 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);
  }
}
Cell size choice: set the cell size equal to the perception radius 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;
Common bug: forgetting 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

🐦 See it running

The full boids simulation built with these exact techniques.

Open simulation →

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.