Computer Graphics (rasterisation, ray tracing, WebGPU) Module 5 – 3D transformations, rotations, cameras and projection
Quaternions and rotation interpolation
Store rotations as unit quaternions, convert them to and from axis-angle and matrices, compose them, and interpolate with slerp and nlerp the short way.
What you will learn
- Represent rotations as unit quaternions and convert to and from axis-angle and matrices
- Rotate vectors and compose rotations with quaternion multiplication
- Implement slerp and nlerp between orientations and handle the double cover
- Compare the paths that slerp, nlerp and Euler-angle interpolation take between two orientations
Before you start
What you’ll learn · Before you start
What you will learn
- Represent rotations as unit quaternions and convert to and from axis-angle and matrices
- Rotate vectors and compose rotations with quaternion multiplication
- Implement slerp and nlerp between orientations and handle the double cover
- Compare the paths that slerp, nlerp and Euler-angle interpolation take between two orientations
Before you start
On this page
A character’s head turns from yaw 170° to yaw −170°, two keyframes only 20° apart, and the animation swings it 340° the long way round. Blend a larger change, from yaw, pitch and roll (0°, 10°, 0°) to (170°, 70°, −60°), one Euler angle at a time, and the head travels 220° for a 150° turn. Both bugs disappear once orientations are stored as unit quaternions and blended along the shorter arc.
You will build quaternions from an axis and an angle, from two directions and from a matrix, compose them and turn vectors with them, keep them unit length in float32, and interpolate them with slerp and nlerp, measuring how far each method strays. Every number comes from a program that runs on this page.
A unit quaternion stores an axis and half the angle
The 120° turn about the diagonal (1, 1, 1) that cycled the axes in the last lesson takes nine numbers as a matrix and three as Euler angles: Three angles of turns about coordinate axes, applied in a stated order such as XYZ, that together describe an orientation. Without the order and its reading they are ambiguous. More in “3D transformations, Euler angles and gimbal lock” that need an order. As a quaternion: A number w + x i + y j + z k with one real part and three imaginary units that obey i² = j² = k² = ijk = −1. Graphics code stores it as (x, y, z, w). it is four numbers, all of them 0.5. A quaternion is w + x i + y j + z k, a number with one real part and three imaginary units, which William Rowan Hamilton published as a new system of imaginaries in algebra. A turn by θ about the unit axis k is the unit quaternion: A quaternion of length 1. The turn by θ about a unit axis k is (k sin(θ/2), cos(θ/2)), and it turns a vector v as q v q*.
In words: the axis, scaled by the sine of half the angle, then the cosine of half the angle. For 120° about the normalised (1, 1, 1), sin 60° times 1/√3 is 0.5 and cos 60° is 0.5. three.js and glTF store the four numbers in the order (x, y, z, w), with w last; glTF’s specification calls a node’s rotation “a unit quaternion value, XYZW”, where W is the scalar. The program builds the quaternion, turns vectors with it, converts it to a matrix and back, and builds the turn that takes one direction onto another:
// Unit quaternions as rotations, stored (x, y, z, w) with w the scalar part, as three.js and glTF store them:
// from an axis and an angle, from two directions, to a 3 x 3 matrix and back, and turning a vector by q v q*.
const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
function fromAxisAngle([ax, ay, az], angle) {
const s = Math.sin(angle / 2) / Math.hypot(ax, ay, az);
return [ax * s, ay * s, az * s, Math.cos(angle / 2)];
}
// The Hamilton product a b; with it, q v q* turns v, written as the pure quaternion (v, 0).
function mul([ax, ay, az, aw], [bx, by, bz, bw]) {
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
}
const conjugate = ([x, y, z, w]) => [-x, -y, -z, w];
const rotate = (q, [x, y, z]) => mul(mul(q, [x, y, z, 0]), conjugate(q)).slice(0, 3);
function toMatrix([x, y, z, w]) {
return [
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
];
}
// Back from a rotation matrix, from its largest diagonal combination so that nothing is divided by ~0.
function fromMatrix(m) {
const trace = m[0][0] + m[1][1] + m[2][2];
if (trace > 0) {
const s = 2 * Math.sqrt(trace + 1);
return [(m[2][1] - m[1][2]) / s, (m[0][2] - m[2][0]) / s, (m[1][0] - m[0][1]) / s, s / 4];
}
if (m[0][0] > m[1][1] && m[0][0] > m[2][2]) {
const s = 2 * Math.sqrt(1 + m[0][0] - m[1][1] - m[2][2]);
return [s / 4, (m[0][1] + m[1][0]) / s, (m[0][2] + m[2][0]) / s, (m[2][1] - m[1][2]) / s];
}
if (m[1][1] > m[2][2]) {
const s = 2 * Math.sqrt(1 + m[1][1] - m[0][0] - m[2][2]);
return [(m[0][1] + m[1][0]) / s, s / 4, (m[1][2] + m[2][1]) / s, (m[0][2] - m[2][0]) / s];
}
const s = 2 * Math.sqrt(1 + m[2][2] - m[0][0] - m[1][1]);
return [(m[0][2] + m[2][0]) / s, (m[1][2] + m[2][1]) / s, s / 4, (m[1][0] - m[0][1]) / s];
}
const apply = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;
const q = fromAxisAngle([1, 1, 1], rad(120));
console.log(`120° about (1, 1, 1): q = ${show(q)}, length ${num(Math.hypot(...q))}`);
console.log(`q v q* sends x to ${show(rotate(q, [1, 0, 0]))}, y to ${show(rotate(q, [0, 1, 0]))}, z to ${show(rotate(q, [0, 0, 1]))}`);
console.log(`its matrix sends x to ${show(apply(toMatrix(q), [1, 0, 0]))}, and the matrix gives back q = ${show(fromMatrix(toMatrix(q)))}`);
console.log(`the angle back from w: 2 acos(w) = ${num(deg(2 * Math.acos(q[3])))}°`);
// The turn that takes one direction onto another: axis a × b, angle from a · b.
const a = [0, 0, 2];
const b = [2, 1, 2];
const axis = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const angle = Math.acos((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / (Math.hypot(...a) * Math.hypot(...b)));
const turn = fromAxisAngle(axis, angle);
console.log('');
console.log(`to turn the nose ${show(a)} towards ${show(b)}: axis a × b = ${show(axis)}, angle ${num(deg(angle))}°`);
console.log(` q = ${show(turn)}, and q turns the nose to ${show(rotate(turn, a))}, length 2 and along ${show(b)}`);
// 1,000 seeded rotations: q v q* against the matrix, and the matrix back to the same rotation.
let seed = 21; // mulberry32
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let agree = 0;
let back = 0;
for (let n = 0; n < 1000; n++) {
const qq = fromAxisAngle([random() * 2 - 1, random() * 2 - 1, random() * 2 - 1], (random() * 2 - 1) * Math.PI);
const v = [random() * 4 - 2, random() * 4 - 2, random() * 4 - 2];
const r1 = rotate(qq, v);
const r2 = apply(toMatrix(qq), v);
if (r1.every((x, i) => Math.abs(x - r2[i]) < 1e-12)) agree++;
const again = fromMatrix(toMatrix(qq));
const d = Math.abs(again.reduce((s, x, i) => s + x * qq[i], 0)); // 1 when again is q or -q
if (Math.abs(d - 1) < 1e-12) back++;
}
console.log('');
console.log(`1000 seeded rotations: q v q* and the matrix agree within 1e-12 for ${agree}`);
console.log(`and the matrix gives back q or -q for ${back}`); Output
120° about (1, 1, 1): q = (0.5, 0.5, 0.5, 0.5), length 1 q v q* sends x to (0, 1, 0), y to (0, 0, 1), z to (1, 0, 0) its matrix sends x to (0, 1, 0), and the matrix gives back q = (0.5, 0.5, 0.5, 0.5) the angle back from w: 2 acos(w) = 120° to turn the nose (0, 0, 2) towards (2, 1, 2): axis a × b = (-2, 4, 0), angle 48.19° q = (-0.183, 0.365, 0, 0.913), and q turns the nose to (1.333, 0.667, 1.333), length 2 and along (2, 1, 2) 1000 seeded rotations: q v q* and the matrix agree within 1e-12 for 1000 and the matrix gives back q or -q for 1000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node quat.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The quaternion is (0.5, 0.5, 0.5, 0.5), and 2 acos(w) gives back the 120°. It sends x to y, y to z and z to x, as the matrix of Rodrigues' formula: The rotation of a vector v by an angle θ about a unit axis k: R v = v cos θ + (k × v) sin θ + k (k · v)(1 − cos θ). The part of v along k stays; the part across it turns. More in “3D transformations, Euler angles and gimbal lock” did, and its matrix converts back to the same four numbers. On 1,000 seeded rotations the quaternion and its matrix turn every vector the same way within 10⁻¹², and the matrix always converts back to q or −q, a sign the third section explains. The middle lines answer a question cameras and characters ask all the time: which turn points the nose (0, 0, 2) towards (2, 1, 2)? The axis is the cross product of the two directions and the angle comes from their dot product, the two numbers this figure computes:
a = (0, 0, 2), b = (2, 1, 2): a · b = 4, a × b = (−2, 4, 0).
The cross product (−2, 4, 0) is the axis and the angle between the vectors, 48.2°, is the turn, so the quaternion is
(−0.183, 0.365, 0, 0.913), which turns the nose to (1.333, 0.667, 1.333), along (2, 1, 2). three.js’s
Quaternion.setFromUnitVectors builds the same quaternion from the two directions.
Checkpoint
Check: Unit quaternions from an axis and an angle
Multiplying quaternions turns by the right-hand one first
Quaternions multiply by Hamilton’s rules for the units: i² = j² = k² = ijk = −1. From those, i j = k but j i = −k, so the order of a product matters, as it does for rotations. The Hamilton product of a and b is
where a and b are the vector parts (x, y, z). In words: the scalar parts scale each other’s vectors, the cross product mixes them, and the dot product leaves the scalar. A vector v turns by q as q v q*, with v written as the quaternion (v, 0) and q* = (−x, −y, −z, w) the conjugate, which undoes a unit q. The program checks the rules and composes two quarter turns of the paper plane:
// Multiplying quaternions composes rotations. Hamilton's units first, then two quarter turns of the paper
// plane in both orders, checked against the matrix products, and the conjugate as the inverse.
const rad = (degrees) => (degrees * Math.PI) / 180;
const fromAxisAngle = ([x, y, z], a) => [x * Math.sin(a / 2), y * Math.sin(a / 2), z * Math.sin(a / 2), Math.cos(a / 2)];
function mul([ax, ay, az, aw], [bx, by, bz, bw]) {
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
}
const conjugate = ([x, y, z, w]) => [-x, -y, -z, w];
const rotate = (q, [x, y, z]) => mul(mul(q, [x, y, z, 0]), conjugate(q)).slice(0, 3);
const matMul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const apply = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;
// Hamilton's rules for the units i, j and k: i² = j² = k² = ijk = −1, and ij = k but ji = −k.
const [i, j, k] = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]];
const unitName = (u) => {
const at = u.findIndex((x) => x !== 0);
return `${u[at] < 0 ? '-' : ''}${['i', 'j', 'k', '1'][at]}`;
};
const rules = [['i i', mul(i, i)], ['j j', mul(j, j)], ['k k', mul(k, k)], ['i j', mul(i, j)], ['j i', mul(j, i)], ['i j k', mul(mul(i, j), k)]];
console.log(rules.map(([name, u]) => `${name} = ${unitName(u)}`).join(', '));
// q2 q1 turns by q1 first, as the matrix product R2 R1 does.
const q1 = fromAxisAngle([1, 0, 0], rad(90));
const q2 = fromAxisAngle([0, 1, 0], rad(90));
const tip = [1.5, 0, -1];
console.log('');
console.log(`q1 = 90° about x, q2 = 90° about y; the left wing tip ${show(tip)}:`);
console.log(` q2 q1 sends it to ${show(rotate(mul(q2, q1), tip))}; q1 first, then q2: ${show(rotate(q2, rotate(q1, tip)))}`);
console.log(` Ry(90°) Rx(90°) sends it to ${show(apply(matMul(Ry(rad(90)), Rx(rad(90))), tip))}`);
console.log(` q1 q2 sends it to ${show(rotate(mul(q1, q2), tip))}, the other order`);
// The conjugate of a unit quaternion undoes it, as the transpose undoes a rotation matrix.
const q = mul(q2, q1);
console.log('');
console.log(`q* q = ${show(mul(conjugate(q), q))}, the identity rotation (0, 0, 0, 1)`);
console.log(`q* turns the wing tip back: ${show(rotate(conjugate(q), rotate(q, tip)))}`); Output
i i = -1, j j = -1, k k = -1, i j = k, j i = -k, i j k = -1 q1 = 90° about x, q2 = 90° about y; the left wing tip (1.5, 0, -1): q2 q1 sends it to (0, 1, -1.5); q1 first, then q2: (0, 1, -1.5) Ry(90°) Rx(90°) sends it to (0, 1, -1.5) q1 q2 sends it to (-1, 1.5, 0), the other order q* q = (0, 0, 0, 1), the identity rotation (0, 0, 0, 1) q* turns the wing tip back: (1.5, 0, -1)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compose.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
q2 q1 sends the left wing tip (1.5, 0, −1) to (0, 1, −1.5), exactly where q1 followed by q2 sends it, and where the
matrix product Ry(90°) Rx(90°) sends it. So q2 q1 turns by q1 first, the same right-to-left reading as matrices; q1 q2
puts the tip at (−1, 1.5, 0) instead. The conjugate turns the tip back, and q* q is the identity rotation
(0, 0, 0, 1). three.js follows the same rule: a.multiply(b) sets a to a b, so b acts first.
A quaternion must stay at length 1 to be a rotation: q v q* scales v by |q|². Multiplying in a small turn every frame
lets rounding pull the length away from 1, and storage in 32-bit floats, as in a Float32Array or a GPU buffer, makes
the rounding coarse. The next program spins a model by 0.5° a frame for 30 minutes at 60 frames a second, rounding
every result to float32 with Math.fround:
// A spinning model turned by 0.5° a frame for 30 minutes at 60 frames a second, 108,000 frames, with
// its orientation kept as a float32 quaternion (as in a Float32Array or a GPU buffer). Math.fround
// rounds every result to float32. Without normalising, the length drifts and q v q* scales the model.
const f = Math.fround;
function mul32([ax, ay, az, aw], [bx, by, bz, bw]) {
return [
f(f(f(f(aw * bx) + f(ax * bw)) + f(ay * bz)) - f(az * by)),
f(f(f(f(aw * by) - f(ax * bz)) + f(ay * bw)) + f(az * bx)),
f(f(f(f(aw * bz) + f(ax * by)) - f(ay * bx)) + f(az * bw)),
f(f(f(f(aw * bw) - f(ax * bx)) - f(ay * by)) - f(az * bz)),
];
}
const length = (q) => Math.hypot(...q);
const normalise32 = (q) => q.map((v) => f(v / length(q)));
const axis = [1, 2, 3].map((v) => v / Math.hypot(1, 2, 3));
const half = (0.5 * Math.PI) / 180 / 2;
const step = [f(axis[0] * Math.sin(half)), f(axis[1] * Math.sin(half)), f(axis[2] * Math.sin(half)), f(Math.cos(half))];
console.log(`the 0.5° step in float32 has length 1 ${length(step) < 1 ? '-' : '+'} ${Math.abs(1 - length(step)).toExponential(1)}`);
let plain = [0, 0, 0, 1];
let kept = [0, 0, 0, 1];
console.log('');
console.log(' frames |q|, never normalised scale of q v q* |q|, normalised');
for (let frame = 1; frame <= 108000; frame++) {
plain = mul32(step, plain);
kept = normalise32(mul32(step, kept));
if (frame === 3600 || frame === 36000 || frame === 108000) {
const p = length(plain);
console.log(` ${String(frame).padStart(7)} ${p.toFixed(6).padStart(20)} ${(p * p).toFixed(6).padStart(15)} ${length(kept).toFixed(6).padStart(15)}`);
}
} Output
the 0.5° step in float32 has length 1 - 1.7e-8
frames |q|, never normalised scale of q v q* |q|, normalised
3600 0.999937 0.999874 1.000000
36000 0.999385 0.998771 1.000000
108000 0.998211 0.996425 1.000000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node drift.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The 0.5° step itself is already 1.7 × 10⁻⁸ short of length 1 in float32, and after 108,000 frames the quaternion’s length is 0.998211, so the model is drawn at 0.996425 of its size, 0.36 % too small and still shrinking. Dividing by the length after each multiplication keeps it at 1.000000. A matrix multiplied in the same way drifts too, but it needs re-orthonormalising, a heavier repair than one division.
Composing in the wrong order
In three.js, q.multiply(turn) turns about the object’s own axes, because the turn acts first, while
q.premultiply(turn) turns about the world’s axes. Writing turn.multiply(q) for the world turn builds the right
product but stores it in turn, which spoils a turn that is reused every frame. Pick the one the control needs, the
way the last lesson picked between intrinsic and extrinsic readings, and normalise after composing.
Checkpoint
Check: Composing and turning with quaternions
q and −q are the same rotation
The keyframes of the opening, yaw 170° and yaw −170°, have the quaternions (0, 0.996, 0, 0.087) and (0, −0.996, 0, 0.087). Negate the second, and (0, 0.996, 0, −0.087) is a turn of 190° about +y, which is the same rotation as 170° about −y. Every rotation has two unit quaternions, q and −q, and both give the same matrix, because every entry of the matrix multiplies two components. This is the double cover: The fact that every rotation has two unit quaternions, q and −q, which give the same matrix. Interpolation negates one keyframe when their dot product is negative, to take the shorter arc.. It decides interpolation: the unit quaternions form a sphere in four dimensions, and from one keyframe there is an arc to q and an arc to −q of the next. The figure cuts that sphere through both:
The double cover, and slerp against nlerp, on a slice of the 4D sphere
Text description of the diagram
Both panels show a circle: the slice of the unit sphere of quaternions through two keyframes and the centre. One degree around this circle is two degrees of turn, because a quaternion holds half the angle.
The top panel marks three points. a, the keyframe at yaw 170°, sits on the right. b, the keyframe at yaw −170°, sits 170° away round the circle, on the far left; a dashed arc joins a to b over the top, a turn of 340°. −b, the same rotation as b, sits only 10° below a; a thick blue arc joins a to −b, a turn of 20°.
The bottom panel shows the arc of a 150.48° turn, 75.24° of the circle, with the straight chord between its ends dashed. Ten steps of slerp are marked outside the arc as blue ticks, evenly spaced. Ten steps of nlerp, the chord's evenly spaced points pushed out to the circle, are marked inside the arc as black ticks: they are wider apart in the middle and closer together at the ends.
In the upper panel b is 170° from a around the circle and −b only 10°. Every degree on this circle is two degrees of turn, because the quaternion holds half the angle, so the arc to b turns the plane 340° and the arc to −b turns it 20°. The sign of the dot product a · b tells which is shorter: a negative dot product means the arc to −b is. The program checks the sign and walks both arcs:
// q and −q are the same rotation. Two keyframes of the paper plane, yaw 170° and yaw −170°, are 20° apart,
// but their quaternions have a negative dot product, and slerp that ignores the sign goes 340° the long way.
const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
const yaw = (degrees) => [0, Math.sin(rad(degrees) / 2), 0, Math.cos(rad(degrees) / 2)]; // about y
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const negate = (q) => q.map((v) => -v);
function toMatrix([x, y, z, w]) {
return [
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
];
}
const sameMatrix = (a, b) => toMatrix(a).every((row, r) => row.every((v, c) => Math.abs(v - toMatrix(b)[r][c]) < 1e-12));
function slerp(a, b, t, shortest) {
let d = dot(a, b);
if (shortest && d < 0) [b, d] = [negate(b), -d];
const theta = Math.acos(Math.max(-1, Math.min(1, d)));
const [wa, wb] = [Math.sin((1 - t) * theta) / Math.sin(theta), Math.sin(t * theta) / Math.sin(theta)];
return a.map((v, i) => wa * v + wb * b[i]);
}
// The heading of the plane's nose (0, 0, 1) after q, in degrees: 0 along +z, 90 along +x.
const heading = (q) => {
const m = toMatrix(q);
return deg(Math.atan2(m[0][2], m[2][2]));
};
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const show = (q) => `(${q.map(num).join(', ')})`;
const a = yaw(170);
const b = yaw(-170);
console.log(`yaw 170°: q = ${show(a)}`);
console.log(`yaw -170°: q = ${show(b)}, and -q = ${show(negate(b))}`);
console.log(`q and -q give the same matrix: ${sameMatrix(b, negate(b))}`);
console.log(`2 acos(w): ${num(deg(2 * Math.acos(b[3])))}° for q, ${num(deg(2 * Math.acos(-b[3])))}° for -q, about opposite axes`);
console.log(`dot product of the two keyframes: ${num(dot(a, b))}, negative`);
console.log('');
console.log(' t heading, sign ignored heading, shorter arc');
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
console.log(` ${t.toFixed(2)} ${num(heading(slerp(a, b, t, false))).padStart(14)}° ${num(heading(slerp(a, b, t, true))).padStart(20)}°`);
}
let seed = 4; // mulberry32
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let same = 0;
for (let n = 0; n < 1000; n++) {
const q = [random() - 0.5, random() - 0.5, random() - 0.5, random() - 0.5];
const len = Math.hypot(...q);
const unit = q.map((v) => v / len);
if (sameMatrix(unit, negate(unit))) same++;
}
console.log('');
console.log(`1000 seeded unit quaternions: q and -q give the same matrix for ${same}`); Output
yaw 170°: q = (0, 0.996, 0, 0.087) yaw -170°: q = (0, -0.996, 0, 0.087), and -q = (0, 0.996, 0, -0.087) q and -q give the same matrix: true 2 acos(w): 170° for q, 190° for -q, about opposite axes dot product of the two keyframes: -0.985, negative t heading, sign ignored heading, shorter arc 0.00 170° 170° 0.25 85° 175° 0.50 0° 180° 0.75 -85° -175° 1.00 -170° -170° 1000 seeded unit quaternions: q and -q give the same matrix for 1000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node double_cover.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The dot product of the keyframes is −0.985. Ignoring the sign, the heading runs 170°, 85°, 0°, −85°, −170°, the long
way through 0°; taking the shorter arc, it runs 170°, 175°, 180°, −175°, −170°, through 180°, as the keyframes
intended. All
1,000 seeded quaternions gave the same matrix as their negatives. glTF’s specification builds the same rule into its
spherical interpolation: it uses the dot product’s absolute value and its sign, which, in its words, “ensure that the
spherical interpolation follows the short path”. three.js’s slerp negates one endpoint when the dot product is
negative.
Checkpoint
Check: q and −q, and the shorter arc
Slerp turns at a steady speed; nlerp follows the same arc unevenly
slerp: Spherical linear interpolation: the point a fraction t of the way along the arc between two unit quaternions, at a constant angular speed., spherical linear interpolation, was introduced for animation by Ken Shoemake in his paper on animating rotation with quaternion curves. It walks the arc between two unit quaternions a and b, θ apart on the 4D sphere, at a constant angular speed:
In words: a weighted sum of the two keyframes, with weights that make equal steps of t equal steps of angle. glTF says a LINEAR animation of a rotation should use it. Step through one call, halfway between the two keyframes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The dot product. The keyframes are a = (0, 0.996, 0, 0.087) for yaw 170° and b = (0, −0.996, 0, 0.087) for yaw −170°. Their dot product is −0.985, which is close to −1.
- Negative, so flip. A negative dot product means b sits on the far side of the 4D sphere, 170° from a, and the arc to it would turn the plane 340°. −b is the same rotation, only 10° from a: use −b, and d becomes 0.985.
- Not nearly equal. three.js switches to a plain linear blend, normalised, when d is above 0.9995, because sin θ is then too small to divide by. Here d is 0.985, so the full formula runs.
- The angle. θ = acos 0.985 = 10°. That is the angle between the quaternions on the 4D sphere, half of the 20° the plane will turn: a quaternion holds half the angle.
- The weights. At t = 0.5 both weights are sin 5° ÷ sin 10° = 0.502. They are not 0.5, because the chord between two points of a circle is shorter than the arc; the weights make up for it.
- Halfway, the short way. 0.502 a + 0.502 (−b) = (0, 1, 0, 0), a turn of 180° about y. The plane now heads at 180°, halfway along the 20° turn; without the flip it would head at 0°, halfway round the long side.
The weights at t = 0.5 are 0.502, not 0.5, because a straight blend of two points of a circle cuts across the chord,
inside the circle, and the weights carry it back out. That straight blend, divided by its length, is
nlerp: Normalised linear interpolation: blend two unit quaternions as (1 − t) a + t b, then divide by the length. It follows slerp's arc, a little faster in the middle., normalised linear interpolation. It follows the same arc as slerp, since normalising
pushes every point of the chord straight out to the circle, but not at the same speed: the lower panel of the figure
above shows nlerp’s ten steps wider in the middle and narrower at the ends. Near equal keyframes the two agree, and
three.js’s slerp switches to nlerp when the dot product is above 0.9995, where sin θ would be too small to divide
by. The program measures how far nlerp strays from slerp as the turn grows:
// How far nlerp strays from slerp, by the size of the turn between two keyframes. For each turn, t runs
// from 0 to 0.5 in steps of 0.001 (the error at 1 − t is the same); the error is the angle between the two
// orientations at the same t.
const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
const fromAxisAngle = ([x, y, z], a) => {
const s = Math.sin(a / 2) / Math.hypot(x, y, z);
return [x * s, y * s, z * s, Math.cos(a / 2)];
};
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const between = (a, b) => deg(2 * Math.acos(Math.min(1, Math.abs(dot(a, b)))));
function slerp(a, b, t) {
let d = dot(a, b);
if (d < 0) [b, d] = [b.map((v) => -v), -d];
if (d > 0.9995) return nlerp(a, b, t); // nearly equal: sin(theta) is too small to divide by
const theta = Math.acos(d);
const [wa, wb] = [Math.sin((1 - t) * theta) / Math.sin(theta), Math.sin(t * theta) / Math.sin(theta)];
return a.map((v, i) => wa * v + wb * b[i]);
}
function nlerp(a, b, t) {
if (dot(a, b) < 0) b = b.map((v) => -v);
const q = a.map((v, i) => (1 - t) * v + t * b[i]);
return q.map((v) => v / Math.hypot(...q));
}
const start = fromAxisAngle([0.3, 1, 0.2], 0);
console.log(' turn largest error at t and 1 − t nlerp speed, middle ÷ ends');
for (const turn of [5, 10, 30, 60, 90, 120, 150, 179]) {
const end = fromAxisAngle([0.3, 1, 0.2], rad(turn));
let worst = 0;
let worstT = 0;
for (let i = 0; i <= 500; i++) {
const t = i / 1000;
const e = between(nlerp(start, end, t), slerp(start, end, t));
if (e > worst + 1e-9) [worst, worstT] = [e, t];
}
const speed = (t) => between(nlerp(start, end, t - 0.0005), nlerp(start, end, t + 0.0005)) / 0.001;
const ratio = speed(0.5) / speed(0.0005);
console.log(` ${String(turn).padStart(3)}° ${worst.toFixed(4).padStart(10)}° ${worstT.toFixed(2)} and ${(1 - worstT).toFixed(2)} ${ratio.toFixed(3).padStart(10)}`);
} Output
turn largest error at t and 1 − t nlerp speed, middle ÷ ends
5° 0.0002° 0.21 and 0.79 1.000
10° 0.0012° 0.21 and 0.79 1.002
30° 0.0331° 0.21 and 0.79 1.017
60° 0.2675° 0.21 and 0.79 1.072
90° 0.9188° 0.22 and 0.78 1.171
120° 2.2340° 0.22 and 0.78 1.333
150° 4.5140° 0.23 and 0.77 1.588
179° 8.0010° 0.24 and 0.76 1.981
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node nlerp_error.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
For a 10° turn nlerp is never more than 0.0012° from slerp, for a 30° turn 0.0331°, for 90° 0.9188°, and for a 150° turn 4.514°, always worst a little over a fifth of the way in from either end. Its speed in the middle is 1.002 times its speed at the ends for 10°, and 1.588 times for 150°. Keyframes of an animation are usually close together, which is why many engines blend them with nlerp; a camera that swings a long way, or an animation whose timing matters, needs slerp.
Checkpoint
Check: Slerp and nlerp
Interpolating Euler angles takes the long way round
The third way to blend two orientations is to blend their Euler angles, one number at a time. The program turns the paper plane from yaw, pitch and roll (0°, 10°, 0°) to (170°, 70°, −60°), in the order YXZ, three ways, in ten equal steps of t, and measures how far each step turns it:
// Three ways from one orientation of the paper plane to another, in 10 equal steps of t: slerp, nlerp
// and interpolating the Euler angles (order YXZ). For each step: how far the plane turns in that step.
// Quaternions are (x, y, z, w); the angle between two orientations a and b is 2 acos(|a · b|).
const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
const fromAxisAngle = ([x, y, z], a) => [x * Math.sin(a / 2), y * Math.sin(a / 2), z * Math.sin(a / 2), Math.cos(a / 2)];
function mul([ax, ay, az, aw], [bx, by, bz, bw]) {
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
}
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const normalise = (q) => q.map((v) => v / Math.hypot(...q));
const between = (a, b) => deg(2 * Math.acos(Math.min(1, Math.abs(dot(a, b)))));
// Euler angles in the order YXZ: Ry(yaw) Rx(pitch) Rz(roll), as a quaternion.
const euler = ([yaw, pitch, roll]) => mul(fromAxisAngle([0, 1, 0], rad(yaw)), mul(fromAxisAngle([1, 0, 0], rad(pitch)), fromAxisAngle([0, 0, 1], rad(roll))));
function slerp(a, b, t) {
let d = dot(a, b);
if (d < 0) [b, d] = [b.map((v) => -v), -d]; // the shorter of the two arcs
const theta = Math.acos(Math.min(1, d));
const [wa, wb] = [Math.sin((1 - t) * theta) / Math.sin(theta), Math.sin(t * theta) / Math.sin(theta)];
return a.map((v, i) => wa * v + wb * b[i]);
}
function nlerp(a, b, t) {
if (dot(a, b) < 0) b = b.map((v) => -v);
return normalise(a.map((v, i) => (1 - t) * v + t * b[i]));
}
const startAngles = [0, 10, 0];
const endAngles = [170, 70, -60];
const start = euler(startAngles);
const end = euler(endAngles);
const ways = {
slerp: (t) => slerp(start, end, t),
nlerp: (t) => nlerp(start, end, t),
'Euler angles': (t) => euler(startAngles.map((a, i) => a + t * (endAngles[i] - a))),
};
const num = (v, places = 2) => v.toFixed(places).padStart(7);
console.log(`from yaw, pitch, roll (${startAngles.join('°, ')}°) to (${endAngles.join('°, ')}°), in the order YXZ`);
console.log(`the shortest turn between them is ${between(start, end).toFixed(2)}°`);
console.log('');
console.log(' step slerp nlerp Euler (degrees turned in each step)');
const totals = { slerp: 0, nlerp: 0, 'Euler angles': 0 };
for (let i = 1; i <= 10; i++) {
const cells = Object.entries(ways).map(([name, at]) => {
const step = between(at((i - 1) / 10), at(i / 10));
totals[name] += step;
return num(step);
});
console.log(` ${String(i).padStart(4)} ${cells.join(' ')}`);
}
console.log('');
for (const [name, total] of Object.entries(totals)) console.log(`${name.padEnd(13)} turns ${total.toFixed(2)}° in all`);
console.log(`halfway, the Euler path is ${between(ways['Euler angles'](0.5), ways.slerp(0.5)).toFixed(2)}° away from the slerp path`);
console.log(`halfway, nlerp is ${between(ways.nlerp(0.5), ways.slerp(0.5)).toFixed(2)}° away from slerp; at t = 0.25 it is ${between(ways.nlerp(0.25), ways.slerp(0.25)).toFixed(2)}° away`); Output
from yaw, pitch, roll (0°, 10°, 0°) to (170°, 70°, -60°), in the order YXZ
the shortest turn between them is 150.48°
step slerp nlerp Euler (degrees turned in each step)
1 15.05 11.93 20.16
2 15.05 13.68 20.66
3 15.05 15.37 21.14
4 15.05 16.74 21.58
5 15.05 17.52 21.99
6 15.05 17.52 22.36
7 15.05 16.74 22.69
8 15.05 15.37 22.97
9 15.05 13.68 23.22
10 15.05 11.93 23.42
slerp turns 150.48° in all
nlerp turns 150.48° in all
Euler angles turns 220.17° in all
halfway, the Euler path is 174.98° away from the slerp path
halfway, nlerp is 0.00° away from slerp; at t = 0.25 it is 4.53° away
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node slerp_vs_euler.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The shortest turn between the two orientations is 150.48°. Slerp takes it in ten steps of 15.05°; nlerp takes the same path in steps from 11.93° to 17.52°. The Euler angles take every step larger, from 20.16° to 23.42°, and turn the plane 220.17° in all, 46 % more than needed. Halfway, the Euler path is 174.98° away from the slerp path, which is almost as far as two orientations can be from each other: it went round the other side. The chart draws the three columns of the program’s table:
Degrees turned in each of ten steps by slerp, nlerp and the Euler angles
Text description of the diagram
A line chart with the step, 1 to 10, across and the degrees turned in that step, 10° to 25°, up.
Slerp, a solid blue line with round marks, is flat at 15.05° for every step. nlerp, a dashed black line with square marks, starts at 11.93°, rises to 17.52° at steps 5 and 6 and falls back to 11.93° at step 10, symmetrically. The Euler angles, a dotted grey line with diamond marks, start at 20.16° and climb steadily to 23.42° at step 10, above both others the whole way.
Slerp and nlerp both turn 150.48° in all, the shortest turn; the Euler angles turn 220.17°.
| Step | Slerp | nlerp | Euler angles | | --- | --- | --- | --- | | 1 | 15.05° | 11.93° | 20.16° | | 2 | 15.05° | 13.68° | 20.66° | | 3 | 15.05° | 15.37° | 21.14° | | 4 | 15.05° | 16.74° | 21.58° | | 5 | 15.05° | 17.52° | 21.99° | | 6 | 15.05° | 17.52° | 22.36° | | 7 | 15.05° | 16.74° | 22.69° | | 8 | 15.05° | 15.37° | 22.97° | | 9 | 15.05° | 13.68° | 23.22° | | 10 | 15.05° | 11.93° | 23.42° |
Change the two orientations
Press Edit and set endAngles to [90, 10, 0], a pure turn of yaw: slerp and the Euler angles then take the same
ten steps of 9°, because only one angle moves, while nlerp’s steps run from 8.33° to 9.47°. Then try
[170, 85, -60], nearer gimbal lock: the shortest turn is 144.2°, and the Euler path grows to 227.92°.
The reason is that blending the three angles draws a straight line between two triples of numbers, not between the two orientations. When only one angle changes, by less than 180°, every step turns about the same fixed axis, so the path is slerp’s; when two or three change at once, the axis in general moves from step to step, and here the path went round the other side.
Key idea
Blend whole orientations, not their angles: slerp walks the shortest arc at a steady speed, nlerp walks the same arc a little unevenly, and both need the sign of the dot product checked first.
Checkpoint
Check: Interpolating Euler angles
Practise: quaternions and slerp in code
Two exercises put the quaternion algebra into code, each checked by sample tests here on the page. One makes quaternions from an axis and an angle, multiplies them and turns vectors with them; the other writes a slerp that takes the shorter arc and never divides by zero:
Code lab · 2 steps
Build a quaternion toolkit
Make quaternions from an axis and an angle, compose them in the right order and turn vectors, then interpolate them with a slerp that takes the shorter arc.
- Build quaternions, multiply them and turn vectors To do
- Slerp that takes the short way and never divides by zero To do
Exercise · Medium · JavaScript
Build quaternions, multiply them and turn vectors
A quaternion here is an array [x, y, z, w], with w the scalar part, as three.js and glTF store it. Write, in quat.mjs:
fromAxisAngle(axis, angle): the unit quaternion of a turn byangleradians aboutaxis, which may have any length except 0.multiply(a, b): the Hamilton product a b. Applied to a vector, a b turns by b first and then by a, like the matrix product of the two rotations.rotate(q, v): the vectorv = [x, y, z]turned by the unit quaternionq, computed as q v q*, with v written as the pure quaternion[x, y, z, 0]and q* the conjugate[−x, −y, −z, w].
The starter builds its quaternions from the whole angle instead of half of it and never normalises the axis. Its product has the cross-product terms the wrong way round, so it computes b a instead of a b and reverses every composition. The sample tests use the lesson's 120° turn about (1, 1, 1), Hamilton's rules for i, j and k, the two quarter turns of the paper plane in both orders, and 300 seeded turns compared with Rodrigues' formula.
Starter code · quat.mjs
/**
* Quaternions as [x, y, z, w], w the scalar part. fromAxisAngle(axis, angle) builds a unit quaternion,
* multiply(a, b) is the Hamilton product a b (b first), rotate(q, v) turns a vector by q v q*. This first version
* uses the whole angle instead of half of it, forgets to normalise the axis, and turns the cross product in
* multiply the wrong way round, which computes b a instead of a b.
*/
export function fromAxisAngle(axis, angle) {
const [x, y, z] = axis;
return [x * Math.sin(angle), y * Math.sin(angle), z * Math.sin(angle), Math.cos(angle)];
}
export function multiply([ax, ay, az, aw], [bx, by, bz, bw]) {
return [
aw * bx + ax * bw - ay * bz + az * by,
aw * by + ax * bz + ay * bw - az * bx,
aw * bz - ax * by + ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
}
export function rotate(q, [x, y, z]) {
const conjugate = [-q[0], -q[1], -q[2], q[3]];
return multiply(multiply(q, [x, y, z, 0]), conjugate).slice(0, 3);
} The sample tests · quat.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fromAxisAngle, multiply, rotate } from './quat.mjs';
const rad = (d) => (d * Math.PI) / 180;
function near(got, want, label, eps = 1e-9) {
const ok = got.length === want.length && got.every((v, i) => Math.abs(v - want[i]) < eps);
assert.ok(ok, `${label}: got (${got.map((v) => Number(v.toFixed(4)) + 0).join(', ')}), expected (${want.map((v) => Number(v.toFixed(4)) + 0).join(', ')})`);
}
test('120° about (1, 1, 1) is (0.5, 0.5, 0.5, 0.5) and cycles the axes', () => {
const q = fromAxisAngle([1, 1, 1], rad(120));
near(q, [0.5, 0.5, 0.5, 0.5], 'the quaternion');
near(rotate(q, [1, 0, 0]), [0, 1, 0], 'x turns into y');
near(rotate(q, [0, 0, 1]), [1, 0, 0], 'z turns into x');
});
test('the axis may have any length', () => {
const q = fromAxisAngle([0, 0, 3], rad(90));
near([Math.hypot(...q)], [1], 'a unit quaternion');
near(rotate(q, [1, 0, 0]), [0, 1, 0], '90° about (0, 0, 3) turns x into y');
});
test("Hamilton's rules: i j = k, j i = −k, i i = −1", () => {
const [i, j, k] = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]];
near(multiply(i, j), k, 'i j');
near(multiply(j, i), [0, 0, -1, 0], 'j i');
near(multiply(j, k), i, 'j k');
near(multiply(i, i), [0, 0, 0, -1], 'i i');
near(multiply(multiply(i, j), k), [0, 0, 0, -1], 'i j k');
});
test('multiply(q2, q1) turns by q1 first', () => {
const q1 = fromAxisAngle([1, 0, 0], rad(90));
const q2 = fromAxisAngle([0, 1, 0], rad(90));
const tip = [1.5, 0, -1];
near(rotate(multiply(q2, q1), tip), [0, 1, -1.5], 'q2 q1 on the wing tip');
near(rotate(multiply(q1, q2), tip), [-1, 1.5, 0], 'q1 q2 on the wing tip');
near(rotate(multiply(q2, q1), tip), rotate(q2, rotate(q1, tip)), 'q2 q1 equals q1 then q2');
});
test('300 seeded turns agree with Rodrigues', () => {
let seed = 8; // mulberry32
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (let n = 0; n < 300; n++) {
const axis = [random() * 4 - 2, random() * 4 - 2, random() * 4 - 2];
const angle = (random() * 2 - 1) * Math.PI;
const v = [random() * 4 - 2, random() * 4 - 2, random() * 4 - 2];
const len = Math.hypot(...axis);
const k = axis.map((a) => a / len);
const kv = k[0] * v[0] + k[1] * v[1] + k[2] * v[2];
const kxv = [k[1] * v[2] - k[2] * v[1], k[2] * v[0] - k[0] * v[2], k[0] * v[1] - k[1] * v[0]];
const want = v.map((x, i) => x * Math.cos(angle) + kxv[i] * Math.sin(angle) + k[i] * kv * (1 - Math.cos(angle)));
near(rotate(fromAxisAngle(axis, angle), v), want, `case ${n + 1}`);
}
}); A hint
A unit quaternion holds half the angle: [kx sin(θ/2), ky sin(θ/2), kz sin(θ/2), cos(θ/2)] for a unit axis k, so 120° about (1, 1, 1) is (0.5, 0.5, 0.5, 0.5). Check the product with Hamilton's units: multiply(i, j) must be k, [0, 0, 1, 0], and multiply(j, i) must be −k. The x part of a b is aw·bx + ax·bw + ay·bz − az·by.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Slerp that takes the short way and never divides by zero
Quaternions are arrays [x, y, z, w] of length 1. Write slerp(a, b, t) in slerp.mjs: the orientation a fraction t of the way from a to b, turning at a constant angular speed along the shorter of the two arcs.
- If the dot product a · b is negative, use −b instead of b: it is the same rotation, and the arc to it is the shorter one.
- With θ = acos(a · b), the result is a sin((1 − t)θ) / sin θ + b sin(tθ) / sin θ.
- When a · b is above 0.9995 the two are nearly equal and sin θ is too small to divide by: interpolate linearly and normalise the result instead, as three.js does.
The starter has the formula but neither guard. The sample tests use the lesson's keyframes at yaw 170° and −170°, which must meet at a heading of 180°, check that ten steps of a 150° turn are equal, and feed it identical, opposite and nearly equal quaternions, which must give unit quaternions and never NaN.
Starter code · slerp.mjs
/**
* slerp(a, b, t): the unit quaternion a fraction t of the way from a to b, at a constant angular speed. This first
* version has the formula but no guards: it takes the long arc when a · b is negative, and it divides by sin θ = 0
* when a and b are equal.
*/
export function slerp(a, b, t) {
const d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const theta = Math.acos(Math.max(-1, Math.min(1, d)));
const wa = Math.sin((1 - t) * theta) / Math.sin(theta);
const wb = Math.sin(t * theta) / Math.sin(theta);
return a.map((v, i) => wa * v + wb * b[i]);
} The sample tests · slerp.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { slerp } from './slerp.mjs';
const rad = (d) => (d * Math.PI) / 180;
const deg = (r) => (r * 180) / Math.PI;
const yaw = (d) => [0, Math.sin(rad(d) / 2), 0, Math.cos(rad(d) / 2)];
const about = ([x, y, z], d) => {
const s = Math.sin(rad(d) / 2) / Math.hypot(x, y, z);
return [x * s, y * s, z * s, Math.cos(rad(d) / 2)];
};
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
// Angles near 0 come out of acos to about 1e-6 degrees, so equal orientations are compared within 1e-4 degrees.
const between = (a, b) => deg(2 * Math.acos(Math.min(1, Math.abs(dot(a, b)))));
const isUnit = (q, label) => assert.ok(q.every(Number.isFinite) && Math.abs(Math.hypot(...q) - 1) < 1e-9, `${label}: not a unit quaternion (${q.join(', ')})`);
// The heading of the nose (0, 0, 1) after q, in degrees.
const heading = ([x, y, z, w]) => deg(Math.atan2(2 * (x * z + y * w), 1 - 2 * (x * x + y * y)));
test('the ends: t = 0 gives a, t = 1 gives the rotation b', () => {
const a = about([1, 2, 3], 40);
const b = about([-2, 1, 0.5], 110);
assert.ok(between(slerp(a, b, 0), a) < 1e-4, 't = 0');
assert.ok(between(slerp(a, b, 1), b) < 1e-4, 't = 1');
});
test('yaw 170° to yaw −170° goes the short way, through 180°', () => {
const mid = slerp(yaw(170), yaw(-170), 0.5);
isUnit(mid, 'the middle');
const h = heading(mid);
assert.ok(Math.abs(Math.abs(h) - 180) < 1e-6, `the heading halfway is ${h.toFixed(3)}°, not 180°: the long way round passes through 0°`);
assert.ok(between(yaw(170), slerp(yaw(170), yaw(-170), 0.25)) < 5 + 1e-6, 'a quarter of the way it has turned 5°');
});
test('ten steps of a 150° turn are 15° each', () => {
const a = about([0.3, 1, 0.2], 0);
const b = about([0.3, 1, 0.2], 150);
for (let i = 1; i <= 10; i++) {
const step = between(slerp(a, b, (i - 1) / 10), slerp(a, b, i / 10));
assert.ok(Math.abs(step - 15) < 1e-6, `step ${i} turns ${step.toFixed(4)}°, not 15°`);
}
});
test('equal, nearly equal and opposite inputs give unit quaternions, never NaN', () => {
const q = about([1, 1, 0], 70);
isUnit(slerp(q, q, 0.3), 'q to itself');
assert.ok(between(slerp(q, q, 0.3), q) < 1e-4, 'q to itself stays q');
isUnit(slerp(q, q.map((v) => -v), 0.5), 'q to −q');
assert.ok(between(slerp(q, q.map((v) => -v), 0.5), q) < 1e-4, 'q and −q are the same rotation, so nothing turns');
const near = about([1, 1, 0], 70.01);
isUnit(slerp(q, near, 0.5), 'nearly equal');
assert.ok(Math.abs(between(q, slerp(q, near, 0.5)) - 0.005) < 1e-4, 'halfway between 70° and 70.01°');
});
test('200 seeded pairs: unit results on the shorter arc', () => {
let seed = 12; // mulberry32
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const unit = () => {
const q = [random() - 0.5, random() - 0.5, random() - 0.5, random() - 0.5];
const n = Math.hypot(...q);
return q.map((v) => v / n);
};
for (let n = 0; n < 200; n++) {
const a = unit();
const b = unit();
const t = random();
const q = slerp(a, b, t);
isUnit(q, `pair ${n + 1}`);
const total = between(a, b);
assert.ok(Math.abs(between(a, q) + between(q, b) - total) < 1e-4, `pair ${n + 1}: the result is off the shorter arc`);
assert.ok(Math.abs(between(a, q) - t * total) < 1e-4, `pair ${n + 1}: not a fraction t of the way`);
}
}); A hint
Compute d = a · b first. If d < 0, negate every component of b and use −d. If d > 0.9995, return normalise((1 − t) a + t b); otherwise return the slerp formula with θ = Math.acos(d). Identical quaternions have d = 1, and Math.sin(0) is 0, which is why the plain formula returns NaN for them.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check covers all five ideas, from the half-angle to the Euler path. It picks the idea your answers show is weakest, raises the level as you get questions right, and brings a missed question back before it ends:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2-5 years): Why do engines store orientations as quaternions rather than Euler angles or matrices?
A unit quaternion stores a rotation in four numbers: the axis times sin(θ/2), and cos(θ/2). Unlike Euler angles, it has no axis order to agree on and no gimbal lock: every orientation is an ordinary point on the unit sphere in four dimensions, so slerp can turn between two orientations at a steady speed along the shortest arc, where blended angles took a 220° path for a 150° turn. Unlike a 3 × 3 matrix, it is smaller, four numbers instead of nine, it composes with 16 multiplications instead of 27, and it is easy to keep valid: a drifting quaternion is repaired by dividing by its length, a drifting matrix only by re-orthonormalising it. The costs are the double cover, q and −q being the same rotation, so interpolation has to pick the shorter arc, and a conversion to a matrix for the GPU.
Two questions of this module take quaternions into animation systems: keyframes that turn the long way, and whether nlerp is good enough for thousands of bones. Each row leads to where a good answer begins and to the follow-ups it should be ready for:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Some keyframe pairs of a character's head animation spin the head almost a full turn the long way, although the poses are close: one pair is yaw 170° and yaw −170°. Other pairs play correctly. Explain, reproduce it with numbers, and decide whether to fix the exporter or the runtime.
-
Mid to staff
An animation runtime blends 2,000 bone rotations per character on phones, and a profile shows slerp's trigonometry in the hot path. A colleague proposes nlerp everywhere. When is that safe, how large is the error, and how would you decide with measurements?
Key takeaways
- A turn by θ about the unit axis k is the unit quaternion (k sin(θ/2), cos(θ/2)), stored as (x, y, z, w) by three.js and glTF: 120° about (1, 1, 1) is (0.5, 0.5, 0.5, 0.5).
- The Hamilton product composes rotations right to left, like matrices, so q2 q1 turns by q1 first; q v q* turns a vector, and normalising after each product keeps float32 rounding from shrinking the model.
- q and −q are the same rotation, so before interpolating, negate one keyframe when the dot product is negative: yaw 170° to −170° then turns 20°, not 340°.
- Slerp walks the arc between two quaternions at a constant speed; nlerp walks the same arc unevenly, within 0.0012° of slerp for a 10° turn and 4.514° for a 150° one.
- Interpolating Euler angles leaves the shortest path: a 150.48° turn became a 220.17° one that went round the other side.
Terms in this lesson
- double cover
- The fact that every rotation has two unit quaternions, q and −q, which give the same matrix. Interpolation negates one keyframe when their dot product is negative, to take the shorter arc.
- nlerp
- Normalised linear interpolation: blend two unit quaternions as (1 − t) a + t b, then divide by the length. It follows slerp's arc, a little faster in the middle.
- quaternion
- A number w + x i + y j + z k with one real part and three imaginary units that obey i² = j² = k² = ijk = −1. Graphics code stores it as (x, y, z, w).
- slerp
- Spherical linear interpolation: the point a fraction t of the way along the arc between two unit quaternions, at a constant angular speed.
- unit quaternion
- A quaternion of length 1. The turn by θ about a unit axis k is (k sin(θ/2), cos(θ/2)), and it turns a vector v as q v q*.
References
- On Quaternions; or on a new System of Imaginaries in Algebra (Philosophical Magazine, transcription) (Trinity College Dublin, School of Mathematics)
- Animating rotation with quaternion curves (ACM SIGGRAPH Computer Graphics, vol. 19, no. 3) (Association for Computing Machinery)
- glTF 2.0 Specification: 3.5.3 Transformations, 5.8.2 animation.sampler.interpolation and C.4 Spherical Linear Interpolation (Khronos Group)
- three.js documentation: Quaternion (three.js authors)
- three.js r186 source: Quaternion.slerp and Quaternion.multiplyQuaternions (three.js authors)
- IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019) (IEEE Standards Association)
- ECMAScript Language Specification: Math.fround (Ecma International (TC39))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress