Secondary Motion: Damped Springs for Hair and Tails
Secondary Motion: Damped Springs for Hair and Tails
Search
Ask the AI

Secondary Motion: Damped Springs for Hair and Tails

Once the keyframe curves are dialled in, the character moves — and still reads as stiff. Hair snaps into place with the head, a cape behaves like a board glued to the back, ears and tail do not move at all. What is missing is secondary motion: the parts an animator does not pose frame by frame, which get dragged along by the primary action and arrive a beat late.

Secondary motion can be hand-keyed, but it is expensive and has to be redone every time the primary action changes. It is far cheaper to let physics produce it. This covers the most practical model for the job — the damped spring — and the four traps you hit when wiring it into a real 2D skeletal system.

One damped spring

The model is a single line. Some quantity has a current value x, a target t, and a velocity v. Acceleration is the sum of a restoring force pulling it toward the target and a damping force resisting motion.

a = -k · (x - t) - c · v

k is stiffness, governing how fast it springs back; c is damping, governing how long it keeps wobbling. Handing those two numbers to an animator is unkind, because they are not orthogonal — raising k makes the motion both faster and more prone to oscillate.

A different parameterisation is far more usable: frequency f (oscillations per second) and damping ratio ζ (how decisively it settles).

omega = 2 * math.pi * f
k = omega * omega
c = 2 * zeta * omega

These two are orthogonal: f controls speed only, ζ controls the number of bounces only. ζ < 1 is underdamped and swings back and forth a few times — exactly what hair, ears and tails want. ζ = 1 is critically damped, arriving as fast as possible without overshoot — the right choice for camera follow and UI easing. ζ > 1 is overdamped, creeping into place, which suits very heavy objects.

In practice those are the two sliders to expose, and ranges of f ∈ [0.5, 6] and ζ ∈ [0.1, 1.2] cover essentially everything.

The wrong integrator makes the spring explode

This is the first trap and the easiest one to fall into. Having written the acceleration, you need to advance a frame, and the intuitive way to write it is:

x += v * dt          # position from the OLD velocity
v += a * dt

That is explicit Euler, and for a spring system it is unconditionally unstable. The reason can be stated precisely: for an undamped harmonic oscillator, explicit Euler’s per-step amplitude gain is √(1 + ω²dt²), which is always greater than 1. Energy grows every single frame. With a soft k the growth is slow enough to go unnoticed; stiffen it and the hair swings wider and wider until it leaves the screen.

Swapping two lines fixes it — update velocity first, then move the position using the new velocity:

v += a * dt          # velocity first
x += v * dt          # position from the NEW velocity

This is semi-implicit (symplectic) Euler. It costs exactly the same arithmetic and is stable for ω · dt < 2. At 60 fps with dt ≈ 0.0167, that allows frequencies up to roughly 19 Hz — far above anything animation will ask for.

What makes this trap hide well is that with gentle parameters the two versions look identical. It only surfaces when somebody stiffens the spring, or when the game drops frames and dt grows — and at that moment nobody suspects the integrator.

Different frame rates, different results

Switching to semi-implicit Euler stops the explosion but does not fix a second problem: motion amplitude varies with frame rate. The same parameters at 30 fps and 60 fps produce visibly different swing amplitude and decay rate on a tail. Put two screen recordings side by side and the difference is obvious.

The dependable fix is to pin the physics step and make up the difference with an accumulator:

accumulator += frame_dt
while accumulator >= FIXED_H:          # FIXED_H is typically 1/120
    v += (-k * (x - t) - c * v) * FIXED_H
    x += v * FIXED_H
    accumulator -= FIXED_H

The cost is potentially several steps per frame, but each step is a handful of multiply-adds, so even dozens of springs barely register.

Do cap the iteration count. On a scene change, after a debugger breakpoint, or when the window returns from the background, frame_dt can be several seconds — and without a cap this loop runs thousands of steps at once. It presents as a hitch, and worse, the hitch inflates the next frame_dt, so it can spiral into itself. Past the cap, discard the remaining time outright.

A bone chain must update root-to-tip within one frame

Hair and tails are not one spring but a series, and that raises an ordering question, because each segment’s target position is determined by its parent.

If every segment reads its parent’s position from the previous frame, motion takes n frames to reach segment n. On a six-segment tail, the tip lags the root by six frames — a quarter of a second at 24 fps. It reads as a tail that has come apart, with the tip moving independently of the base.

The correct approach walks the chain root to tip every frame, each segment consuming the result its parent produced this same frame:

for i, seg in enumerate(chain):        # chain is pre-sorted root to tip
    anchor = chain[i - 1].tip if i else root_transform.point
    seg.target = anchor + seg.rest_offset
    seg.step(h)                        # integrate now, for the next segment

The rule holds for all hierarchical secondary motion: a computation that depends on a parent’s result must complete in the same frame as the parent, ordered after it.

Spring the angle, not the position

The third trap is about what you attach the spring to. The intuitive choice is the bone tip’s position, but positions computed that way are not compatible with the bone’s fixed length — the hair stretches and contracts as it swings.

