Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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*.

q=(kxsin⁡θ2, kysin⁡θ2, kzsin⁡θ2, cos⁡θ2)q = \left(k_x \sin\tfrac{\theta}{2},\ k_y \sin\tfrac{\theta}{2},\ k_z \sin\tfrac{\theta}{2},\ \cos\tfrac{\theta}{2}\right)

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:

Quaternions from an axis, from two directions and from a matrix JavaScript · basics/quat.mjs
// 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

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:

Interactive · in 3D The nose (0, 0, 2) and the target direction (2, 1, 2) Set the coordinates of a and b. The dot product measures how much they point the same way; the cross product is perpendicular to both, as long as the area of the parallelogram they span.
Vectors a = (0, 0, 2) and b = (2, 1, 2) on x, y and z axes, with a × b.xyzaba × b (half length)

a = (0, 0, 2), b = (2, 1, 2): a · b = 4, a × b = (−2, 4, 0).

a · b 4 · aₓbₓ + a_yb_y + a_zb_z
Angle between a and b 48.2° · cos θ = a · b ÷ (|a| |b|)
a × b (−2, 4, 0) · (a_yb_z − a_zb_y, a_zb_x − a_xb_z, a_xb_y − a_yb_x)
|a × b| (parallelogram area) 4.472
Projection of a on b 1.333 · a · b ÷ |b|

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

  1. Question 1 of 2 Which unit quaternion (x, y, z, w) turns by 90° about the z axis?

    Choose one answer.

    A hint

    The vector part is the axis times the sine of half the angle.

    Show the answer: Question 1 of 2

    Answer: (0, 0, 0.707, 0.707)

    q = (k sin(θ/2), cos(θ/2)) with k = (0, 0, 1) and θ = 90° gives (0, 0, sin 45°, cos 45°) = (0, 0, 0.707, 0.707).

  2. Question 2 of 2 A unit quaternion has w = 0.5. By how many degrees does it turn?

    Type a number.

    A hint

    w is the cosine of half the angle.

    Show the answer: Question 2 of 2

    Answer: 120 degrees (anything from 119.99 to 120.01 counts)

    w = cos(θ/2) = 0.5 gives θ/2 = 60°, so θ = 2 acos(0.5) = 120°, the turn about (1, 1, 1) of the program.

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

ab=(aw𝐛+bw𝐚+𝐚×𝐛,  awbw−𝐚⋅𝐛)a\,b = \left(a_w \mathbf{b} + b_w \mathbf{a} + \mathbf{a} \times \mathbf{b},\ \ a_w b_w - \mathbf{a} \cdot \mathbf{b}\right)

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:

Hamilton's rules and two quarter turns in both orders JavaScript · compose/compose.mjs
// 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

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:

Thirty minutes of float32 turns, with and without normalising JavaScript · drift/drift.mjs
// 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

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

  1. Question 1 of 2 q1 is 90° about x and q2 is 90° about y. Which product turns the wing tip by q1 first and then by q2?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: q2 q1

    q2 q1 v (q2 q1)* = q2 (q1 v q1*) q2*, so q1 turns v first. It matches the matrix product Ry(90°) Rx(90°).

  2. Question 2 of 2 After 30 minutes of float32 turns, a quaternion has length 0.998211. By what factor does q v q* scale the model?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0.996425, the square of the length

    Normalising after each multiplication keeps the length at 1.000000, and the model at its size.

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:

Two circles through unit quaternions: the long and short arcs from a to b and −b, and slerp steps against nlerp steps.Arcs between unit quaternionsA slice through a, b and the centre of the 4D sphereq and −q: two arcs to one rotationab−ba: yaw 170°b: yaw −170°dashed: a to b,170° on the circle,a 340° turnblue: a to −b,10° on the circle,a 20° turnTen steps along a 150.48° turnthe endsstartendslerp, outside:equal stepsnlerp, inside:wider in themiddle,narrower atThe dashed chord is what a plain blend follows.

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:

