Interpolation and easing

17-interpolation-and-easing.md · updated 2026-10-17

Moving a value from where it is to where it should be, over time. Note 12 is about simulating motion under forces; this is about authoring it — a tab sliding out, a camera settling behind a character, a turret swinging onto a target. The two mechanisms below cover almost all of it, and most bugs come from using the wrong one.

A gate is a timer you can run backwards

Store one t from 0 to 1, derive position from it, never store position.

Tab :: struct { open: bool, t: f32, duration: f32 }

tab_update :: proc(tab: ^Tab, dt: f32) {
    dir: f32 = tab.open ? 1 : -1
    tab.t = clamp(tab.t + dir * dt / tab.duration, 0, 1)
}

dt / duration is the per-frame step, so a 0.25 s slide takes 0.25 s on any machine. The payoff is the dir: because a single value moves both ways, toggling mid-slide simply reverses from wherever it got to. No "closing" state, no state machine, no snap back to the start. That reversibility is the entire reason to store t instead of storing x and a target.

Use a gate when the motion has a known duration and a known end: menus, panels, doors, scripted camera moves.

The curve is a separate concern

t is always linear; all personality lives in the function you push it through before lerping.

ease_smoothstep :: proc(t: f32) -> f32 { return t * t * (3 - 2 * t) }
ease_out_cubic  :: proc(t: f32) -> f32 { u := 1 - t; return 1 - u*u*u }

ease_out_back :: proc(t: f32) -> f32 {
    c1 :: f32(1.70158); c3 :: c1 + 1
    u := t - 1
    return 1 + c3*u*u*u + c1*u*u
}

Smoothstep is symmetric — soft at both ends, right for something that moves and stops. Out-cubic is the interface default: most of the distance is covered early, which reads as responsive. Out-back overshoots roughly 10% past the target and settles, which reads as weight.

Overshoot needs somewhere to go. A panel that rests flush against a container edge, or sits inside a clipping rect, has its excursion eaten and the animation just looks like it stalled.

Chasing has no duration

When the target keeps moving, there is no 0-to-1 to run. A follow camera is never finished arriving, so it decays toward wherever the target is now.

The version everyone writes first is frame-rate dependent:

cam.pos = math.lerp(cam.pos, target, 0.1)   // wrong: converges 2.4× faster at 144 Hz

dt belongs in the exponent, not the coefficient:

smooth_damp :: proc(current, target, half_life, dt: f32) -> f32 {
    return target + (current - target) * math.exp2(-dt / half_life)
}

half_life is the time to close half the remaining gap — directly tunable, frame-rate independent. 0.1 s is snappy, 0.4 s is heavy. The gap never mathematically reaches zero, so snap below an epsilon if exact arrival matters.

Angles need the short way round

You cannot lerp angles directly. 170° to −170° is a 20° turn, but a naive lerp reads it as 340° and takes the long way. Wrap the difference first:

angle_lerp :: proc(a, b, t: f32) -> f32 {
    d := math.mod(b - a + math.PI, math.TAU)
    if d < 0 do d += math.TAU
    return a + (d - math.PI) * t
}

Or sidestep angles: lerp the two direction vectors and renormalise. That is nlerp, and it is the same move as the quaternion case in note 16 — interpolate a representation with no discontinuity, then project back onto unit length. No wrap, no branch, no seam. The only cost is non-constant angular velocity, visible only at large angles.

Demo

Four curves driving a panel, plus the angle-wrap failure side by side — reverse the gate mid-slide to see why t is stored instead of x, then drag the frame-rate slider under the two chase cameras and watch only the naive one change speed.

Gotchas

Symptom Cause
Animation faster on a better PClerp(a, b, k) per frame with no dt; use the exponential form
Panel jumps back to the start when toggled mid-slidePosition stored instead of a reversible t
Overshoot invisibleClipped by the container, or the rest position is flush against an edge
Turret spins 340° to turn 20°Angle lerped without wrapping the difference
Chase camera never quite arrivesCorrect — exponential decay is asymptotic; snap below an epsilon
Motion feels mushy at every durationLinear easing; almost nothing should be linear
Easing "snaps" at the endt not clamped, so the curve is evaluated past 1
Slerp between orientations takes the long pathQuaternion sign not fixed first — see note 16

In KoldEngine

Worth keeping the gate and the chase as two named helpers rather than open-coding lerp at each call site — the half-life form in particular is easy to get subtly wrong once per file. If UI motion and camera follow both exist, they want different tools: gates for the former, damping for the latter.

Open the demo →

← all notes & demos