
Two-species boids in three.js: narwhals hunting fish, with a custom water shader on top
Built this for the hero of a Godot documentation chatbot I shipped at work (games.surmado.com — Godot mention done, moving on to the three.js).
The interesting bit for this sub is the underwater scene: two species sharing the swim volume with cross-species behavior.
Setup
- 35 rigged narwhals + 120 rigged fish, both GLB with skinned meshes and a "Swim" animation clip
SkeletonUtils.clone()per instance so each boid runs its ownAnimationMixerwith a randomtimeScale— no two on the same flap- Full 3D flocking (alignment + cohesion + separation summed into a
maxAccelcap) - Cohesion vector is rotated around the world y-axis by a per-species spiral angle (~60° narwhals, ~83° fish) so dense groups form horizontal cyclones instead of tumbling 3D clouds
Why the spiral, specifically for the fish
First passes had straight cohesion — fish pulled directly at the centroid of their neighbors. The result was spherical clumps, and it just didn't match what I see when I dive. Real fish schools aren't just stuck together matching velocities in one big clump. they're swirling spheres that rotate. There's a tangential component that straight cohesion doesn't capture.
Rotating the cohesion vector around the y-axis by a sinusoidal angle (cos/sin of spiralAngle) lets me bias the pull from "directly at centroid" toward "perpendicular to the radial direction"
Narwhals use a gentler ~60° spiral because their schools are looser and more meandering than fish. Might remove this tbh need to think about it. Fits the fish far better.
Cross-species behavior
- Fish flee any narwhal within radius 300 with a 1.4× force multiplier. While at least one narwhal is in that radius, the fish's speed cap goes to 1.7× and accel cap to 2.2× — a panic burst.
- Each non-hunting narwhal has a 0.0009/frame chance to lock onto the nearest fish within radius 450 and steer toward it for 3.5s. Most of the time the school is mellow, but occasionally one narwhal breaks formation and you get a documentary moment.
- Fish flocking is 5× tighter cohesion + smaller perception radius than narwhals so the school stays compact when fleeing.
Bounded swim volume
- Sand floor at y=-220, water surface at y=220, with cubic wall-repulsion (
force = peak × penetration³) on all six walls so even a panic-bursting fish can't punch through. - Three.js fog for the atmospheric depth fade.
Two-canvas pipeline for water effects
- three.js canvas with
preserveDrawingBuffer: trueis the source. - A second
<canvas>on top with a custom WebGL fragment shader samples the three.js canvas as a texture, applies caustic patterns (two scrolling noise samples multiplied + powered to isolate bright filaments) and an atmospheric darken toward the top. - Two passes lets me keep the three.js scene clean and do screen-space water effects without bloating the main shader.
Perf
- Spatial grid (cell size = max same-species perception radius) for the inner-loop neighbor scan.
- Cross-species checks just iterate the other species — under 6k checks/frame total at these counts.
- Holds 60fps on desktop with both canvases active.
Looking for feedback on
- The "documentary hunt" cadence (0.0009/frame ≈ one hunt every ~60s in expectation) — feels about right to me but might be too rare for a marketing hero where most visitors only watch for 10–15 seconds.
- Caustics work on the close water but I haven't figured out a clean way to project them onto the sand floor cleanly.
Will be adding more as time goes on to make it more immersive and realistic. Great way to kill time at work lmao.