Two quaternions for one rotation, and the two arcs JavaScript · cover/double_cover.mjs
// 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

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

  1. Question 1 of 2 Two keyframes' quaternions have the dot product −0.985. What should an interpolation do before blending them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Negate one of them, so the dot product becomes 0.985 and the blend takes the shorter arc

    A negative dot product means the arc to −b is shorter. glTF's spherical interpolation and three.js's slerp both flip the sign in this case.

  2. Question 2 of 2 Why do q and −q give the same rotation matrix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Every entry of the matrix is a product of two components of q, so the two minus signs cancel

    −q describes a turn by 360° − θ about −k, which ends in the same place as θ about k: every rotation has exactly two unit quaternions.

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:

slerp⁡(a,b,t)=asin⁡((1−t)θ)sin⁡θ+bsin⁡(tθ)sin⁡θ,cos⁡θ=a⋅b\operatorname{slerp}(a, b, t) = a\,\frac{\sin\bigl((1 - t)\,\theta\bigr)}{\sin\theta} + b\,\frac{\sin(t\,\theta)}{\sin\theta}, \qquad \cos\theta = a \cdot b

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:

Interactive · step by step Slerp halfway from yaw 170° to yaw −170° Step through one call of slerp at t = 0.5. Each box is one line of the method; the right column is the branch for nearly equal keyframes.
Slerp halfway from yaw 170° to yaw −170°, step 6 of 6. Showing d = a · b (d = −0.985), d < 0? use −b (d = 0.985 with −b), θ = acos d (θ = 10°), d > 0.9995? (no: 0.985 is below it), Two weights (both 0.502 at t = 0.5), Weighted sum ((0, 1, 0, 0): yaw 180°). In focus: Weighted sum.d = a · bd = −0.985d < 0? use −bd = 0.985 with −bθ = acos dθ = 10°d > 0.9995?no: 0.985 is below itTwo weightsboth 0.502 at t = 0.5Weighted sum(0, 1, 0, 0): yaw 180°sum

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 JavaScript · nlerp/nlerp_error.mjs
// 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

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

  1. Question 1 of 2 Halfway between keyframes 10° apart on the 4D sphere, slerp's two weights are 0.502 each, not 0.5. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Half of each keyframe, added, lands on the chord inside the circle; the larger weights carry the point back out to the sphere

    The chord between two points of a circle runs inside it, so its midpoint is shorter than the radius; slerp's weights add up to more than 1 exactly enough to land on the circle.

  2. Question 2 of 2 Two keyframes are 90° apart. What is the largest angle, in degrees, between nlerp and slerp at the same t?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0.9188 degrees (anything from 0.9178 to 0.9198 counts)

    The program's table gives 0.9188° for a 90° turn, worst a little over a fifth of the way in from either end.

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:

Slerp, nlerp and Euler angles from one orientation to another JavaScript · interp/slerp_vs_euler.mjs
// 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

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:

A line chart of degrees turned per step: slerp flat at 15.05°, nlerp rising to 17.52° and back, Euler angles from 20.16° to 23.42°.Degrees turned in each of ten stepsFrom the program above: a 150.48° turn10°15°20°25°12345678910stepslerp: 15.05° every stepnlerp: 11.93° to 17.52°Euler angles: 20.16° to 23.42°

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

  1. Question 1 of 2 The shortest turn between the two orientations is 150.48°. How many degrees in all did interpolating the Euler angles turn the plane?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 220.17 degrees (anything from 220.16 to 220.18 counts)

    The Euler steps grew from 20.16° to 23.42° and added up to 220.17°, 46 % more than the shortest turn.

  2. Question 2 of 2 For which pair of orientations does interpolating Euler angles (YXZ) follow exactly the slerp path?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yaw, pitch, roll (0°, 10°, 0°) to (90°, 10°, 0°), where only the yaw changes

    With only one angle moving the Euler blend is a steady turn about one axis, so it matches slerp, ten steps of 9°. Once two or three angles move together, the path bends.

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.

  1. Build quaternions, multiply them and turn vectors To do
  2. 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 by angle radians about axis, 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 vector v = [x, y, z] turned by the unit quaternion q, 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.

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.

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.

