On this page

  1. Why Verlet?
  2. The Basic Chain
  3. Ocean Current Model
  4. Creature Impulse & Propagation
  5. Procedural Bone Rigging
  6. LOD & Performance

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:

Interactive Verlet Chain - drag any node

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.

Ocean current - kelp forest with varying parameters

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.

Creature impulse - fish pushes kelp aside as it swims through

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);
13
Bones per plant
3
Constraint iterations
0
Allocations per frame
60
Plants at 60fps

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.