Computer Graphics · Animation
Keyframe Interpolation and the Cubic Hermite Segment
How a sparse animation channel becomes a continuous function of time, why cubic tangents carry units of value per second, and which degenerate segments an evaluator must survive.
An animation clip is a sparse set of statements about a value at particular times. A joint’s rotation is given at frame 0, at frame 12, at frame 30, and between those times the runtime must produce a value for every rendered frame without the clip storing the intermediate values. Keyframe interpolation is the machinery that turns the sparse statements into a total function of time. The interpolation formulas themselves are a few lines each. The parts that cause trouble are the conventions around them: how time is scaled, what the tangents mean, and which malformed inputs an evaluator has to survive.
A channel is a function of time, not a list of poses
A clip holds channels, and a channel is a typed array of keys: a time and a value, repeated. Times are strictly increasing within a channel, values have a fixed component count (one for a field of view, three for a translation, four for a rotation quaternion), and the channel carries a single interpolation mode for the whole array. glTF 2.0 defines exactly three modes. STEP holds a value until the next key, which is what a constant track reduces to and what a discrete visibility switch requires. LINEAR joins neighbouring values with a straight line. CUBICSPLINE stores three values per key, an in-tangent, the value, and an out-tangent, and evaluates a cubic Hermite segment between consecutive keys.
The structure that makes evaluation cheap is the assumption that times are sorted. With sorted times, sampling costs a binary search over the keys, $O(\log n)$ for a channel of n keys, followed by a constant amount of arithmetic in the one segment that brackets the requested time. The logarithm is smaller in practice than it sounds: a channel with thirty keys needs five comparisons. The competing layout is a uniform grid with one slot per frame, which trades memory for a direct index. That trade only pays when the clip is played forward at a known rate and never scrubbed; scrubbing, blending, and reverse playback all want the search.
Behaviour outside the stored range is the caller’s policy, not the file’s. glTF says nothing about what happens before the first key or after the last, and implementations clamp, repeat, or ping-pong. Clamping is the safe default because it cannot invent motion, while looping is a property of the playback system, which knows the intended period. An evaluator that silently wraps time inside a channel produces a discontinuity wherever the last value differs from the first, which is the normal case for a stride that does not return to its start pose.
Time is normalized before the basis is evaluated
The segment between two keys is a Hermite cubic, determined by the values and derivatives at its endpoints. Writing $u = (t - t_0)/(t_1 - t_0)$ for the normalized position inside the segment, the value is
$$p(u) = h_{00}(u)p_0 + h_{10}(u)m_0 + h_{01}(u)p_1 + h_{11}(u)m_1$$
with the basis polynomials
$$h_{00} = 2u^3 - 3u^2 + 1,\quad h_{10} = u^3 - 2u^2 + u,\quad h_{01} = -2u^3 + 3u^2,\quad h_{11} = u^3 - u^2.$$
The $h_{10}$ and $h_{11}$ terms multiply derivatives rather than values, and the basis is built so that the derivatives of $p$ at $u=0$ and $u=1$ are exactly $m_0$ and $m_1$. At $u=0$, $h_{00}$ equals one, $h_{10}$ has unit slope, and the remaining terms vanish with zero derivative; symmetry at $u=1$ gives the far endpoint the same property.
typedef struct { float t, v, in_t, out_t; } key_t;
/* Hermite evaluation on [k0, k1] at absolute time t.
Tangents arrive in value units per second, so each is rescaled by dt. */
static float eval_segment(const key_t *k0, const key_t *k1, float t) {
float dt = k1->t - k0->t;
if (dt <= 0.0f) return k0->v; /* degenerate: hold the value */
float u = (t - k0->t) / dt;
float u2 = u * u, u3 = u2 * u;
float h00 = 2*u3 - 3*u2 + 1, h10 = u3 - 2*u2 + u;
float h01 = -2*u3 + 3*u2, h11 = u3 - u2;
float m0 = k0->out_t * dt, m1 = k1->in_t * dt; /* slope in u */
return h00*k0->v + h10*m0 + h01*k1->v + h11*m1;
}
Normalization is what makes the tangent units matter. The basis evaluates in $u$, a parameter in which every segment spans zero to one regardless of its duration, so the derivative with respect to $u$ is the derivative with respect to time multiplied by dt. Storing tangents as value per second and applying the factor at evaluation keeps a tangent meaningful when a key is moved in time. Retiming a segment changes dt, and therefore changes the slope in $u$, without any edit to the stored number. A format that stored the slope in $u$ instead would need every tangent rewritten whenever neighbouring key spacing changed, which is exactly what a retiming tool does.
Where the joins gain or lose continuity
Each segment is smooth. The piecewise curve is what callers sample, and its character is decided at the keys. If the out-tangent of key i differs from the in-tangent of key i+1, the curve is continuous in value but has a corner in velocity: the derivative jumps. That is $C^0$ continuity with a $C^1$ break. In a position channel the corner reads as a change of speed at the key. In a rotation channel the damage is larger, because a quaternion channel interpolated per component and then normalized does not follow the intended arc at all, which is the subject of rotation interpolation.
Authoring tools hide the choice behind tangent modes. An automatic tangent is computed from neighbouring values so that the slope at a key lies between the slopes of the adjacent chords, which prevents overshoot past a local extremum. A flat or clamped tangent forces the derivative to zero, producing ease-in and ease-out. A user-set tangent gives direct control and, when the two sides are set independently, deliberately produces the corner. The parametric family from Kochanek and Bartels exposes tension, continuity, and bias as three scalars that blend between these behaviours without switching modes.
Overshoot is the failure that comes from getting the join wrong. A cubic with large tangents can leave the interval between its endpoint values, so a channel that must stay in [0,1] can emit 1.4 between two keys that are both inside the range. For a rotation component this is not a rounding-level error. Normalization maps the out-of-range value back onto the unit sphere, and the resulting angle can exceed the angle between the endpoint rotations, so a joint swings past its target and returns. Clamped tangents bound the excursion; linear tangents, whose slope is the chord, eliminate it at the cost of the smoothing that made the cubic worth storing.
Evaluation must not depend on the frame rate
Clip time is stored in seconds and samplers are called with an absolute time. The mapping from wall-clock time to clip time is a separate concern, and the recurrent bug is advancing a per-channel cursor by a fixed amount per rendered frame. A frame that takes 33 ms instead of 16 ms then advances the animation by half as much as it should, and playback speed becomes a function of load. The standard structure is an accumulator: wall-clock delta is added to a clip-time variable, and the evaluator receives clip time. Blending two clips with different rates, as in a walk-to-run transition, becomes a matter of scaling the rate rather than resampling keys.
Sub-frame sampling raises a related question. A sampler running at 60 Hz against keys on 30 Hz boundaries sees two samples per segment, and the arithmetic is identical to the case with a key every frame; nothing in the Hermite form cares. What does care is the delivery path. An engine that evaluates on the render thread and uploads a matrix palette every frame transforms the skeleton once per frame. An engine that evaluates at a fixed simulation rate reuses one pose across several rendered frames and interpolates only the output, which decouples animation cost from render rate at the price of a frame of latency in the pose.
Degenerate segments
Evaluators receive data from exporters and hand-edited files, and a few shapes must be handled without producing a NaN or a stalled search.
Duplicate key times make dt zero. The normalization divides by it, and a naive evaluator propagates an infinity into the basis. Holding the first value of the pair is the least surprising result, and it is the limit of the segment taken from the right. Non-monotonic times violate the search’s precondition: the binary search still terminates, but it may return a segment that does not bracket the requested time, so a defensive evaluator verifies $t_0 \le t \le t_1$ after the search and clamps. A channel with one key has no segment and must return the constant for every time; a channel with no keys must be rejected at load rather than sampled.
Component counts have to match the declared type. Reading a quaternion’s four components from an accessor that declares three yields garbage in the fourth, and normalizing that garbage spreads a NaN through every matrix that later consumes the pose. The cubic layout is itself a frequent export error: each key occupies three values, so the accessor is three times the key count, and a converter that writes only the value with zero tangents produces motion that stops at every key and resumes between them. The curve looks broken, but the interpolation is correct and the layout is not.
Interpolation is the smallest part of an animation system and the part everything else stands on. The pose this evaluator emits is consumed by the skinning palette, compressed by key reduction, and mixed with other clips by blend layers. A NaN here is not a curve defect. It is a mesh that disappears.
References
- glTF 2.0 Specification, Animations — Khronos Group
- An Introduction to Splines for Use in Computer Graphics and Geometric Modeling — Bartels, Beatty and Barsky, Morgan Kaufmann, 1987
- Interpolating Splines with Local Tension, Continuity, and Bias Control — Kochanek and Bartels, SIGGRAPH 1984