Matrix Calculator Enter the matrix of (0.5, 0.5, 0.5, 0.5), rows (0, 0, 1), (1, 0, 0), (0, 1, 0), and check that its cube is the identity: three 120° turns make a full turn.

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.

  1. Question 1 of 13 Which quaternion (x, y, z, w) is the identity rotation, the one that turns nothing?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: (0, 0, 0, 1)

    q* q of the program printed (0, 0, 0, 1), the identity.

  2. Question 2 of 13 In which order do three.js and glTF store the four numbers of a quaternion?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: (x, y, z, w), with the scalar part last

    Mixing up (x, y, z, w) and (w, x, y, z) is a classic import bug: the identity (0, 0, 0, 1) read as w first is a 180° turn about z.

  3. Question 3 of 13 How do you build the turn that points the direction a onto the direction b?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Take the axis a × b and the angle acos(a · b ÷ (|a| |b|)), then form (k sin(θ/2), cos(θ/2))

    three.js's setFromUnitVectors builds the same quaternion. When a and b point exactly opposite ways the cross product is zero and any perpendicular axis will do.

  4. Question 4 of 13 By Hamilton's rules, what is j i?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: −k

    The program printed i j = k and j i = −k, the reason the order of rotations matters for quaternions too.

  5. Question 5 of 13 How do you undo the turn of a unit quaternion q = (x, y, z, w)?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: With its conjugate (−x, −y, −z, w)

    For a unit quaternion the inverse is the conjugate. (x, y, z, −w), which is −q*, undoes it too: the same inverse rotation with the other sign.

  6. Question 6 of 13 A spaceship's orientation is q. The player presses "roll right", a small turn r about the ship's own nose axis. How do you update q?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: q = q r, so that r acts first, in the ship's own frame

    Then normalise q, so float32 drift never scales the ship; the program's model shrank to 0.996425 in 30 minutes without it.

  7. Question 7 of 13 q turns 170° about −y. Which turn does −q describe?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: 190° about +y, the same rotation

    The program printed 170° for q and 190° for −q, about opposite axes.

  8. Question 8 of 13 Interpolating from yaw 170° to yaw −170° the short way, what is the heading halfway, in degrees?

    Type a number.

    Show the answer: Question 8 of 13

    Answer: 180 degrees (anything from 179.99 to 180.01 counts)

    The shorter arc runs 170°, 175°, 180°, −175°, −170°; ignoring the sign, the long arc runs through 0°.

  9. Question 9 of 13 nlerp and slerp blend the same two keyframes. What differs between them?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Their speed along the arc; they follow the same path

    nlerp's steps for the 150.48° turn ran from 11.93° to 17.52°, while slerp's were all 15.05°.

  10. Question 10 of 13 An animation blends keyframes that are never more than 30° apart, for thousands of bones a frame. Is nlerp good enough?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Yes for most uses; its largest error at 30° is 0.0331°, far below anything visible

    The program's table: 0.0012° at 10°, 0.0331° at 30°, 0.9188° at 90°. Long camera swings and timing-critical motion still want slerp.

  11. Question 11 of 13 Why does three.js's slerp switch to a normalised linear blend when the dot product is above 0.9995?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: sin θ is then close to 0, and dividing by it would magnify rounding errors or give NaN

    The exercise's starter has no such branch and returns NaN for identical quaternions.

  12. Question 12 of 13 Halfway through the 150.48° turn, how far is the Euler-angle path from the slerp path?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: 174.98°, almost as far apart as two orientations can be

    The program printed 174.98°; the most two orientations can differ is 180°.

  13. Question 13 of 13 Why does blending yaw, pitch and roll one by one take a longer path than slerp?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Each angle turns about an axis that the other angles keep moving, so the blended turns do not add up to one steady turn

    Only when a single angle moves is the blend one steady turn. Converting both keyframes to quaternions and using slerp gives the 150.48° path.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.