Path Animation and Arc-Length

You have laid down a graceful curve through your scene — a spaceship's flight path, a camera's dolly move, a bee's wandering flight — and now you want something to travel along it. Two questions immediately split apart. Where on the curve is the object at each instant? And how fast is it going as it gets there? A path animation that answers the first question badly — a camera that lurches, speeds up for no reason, then crawls — ruins the shot even when the curve itself is beautiful.

The trap is that the curve hands you a natural-looking dial, its own parameter, and that dial is almost never the one you want. This page is about the gap between the two, and the standard fix — arc-length reparametrization — that lets you drive an object down any curve at exactly the speed you choose, and turn it to face where it is going.

It builds directly on arc length and reparametrization and on parametric motion, turning that theory into something an animation system actually runs each frame.

A curve and its parameter

A path is a vector-valued function of one parameter — write it C(u) for u \in [0, 1]. Feed in u = 0 and you get the start point; u = 1 gives the end; values in between trace the curve. A cubic Bézier path, for instance, is

C(u) = (1-u)^3 P_0 + 3(1-u)^2 u\, P_1 + 3(1-u)u^2 P_2 + u^3 P_3.

The obvious first idea for animating along it is to tie the parameter straight to a normalized clock: let t run from 0 to 1 over the shot and just set u = t. Simple, and almost always wrong — because equal steps in u are not equal steps in distance.

The parameter bunches — see it

Take a concrete Bézier arch. Plot the fraction of total distance travelled against the parameter u. If the parameter were honest — a step in u always the same step in distance — this graph would be the straight faint line s = u. It is not: the real curve s(u) bows away from that diagonal, because this Bézier dawdles near the start (its control points cluster there, so |C'(u)| is small) and then races across the long final stretch.

Drag the parameter. Read off the gap between the faint diagonal and the bold curve: at u = 0.5 the object has covered only about 35% of the path's length, not the 50% a uniform reading would suggest. Halfway through the parameter is nowhere near halfway through the journey. That gap is exactly the uneven speed you would see on screen.

Arc length: the honest ruler

The cure is to measure the curve by distance actually walked rather than by its parameter. That distance is the arc length. Starting from u = 0, the length accumulated by the time we reach parameter u is

s(u) = \int_0^u \bigl|C'(\tau)\bigr|\, d\tau.

The catch is that s(u) rarely has a tidy closed form (that Bézier integral is nasty), and inverting it analytically is worse. So in practice nobody integrates at runtime — they precompute a table.

The arc-length LUT

Sample the curve at many closely spaced parameter values, add up the little straight-line hops between successive samples, and store the running total. That is an arc-length lookup table (LUT): a list of pairs (u_i, s_i) approximating s(u). To go from a wanted distance s back to a parameter, binary-search the s_i column and interpolate — that is your u(s), no integral required.

type Vec = { x: number; y: number }; function buildArcLUT(C: (u: number) => Vec, samples = 256): number[] { const s: number[] = [0]; // s[i] = distance up to u = i/samples let prev = C(0); for (let i = 1; i <= samples; i++) { const p = C(i / samples); const d = Math.hypot(p.x - prev.x, p.y - prev.y); s.push(s[i - 1] + d); prev = p; } return s; // s[samples] is the total length } // Invert: given a distance target, find the parameter u that reaches it. function uForDistance(lut: number[], target: number): number { const n = lut.length - 1; let lo = 0, hi = n; while (lo < hi) { // binary search for the segment const mid = (lo + hi) >> 1; if (lut[mid] < target) lo = mid + 1; else hi = mid; } const i = Math.max(1, lo); const seg = lut[i] - lut[i - 1]; const frac = seg > 0 ? (target - lut[i - 1]) / seg : 0; return (i - 1 + frac) / n; // interpolated parameter in [0, 1] }

With the LUT in hand, the per-frame recipe writes itself. Choose how far along the object should be as a fraction of total length, convert that to a distance, invert to a parameter, and evaluate the curve:

u(t) = u\bigl(\,\underbrace{e(t)}_{\text{eased fraction}} \cdot L\,\bigr), \qquad \text{position} = C\bigl(u(t)\bigr), \quad L = s(1).

Because the object now advances by distance, its speed is whatever your fraction function e(t) says — set e(t) = t for dead-constant speed, or pass t through an easing curve first for a controlled slow-in / slow-out — completely independent of how the curve happens to be parameterised.

Worked example: two objects, one clock

Put two dots on the same Bézier arch and run the same clock t for both. The orange dot uses the naive rule u = t. The blue dot uses arc length: u = u(t \cdot L). Slide the clock and watch them separate.

Freeze the clock at t = 0.5. This path has total length L \approx 13.55. The two rules land in very different places:

rule at t = 0.5parameter upositiondistance walked
naive u = t0.500(2.19,\ 3.75)4.81  (35% of L)
arc length0.676(4.08,\ 3.29)6.77  (50% of L)

At the very same instant the naive dot has trudged only 35% of the way while the arc-length dot is exactly at the halfway mark, needing parameter u \approx 0.676 to get there. The naive object spends the first half of the shot loitering in the slow cluster near the start, then sprints the back half — visible, unwanted acceleration. The arc-length object glides at constant speed from end to end.

Facing the right way: orienting along the path

Position is only half of path animation. A car, a fish, a chase camera also has to point somewhere sensible as it moves. The classic construction is a little coordinate frame that rides along the curve — the Frenet frame, built from the derivatives of the (already unit-speed) curve.

At a point on a unit-speed curve, three mutually perpendicular unit vectors define an orientation:

Handy, but the Frenet frame has a nasty failure: at an inflection, where the curve momentarily straightens, the curvature drops to zero, N becomes undefined, and as the curve bends the other way N and B snap 180°. Your object flips upside-down for a frame. Straight segments (zero curvature throughout) leave the frame undefined entirely.

The production fix is a rotation-minimizing frame (RMF), computed by parallel transport: keep the tangent honest and carry the up-vector forward with the least twist needed to stay perpendicular, never referencing curvature. It cannot flip, because it never asks which way the curve is bending — only how the tangent turned since the last frame. For vehicles you then add banking: roll the object about its tangent by an amount proportional to how sharply it is turning (curvature times speed squared), so a plane leans into its arc and a roller-coaster car hugs the track. That roll is a deliberate lie about "up" layered on top of the honest, flip-free frame.

The single most common path-animation bug is driving position with the curve's own parameter — u = t — and wondering why the motion lurches. It looks innocent because at authoring time you drag control points and the curve looks smooth; the speed problem only shows up in motion. Any spline with unevenly spaced control points (which is nearly all of them) has a parameter that bunches, so the object visibly speeds up and slows down for no story reason — worst near tight clusters and sharp corners. The fix is never "add more keyframes" or "tweak the tangents": it is to reparametrize by arc length — build the LUT once, drive the object by eased distance s(t), and let u(s) absorb the curve's crooked parameterisation. Then, and only then, does your easing curve actually mean what it says.

Exactly this trick, run in reverse and reused. Each patrol route is baked once into an arc-length LUT at load time, so the total length L is known. To move a guard at a fixed 1.4 m/s, the engine advances a distance each frame — 1.4 \, \Delta t metres — and looks up the parameter for that new distance. The guard holds walking pace whether the road is straight or hairpin, because distance, not parameter, is the currency. Better still, one LUT serves a whole squad: give each soldier a different starting distance and they march in evenly spaced, rock-steady formation down the same curve. And because the table also yields the tangent C'(u)/|C'(u)| cheaply, the same lookup that places each guard also tells it which way to face.