Three.js From Zero · Article s3-05

Procedural Animation — Secondary Motion

← threejs-from-zeroS3 · Article 05 Season 3
Article S3-05 · Three.js From Zero

Procedural Animation — Secondary Motion

Primary animation is keyframed: the character steps, the arm swings. Secondary motion is the follow-through: hair sways behind the step, clothes ripple, a tail catches up a beat later, the chest rises and falls with breath. None of it is authored — it's physically simulated each frame, reacting to the primary animation.

Add secondary motion to a stiff-looking character and it comes alive. The demo has a creature with three procedural systems at once: a spring-damper chain for the tail, sinusoidal breathing, and a head-bob that reacts to the creature's turning motion.

loading…

The core primitive: spring-damper

The building block for every procedural system. A mass connected to a target by a spring (pulls toward target) and a damper (resists motion). Integrated each frame:

class Spring3 {
  constructor(stiffness, damping) {
    this.pos = new Vector3();
    this.vel = new Vector3();
    this.stiffness = stiffness;   // higher = faster / snappier
    this.damping = damping;       // higher = less overshoot
  }
  update(target, dt) {
    // F = -k(x - target) - b·v
    const force = target.clone().sub(this.pos).multiplyScalar(this.stiffness);
    force.addScaledVector(this.vel, -this.damping);
    this.vel.addScaledVector(force, dt);
    this.pos.addScaledVector(this.vel, dt);
  }
}

Tuning:

  • Stiffness = (2π × frequency)². Frequency = how fast it wants to oscillate. Heart-beat ~1Hz → stiffness ~40. Snappy UI ~5Hz → stiffness ~1000.
  • Damping = 2 × ω × ζ where ω = sqrt(stiffness) and ζ ∈ [0..1]. ζ=1 is critically damped (smooth, no bounce). ζ=0.3 is springy-bouncy.
Critically damped springs feel "mechanical and settled". Slightly under-damped (0.5-0.7) feel "alive". Over-damped feels "molasses". For character secondary motion, aim for 0.5-0.8.

Bone chain — tails, hair, antenna

Apply a spring-damper at each bone's tip. The tip follows the previous bone. Each bone rotates to aim at its tip's spring position.

class BoneChain {
  constructor(bones, stiffness, damping, gravity) {
    this.bones = bones;
    this.tips = bones.map(() => new Spring3(stiffness, damping));
    this.gravity = gravity;
    // Initialize tips at each bone's rest world position
  }
  update(dt) {
    for (let i = 0; i < this.bones.length; i++) {
      const bone = this.bones[i];
      // Where this bone's rest position would put its tip in world space
      const restTip = computeRestTipWorld(bone);

      const tip = this.tips[i];
      tip.update(restTip, dt);
      // Gravity pulls the tip down
      tip.vel.y -= this.gravity * dt;

      // Rotate bone so it aims at tip.pos
      const bonePos = bone.getWorldPosition(new Vector3());
      const toTip = tip.pos.clone().sub(bonePos).normalize();
      // ... set bone.quaternion to align its forward axis with toTip
    }
  }
}

The result: turn the body sharply, tail sweeps behind in a delayed arc. Stop, tail settles with a small wobble. Exactly like a real tail.

Breathing — low-frequency noise + sine

Chest expands on inhale, contracts on exhale. Simple sine works but looks robotic. Add small random jitter for "alive":

function breath(t, rateHz = 1.4) {
  const phase = t * rateHz * Math.PI * 2;
  const wave = Math.sin(phase);
  // Not pure sine — inhale is faster than exhale. Power reshape.
  const shaped = Math.sign(wave) * Math.pow(Math.abs(wave), 0.7);
  // Slight randomness
  const jitter = Math.sin(t * 17) * 0.05;
  return shaped + jitter;
}

// Apply to chest bone scale or morph:
chestBone.scale.y = 1 + breath(t) * 0.04;
chestBone.scale.x = 1 + breath(t) * 0.02;

4% scale change is plenty. 8% and people look like they're panting.

Head-bob from motion

When the character moves, the head shouldn't stay perfectly level. Two effects:

  1. Vertical bob in time with footsteps (even with no walk animation yet)
  2. Lean into turns, like a motorcyclist
