Interpolation and easing
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 PC | lerp(a, b, k) per frame with no dt; use the exponential form |
| Panel jumps back to the start when toggled mid-slide | Position stored instead of a reversible t |
| Overshoot invisible | Clipped 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 arrives | Correct — exponential decay is asymptotic; snap below an epsilon |
| Motion feels mushy at every duration | Linear easing; almost nothing should be linear |
| Easing "snaps" at the end | t not clamped, so the curve is evaluated past 1 |
| Slerp between orientations takes the long path | Quaternion 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.