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 u only labels points on the curve; it says nothing
about how far apart they are.
- The speed at which the curve sweeps out distance as u
advances is the length of its velocity vector, |C'(u)| — and for a
typical spline this varies wildly along the curve.
- So driving u = t makes the object crawl where
|C'(u)| is small and lunge where it is large: speed that lurches with
the shape of the curve, not with your intent.
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.
- s(u) is non-decreasing: as
u grows you can only walk forward, so
s' = |C'| \ge 0.
- Where the curve moves at all it is strictly increasing, hence
invertible: there is a unique u(s) giving the
parameter at which you have walked distance s.
- Reparametrizing by s gives a curve of
unit speed: \bigl|\tfrac{d}{ds} C(u(s))\bigr| = 1.
One unit of the new parameter is one unit of distance, always.
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.5 | parameter u | position | distance walked |
| naive u = t | 0.500 | (2.19,\ 3.75) | 4.81 (35% of L) |
| arc length | 0.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:
- the tangent T = C'(s) — the direction of travel, so
the object's nose points along T;
- the normal N = T'/|T'| — points toward the centre of
the turn (its length is the
curvature
\kappa);
- the binormal B = T \times N — completes a
right-handed frame, the object's "up".
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.