There are two ways out. The simple one is to spring the angle: each bone has a single free local rotation, the spring acts on that, and length is preserved by construction. This suits 2D particularly well, because one degree of freedom per segment keeps parameters legible — an animator can reason about “this segment swings at most 30 degrees.”

The other is to integrate positions and then project each segment back to its rest length:

d = seg.tip - seg.root
seg.tip = seg.root + d / max(length(d), 1e-6) * seg.rest_length

That is the positional-constraint approach, more flexible for external forces and collisions. Note that the projection alters position without altering velocity, so the constrained-away component lingers in next frame’s velocity. Doing it properly means projecting that component out of the velocity too, or you get a faint permanent jitter.

Sometimes you want lag, not a spring

Not all secondary motion should bounce. A camera following a character, a health bar chasing its true value, a held prop catching up to the hand — these want “arrive a beat late” without any swing. A critically damped spring does that, but there is a simpler first-order model:

x += (t - x) * (1 - math.exp(-rate * dt))

There is no velocity state here, just a rate: larger follows more tightly. It can never overshoot and cannot become unstable.

The part to be careful about is that 1 - exp(-rate·dt). Almost everyone writes it the following way instead, and it is the single most common frame-rate bug in graphics code:

x += (t - x) * 0.1        # wrong: 0.1 is "per frame", not "per second"

Closing 10% of the gap per frame means that over one second at 60 fps you close 1 - 0.9^60 ≈ 99.8%, and at 30 fps only 1 - 0.9^30 ≈ 95.8%. Change the frame rate and the follow tightness changes with it. The exponential form is frame-rate independent by construction: split a frame into two halves and evaluate twice, and the result is identical to evaluating once.

As an aside, exposing this as a half-life is friendlier than exposing rate. “The gap halves every 0.2 seconds” is something an animator can reason about directly, and the conversion is rate = ln(2) / half_life.

Angle springs have to handle wraparound

Springing an angle brings one boundary you must handle. Angles are usually stored in (-180°, 180°], so when a target moves from 179° to -179° — an actual rotation of 2 degrees — a plain subtraction yields -358 degrees, the spring concludes it needs to travel most of a full turn, and the hair sweeps all the way around the head and back.

The fix folds the difference onto the shortest path:

def shortest_delta(a, b):
    d = (b - a + 180.0) % 360.0 - 180.0
    return d

What characterises this bug is that it only fires at particular headings. Facing right everything is fine; turn to face left, cross that boundary, and the hair suddenly whips around. Testing only in the default orientation will never surface it — which is why angle-related tests have to sweep the full 360 degrees.

Springs belong at the end of pose evaluation

One more ordering question, guaranteed to come up on any rig that also uses inverse kinematics.

Secondary motion is layered on top of the final pose. Run the springs before solving IK and the solver will rewrite joint angles to reach its target, overwriting everything the springs just produced. The symptom is that secondary motion vanishes entirely on the IK-driven chain while working normally elsewhere — which reads convincingly as “the spring parameters aren’t taking effect.”

The correct per-frame order is: sample the keyframe curves for a base pose, solve IK to get final bone angles, and only then run the springs over that result, root to tip.

pose = sample_curves(time)      # keyframes
solve_ik(pose)                  # IK overwrites some joints
step_springs(pose, dt)          # secondary motion layers on the final pose

Put another way: a spring’s input must be the pose already finalised for this frame. Anything that will still modify bones has to run ahead of it.

Teleports blow the spring up

The last trap is not in the mathematics; it is in the pipeline.

Camera cuts, character teleports, level reloads — at these moments the character’s root transform jumps somewhere entirely different within a single frame. What the spring observes is a target that moved 2000 pixels in 16 milliseconds, so it derives an enormous velocity and the hair thrashes for the next several seconds. It is extremely visible.

The fix is to tell the spring system explicitly that a given displacement was not motion: after any teleport, snap the whole chain to its rest pose and zero the velocities.

def teleport(self, new_root):
    for seg in self.chain:
        seg.x = seg.rest_position_under(new_root)
        seg.v = Vec2(0, 0)

Resist the temptation to detect this automatically with a “displacement over threshold” heuristic. Legitimate fast movement trips it, and the symptom — hair freezing for an instant during a sprint — is harder to diagnose than the problem it replaced. A teleport is a fact the caller knows; make the caller say so.

How to verify it is right

Secondary motion is judged subjectively, but its implementation has several properties you can check objectively.

First, energy must not grow on its own. Pin the target, displace the system once, let it decay freely, and record the peak of each cycle. Those peaks must decrease monotonically; a single increase means the integrator is wrong. A few hundred frames is enough to see it, and it is far more reliable than the eye.

Second, at rest it must be exactly at rest. Run for a minute with a stationary target and the tip’s position change should converge to floating-point noise. Persistent jitter at some tiny amplitude usually means the constraint projection is not being mirrored in the velocity.

Third, the envelope must match across frame rates. Run the same primary animation at 30, 60 and 144 fps and overlay the tip position against time. With a correct fixed-step implementation the three curves nearly coincide; any divergence means something is still consuming frame_dt directly.

None of the three requires an art judgement, so all three can be automated and re-run whenever parameters change or the integrator gets refactored.

Leave a Reply

Scroll down