150 low-poly birds flocking across the screen using Craig Reynolds’ three boids rules. Each bird has a detailed geometry with flapping wings driven by a vertex shader. The whole thing runs in one HTML file with no build step.
See it running here. Source on GitHub.
The three forces
Boids flocking comes from three simple rules applied to every bird on every frame:
- Separation - steer away from neighbours that are too close
- Alignment - match the average direction of nearby birds
- Cohesion - steer toward the average position of the flock
Each force produces a steering vector. Weight them, sum them, clamp to a maximum force, and apply to the bird’s velocity. That’s the entire algorithm.
// Separation: steer away from close neighbours
if (d < SEPARATION_DIST && d > 0) {
diff.subVectors(boid.pos, other.pos).normalize().divideScalar(d);
sepForce.add(diff);
sepCount++;
}
// Alignment: match direction of nearby birds
if (d < ALIGNMENT_DIST) {
avgVel.add(other.vel);
aliCount++;
}
// Cohesion: steer toward average position
if (d < COHESION_DIST) {
avgPos.add(other.pos);
cohCount++;
}
The weights matter. Too much separation and the flock scatters. Too much cohesion and they clump into a single point. The distances matter more - separation needs a tight radius (2 units), alignment a wider one (8 units), and cohesion the widest (10 units). This creates the layered behaviour where birds stay close but not too close, heading roughly the same direction.
Instanced rendering
150 birds with detailed geometry would mean 150 draw calls. InstancedMesh reduces that to one. Each bird gets a transform matrix set per frame via setMatrixAt. The vertex shader receives the instance matrix and applies it automatically.
const instancedMesh = new THREE.InstancedMesh(birdGeo, birdMat, BOID_COUNT);
// Each frame, update all transforms
for (let i = 0; i < boids.length; i++) {
dummy.position.copy(boid.pos);
dummy.quaternion.setFromRotationMatrix(lookMatrix);
dummy.quaternion.multiply(geoRotation);
dummy.updateMatrix();
instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
Vertex shader wing flapping
Each bird has a per-instance aFlapPhase attribute so wings aren’t synchronised. The vertex shader identifies wing vertices by their lateral distance from the body centreline and rotates them around the forward axis.
float wingDist = abs(pos.z);
float wingAmount = smoothstep(0.15, 1.3, wingDist);
float flapAngle = sin(uTime * 7.0 + aFlapPhase) * 0.55 * wingAmount;
// outer wing tips flex more
float tipFlex = smoothstep(0.7, 1.3, wingDist);
flapAngle += sin(uTime * 7.0 + aFlapPhase + 0.8) * 0.3 * tipFlex;
The smoothstep creates a gradient from body to wingtip. The secondary flex on the tips makes the flap look more organic - the outer feathers lag behind the inner wing.
Bird geometry
The bird is built from explicit triangle vertices. No models, no imports. A beak pyramid, tapered body from head through chest and mid-body to a wide fan tail, and multi-segment wings with root, elbow, and tip sections. About 50 triangles per bird.
The geometry points along the +X axis. Three.js lookAt faces -Z. A quaternion rotation of PI/2 around Y bridges the gap. Banking into turns uses a secondary rotation around the forward axis proportional to lateral acceleration.
Cel shading
Same approach as the Ghibli shader work - three discrete brightness bands instead of smooth lighting. Dot product of normal and light direction, then hard cutoffs to pick highlight, midtone, or shadow colour. A rim light adds some edge definition.
float NdotL = dot(normal, uLightDir);
vec3 col;
if (NdotL > 0.3) col = uHighlight;
else if (NdotL > -0.1) col = uColor;
else col = uShadow;
Flock initialisation
Birds spawn in pre-formed flocks rather than random positions. The flock count is randomised (3-8), with a 20% chance of one flock being much larger than the others. Each flock gets a shared centre position and velocity, with individual birds offset slightly. This means the scene looks like real flocks from the first frame rather than random dots that slowly organise.
Birds that leave the visible area respawn at edges. When respawning, they check for nearby birds already at the edge and join them, forming new flocks naturally rather than entering as isolated individuals.