On this page
Why Verlet?
The kelp in PolyFish started life as Jolt Physics ragdoll chains - real rigid-body joints with WASM physics running in a web worker. It worked, but it was expensive. With 60 plants on screen, each with 5 joint bodies, we had 300 physics bodies just for vegetation. The overhead was murder on mobile.
Verlet integration offered a way out. Instead of simulating forces and velocities, Verlet stores just two things per node: current position and previous position. Velocity is implicit - it's the difference between the two. This makes the system dead simple, stable, and cheap enough to run hundreds of chains at 60fps.
Position-Based Dynamics - Verlet doesn't solve force equations. Instead, it directly moves positions to satisfy constraints. Each frame: remember where you were, move by inertia, then project positions back to satisfy distance constraints. It's unconditionally stable - no spring explosions, no energy blowup.
The Basic Chain
At its core, a kelp stalk is a chain of nodes connected by rigid-length segments. The bottom node is pinned (it's rooted in the seafloor), and the rest are free to move. Each frame, the simulation runs in four steps:
First, inertia: each node continues moving in the direction it was already going, with a damping factor (0.82–0.90) that prevents infinite oscillation. Second, buoyancy: a gentle upward force that increases toward the tip, keeping the stalk upright. Third, external forces: ocean current and creature impulses push nodes around. Fourth, constraint solving: distance constraints snap each pair of adjacent nodes back to their rest length.
Drag a node in the demo below to feel how the chain responds. Notice how the base stays anchored and the constraint solver pulls everything taut:
The constraint solver runs multiple iterations per frame (the "stiffness" slider). With 1 iteration, the chain is loose and stretchy. At 3+, segments lock to their rest length almost perfectly. This is the classic trade-off in position-based dynamics: more iterations = stiffer, but more expensive.
// Distance constraint: snap adjacent nodes to rest length
for (let i = 0; i < nodeCount - 1; i++) {
const diff = b.clone().sub(a);
const dist = diff.length();
const error = (dist - restLength) / dist;
// Weighted correction - anchor (mass=0) doesn't move
a.add(diff.multiplyScalar(error * wA / (wA + wB)));
b.sub(diff.multiplyScalar(error * wB / (wA + wB)));
}
Ocean Current Model
Ambient kelp sway comes from a directional ocean current applied as a direct position offset - not a force. Each node gets pushed laterally based on its height in the chain, with a phase that shifts from base to tip. This creates the signature traveling wave shape you see in real kelp forests.
The current has two components: a primary wave along a dominant direction (like tidal flow), and a cross wave perpendicular to it for organic variety. A second harmonic layer adds subtle irregularity. Together with per-plant randomized phase offsets, amplitude, and speed, no two kelp stalks sway the same way.
The magic is in the phase span parameter. It controls how much the wave's phase shifts from the base to the tip. A low value makes the whole stalk lean together like a rigid pole. A high value creates a whip-like traveling wave where the base leads and the tip follows a full cycle behind. The game uses 4.0 radians - about 230° of phase shift - which gives a natural flowing-seaweed look.
A gentle restoring spring pulls each node back toward its rest position. The XZ spring is very loose (0.008) so the stalk can sway freely, while the Y spring is stronger (0.06) to keep things upright. The bottom three nodes get an additional pin force (50% → 0%) that keeps the base grounded while the tip whips freely.
Creature Impulse & Propagation
When a fish swims through kelp, it should push the fronds aside. The system handles this through two mechanisms: impulse injection and neighbor propagation.
When a creature enters a node's collision radius, an impulse is applied in the creature's velocity direction - the kelp gets swept into its wake. A small outward push prevents tunneling. The impulse then spreads to neighbors each frame, creating a ripple that travels up and down the chain. Meanwhile, all impulses decay exponentially (0.88× per frame), so the disturbance fades naturally.
The spread parameter (0.2 in the game) keeps impulses localized - a bump at the middle of the stalk barely reaches the base. Higher values create a telegraph effect where the whole chain reacts. The decay rate (0.88) means each impulse lasts about 20 frames before disappearing, which feels like a natural "bounce back."
// Impulse propagation: each node averages with its neighbors, then decays
for (let i = 1; i < nodeCount; i++) {
let avg = impulse[i];
let count = 1;
if (i > 1) { avg += impulse[i-1]; count++; }
if (i < nodeCount-1) { avg += impulse[i+1]; count++; }
avg /= count;
impulse[i] += (avg - impulse[i]) * spread;
impulse[i] *= decay;
}
Procedural Bone Rigging
The original kelp GLB model has just 5 bones - a root plus 4 sway bones. That's not enough for smooth deformation; you get visible kinks. A ProceduralRig system upgrades this to 13 bones (1 root + 12 sway segments) by evenly spacing them along the stalk and recomputing skin weights.
Each Verlet node maps to a bone. Every frame, the bone's rotation is computed by comparing the current chain direction (from node_i to node_i+1) against the rest-pose direction. The delta rotation is applied relative to the bone's rest-pose world quaternion, then converted to local space. This means the system works regardless of the mesh's original orientation - no assumptions about which axis is "up."
// Drive bone rotation from Verlet node positions
const currentDir = nodeB.clone().sub(nodeA).normalize();
const deltaQuat = new Quaternion().setFromUnitVectors(restDir, currentDir);
const worldQuat = deltaQuat.multiply(restBoneWorldQuat);
// Convert world → local space for the bone hierarchy
const localQuat = parentWorldQuat.invert().multiply(worldQuat);
bone.quaternion.copy(localQuat);
LOD & Performance
Running 60 Verlet chains with 13 nodes each is fine on desktop, but on mobile it adds up. The game uses three LOD levels. LOD 0 (close): full Verlet simulation every frame with 3 constraint iterations. LOD 1 (medium): Verlet runs every other frame with just 1 constraint iteration. LOD 2 (far): Verlet is skipped entirely, replaced by a cheap procedural sine-wave sway on the bones.
When transitioning back from LOD 2 to LOD 0, the Verlet chain positions are reset to match the current bone positions, preventing a jarring snap. The whole system is zero-allocation per frame - all temp vectors and Float32Arrays are pre-allocated in the constructor.
Before - Jolt Ragdoll
300 WASM physics bodies for 60 plants. Worker thread overhead. Spring joints required careful tuning to avoid explosions. Expensive collision detection.
After - Verlet Chain
Pure JavaScript, zero WASM calls. 13 position updates + 3 constraint passes per plant. LOD reduces distant plants to a single sine call. 10× cheaper on mobile.