function headBob(t, walkPhase, turnRate) {
  const vertical = Math.sin(walkPhase * Math.PI * 2) * 0.02;
  const horizontal = Math.sin(walkPhase * Math.PI) * 0.01;
  const leanIntoTurn = -turnRate * 0.3;    // rad, lean opposite to turn direction
  return { vertical, horizontal, leanIntoTurn };
}

head.position.y += bob.vertical;
head.rotation.z = bob.leanIntoTurn;

Layer this over whatever the skeleton animation does. Spring-damp the final rotation so it doesn't snap when turnRate changes abruptly.

Follow-through — springs on keyframe output

Primary animation raises the arm. But a hand at the end of a chain shouldn't reach final position instantly — it should overshoot slightly then settle. Wrap every hand-bone position in a spring:

const handSpring = new Spring3(200, 18);

// Each frame, AFTER the animation mixer updates:
const target = hand.getWorldPosition(new Vector3());   // keyframe-set pos
handSpring.update(target, dt);

// Override the hand's world pos with the sprung pos
setBoneWorldPosition(hand, handSpring.pos);

Underdamp slightly (ζ ≈ 0.6) and hand gestures get natural follow-through. Over-damp and it feels like slow motion.

Verlet bones for ultra-cheap cloth

For hair, capes, skirts: a bone chain with Verlet integration gives you cloth-like motion at basically zero cost. The math: each joint has current and previous position. Each step: predict next = current + (current - prev) + gravity·dt². Then constrain distances.

function verletStep(joints, gravity, dt) {
  for (const j of joints) {
    const pred = j.pos.clone().multiplyScalar(2).sub(j.prev);
    pred.y -= gravity * dt * dt;
    j.prev.copy(j.pos);
    j.pos.copy(pred);
  }
}

function constrainDistance(a, b, L) {
  const delta = b.pos.clone().sub(a.pos);
  const d = delta.length();
  const diff = (d - L) / d;
  if (!a.fixed) a.pos.addScaledVector(delta, 0.5 * diff);
  if (!b.fixed) b.pos.addScaledVector(delta, -0.5 * diff);
}

// Each frame: step, then enforce constraints 3-5 times

No forces, no stiffness tuning — just position correction. Used in cloth sims across every engine because it's stable at large timesteps.

Reactive wiggle — twitch on impact

Character gets shot / hit / lands. You want a quick flinch. Apply an impulse to a spring chain:

function flinch(chain, impulseWorld) {
  for (const tip of chain.tips) {
    tip.vel.add(impulseWorld.clone().multiplyScalar(0.5 + Math.random() * 0.5));
  }
}

Spring chain settles over the next ~0.5s. Free "ragdoll-lite" reactions without switching to full physics.

Common first-time pitfalls

  • Spring explodes / NaNs. Stiffness too high for your timestep. Either lower stiffness or sub-step the integration (run 2-3 times per frame at dt/n).
  • Secondary motion feels sluggish. Damping too high. Start with critical damping, reduce ζ toward 0.5 until it looks alive.
  • Chain curls into itself. Missing distance constraints (Verlet) or missing rest-direction (springs). Each bone needs a concept of "where my tip should be when nothing is moving".
  • Breathing snaps. Pure sine over an axis that the animation also controls. Layer carefully or use a dedicated breath bone.
  • Tail lags forever. Damping too high + stiffness too low. Increase stiffness first; the tail should catch up in ~0.3s.
  • Walking causes whole body to wobble. Chain root is attached to something that's moving. That's fine — the motion is what drives the secondary. If too much, anchor the root more tightly (higher stiffness on root).

Exercises

  1. Cape with Verlet: 8×12 grid of joints, top row pinned to shoulder bones. Distance constraints horizontally + vertically. Add wind as a sine-based force.
  2. Eye gaze with spring: target for eyes lerps toward cursor/character-of-interest, but eye bones use a spring so they overshoot slightly and settle.
  3. Landing squash: when a character lands, crush chest scale to 0.92 then spring back to 1.0 over 0.3s. Cartoon impact feel.

What's next

S3-06 — Physics-Driven Animation. Active ragdolls. Character gets hit, the skeleton goes ragdoll briefly, then blends back to keyframed when it lands. Tying Season 2 physics into Season 3 animation.