Quaternions
What You’ll Learn
Section titled “What You’ll Learn”That a rotation is really an axis and an angle, that storing it that way removes gimbal lock entirely, and why the stored angle is half the one you asked for. Then multiplication as composition, the conjugate as a free inverse, and rotating a vector by sandwiching it. And finally the one genuine cost: every orientation has two quaternions, and blending towards the wrong one of the pair sends your object 320 degrees to travel 40.
Start With the Axis, Not the Numbers
Section titled “Start With the Axis, Not the Numbers”Section 3.1 built orientations out of three separate turns and found that the third one collapses into the first. The fix is to stop stacking turns.
Any rotation in 3D is a single turn about a single axis. That is not a simplification, it is a theorem - Euler’s rotation theorem - and it means one axis plus one angle is enough. No sequence, no order convention, no nesting, so nothing to lock.
Four numbers hold it:
The axis is a unit vector, and it lives in scaled by something. The angle lives in . Notice what that something is, because it is the one surprise in the whole construction: the angle is halved.
Drag the sliders. The purple line is the axis, the grey circle is every place the marked corner could go, and the orange dot is where it is now.
src/lib/gamedev/demos/quatspin.scene.ts /**
* One quaternion, its axis drawn, and the circle a corner travels as the angle opens up.
*/
import * as THREE from "three";
import { applyMat4, point, type Vec3 } from "../matrices.ts";
import { fromAxisAngle, quatToMat4, rotateVector } from "../quaternions.ts";
import { makeCanvas, addSlider, addReadout, addBoxWire } from "./ui.ts";
import type { MountFn } from "./runner.ts";
/** The corner we follow, so the reader has one thing to watch rather than eight. */
const MARK: Vec3 = { x: 0.45, y: 0.45, z: 0.45 };
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 310);
const scene = new THREE.Scene();
scene.background = background;
scene.add(new THREE.GridHelper(8, 8, 0x30363d, 0x21262d));
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(2.9, 2.3, 3.9);
camera.lookAt(0, 0, 0);
const box = addBoxWire(scene, 0x39d3c3);
// The axis, the full circle the marked corner would travel, and where it is right now.
const axisLine = new THREE.LineSegments(
new THREE.BufferGeometry(),
new THREE.LineDashedMaterial({
color: 0xd2a8ff,
dashSize: 0.1,
gapSize: 0.08,
}),
);
scene.add(axisLine);
const orbit = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0x545d68 }),
);
scene.add(orbit);
const dot = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 12, 8),
new THREE.MeshBasicMaterial({ color: 0xf0883e }),
);
scene.add(dot);
const show = addReadout(el);
const tilt = addSlider(el, "axis tilt", -90, 90, 55, draw);
const bearing = addSlider(el, "axis bearing", -180, 180, 25, draw);
const angle = addSlider(el, "angle to turn", -180, 180, 90, draw);
function draw() {
const t = (tilt() * Math.PI) / 180;
const b = (bearing() * Math.PI) / 180;
const h = Math.cos(t);
const axis: Vec3 = {
x: h * Math.sin(b),
y: Math.sin(t),
z: h * Math.cos(b),
};
const q = fromAxisAngle(axis, angle());
if (q === null) return;
const m = quatToMat4(q);
box((c) => {
const p = applyMat4(m, point(c[0] * 0.9, c[1] * 0.9, c[2] * 0.9));
return [p.x, p.y, p.z];
});
axisLine.geometry.setFromPoints([
new THREE.Vector3(-axis.x * 1.9, -axis.y * 1.9, -axis.z * 1.9),
new THREE.Vector3(axis.x * 1.9, axis.y * 1.9, axis.z * 1.9),
]);
axisLine.computeLineDistances();
// Every place that corner could go: a circle centred on the axis, always.
const ring: THREE.Vector3[] = [];
for (let d = 0; d <= 120; d += 1) {
const spun = rotateVector(fromAxisAngle(axis, (d / 120) * 360)!, MARK);
ring.push(new THREE.Vector3(spun.x, spun.y, spun.z));
}
orbit.geometry.setFromPoints(ring);
const here = rotateVector(q, MARK);
dot.position.set(here.x, here.y, here.z);
const half = Math.abs(angle()) / 2;
show(
`q = (${q.x.toFixed(2)}, ${q.y.toFixed(2)}, ${q.z.toFixed(2)}, ${q.w.toFixed(2)})` +
` \u00B7 w is cos of half the angle: cos(${half.toFixed(0)}\u00B0) = ${q.w.toFixed(2)}`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Tilt the axis anywhere you like. There is nothing special about , or - the axis is just a direction, and the box turns about it. That freedom is what three separate angles never had.
Watch as you drag the angle. At 90 degrees it reads , which is . At 180 it reads . The readout says so live.
Why half
Section titled “Why half”The halving looks arbitrary and is not. It is what makes multiplication work.
Rotating a vector uses the quaternion twice - once on each side:
Two applications, each carrying half the angle, add up to the whole angle. If stored the full angle the sandwich would rotate twice as far as you asked.
That is the trade. You accept a factor of two in the storage, and in exchange composition becomes plain multiplication, the inverse becomes three sign flips, and there are no poles. It is a very good trade, and it is also why a quaternion is not a thing to read with your eyes - does not look like “a quarter turn” to anybody.
Everything Else Is Ordinary
Section titled “Everything Else Is Ordinary”With the half-angle in place, the rest of the operations are unremarkable, which is the point.
| To do this | You do this | Cost |
|---|---|---|
| Combine two rotations | 16 multiplies | |
| Undo a rotation | , the conjugate | negate three numbers |
| Rotate a vector | two quaternion products | |
| Check nothing has drifted | is the length still 1 | one square root |
Two of those deserve a note.
Multiplication composes, and does not commute. means apply then -
the same right-to-left reading as multiplyMat4 in Part 2, deliberately, so the two are
interchangeable. And , which it had better not be, since rotations do not
commute and Section 2.3 spent a whole page on that.
The conjugate is the inverse. Negate and you have the same axis with the opposite angle. No determinant, no division, no special case - compare that with inverting a matrix in Section 2.4.
The Double Cover
Section titled “The Double Cover”Here is the bill.
Negate all four numbers and you get a different quaternion that describes the identical orientation. Not approximately - the matrices come out bit-for-bit equal, because every entry is a product of two components and both signs cancel.
Which makes sense from the half-angle: turning about and turning about land in the same place, and halving turns that into a sign.
Nothing you can measure about the object distinguishes them. But interpolation is arithmetic on the four numbers, and those are not the same at all. Blend towards the far twin and the object takes the scenic route.
Below, two objects. Both start at the same heading and end at the same heading, only 40 degrees apart. Orange blends towards the quaternion as written. Teal flips its sign first.
src/lib/gamedev/demos/doublecover.scene.ts /**
* The same two orientations blended twice: once naively, once after the double-cover flip.
*/
import * as THREE from "three";
import {
applyMat4,
multiplyMat4,
point,
translation4,
type Mat4,
} from "../matrices.ts";
import {
angleBetweenQuats,
dotQuat,
fromAxisAngle,
nlerpQuat,
quatToMat4,
rotateVector,
shortWayFrom,
type Quat,
} from "../quaternions.ts";
import { makeCanvas, addSlider, addReadout, addBoxWire } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const Y = { x: 0, y: 1, z: 0 };
/** Two headings only 40 degrees apart - but written so their quaternions point away. */
const FROM: Quat = fromAxisAngle(Y, 20)!;
const TO: Quat = fromAxisAngle(Y, 340)!;
const NOSE = { x: 0, y: 0, z: -1 };
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 300);
const scene = new THREE.Scene();
scene.background = background;
scene.add(new THREE.GridHelper(10, 10, 0x30363d, 0x21262d));
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(0.2, 4.4, 5.2);
camera.lookAt(0, 0, 0);
/** One object: a box, a nose arrow, and the trail its nose leaves over the whole blend. */
function rig(centre: number, color: number) {
const box = addBoxWire(scene, color);
const nose = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color }),
);
const trail = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0x545d68 }),
);
scene.add(nose, trail);
return (target: Quat, t: number) => {
const q = nlerpQuat(FROM, target, t)!;
const m: Mat4 = multiplyMat4(translation4(centre, 0, 0), quatToMat4(q));
box((c) => {
const p = applyMat4(m, point(c[0] * 0.6, c[1] * 0.6, c[2] * 0.6));
return [p.x, p.y, p.z];
});
const f = rotateVector(q, NOSE);
nose.geometry.setFromPoints([
new THREE.Vector3(centre, 0, 0),
new THREE.Vector3(centre + f.x * 1.25, f.y * 1.25, f.z * 1.25),
]);
// The whole route, not just where it is now. This is what makes the long way obvious.
const path: THREE.Vector3[] = [];
for (let i = 0; i <= 120; i += 1) {
const step = rotateVector(nlerpQuat(FROM, target, i / 120)!, NOSE);
path.push(
new THREE.Vector3(
centre + step.x * 1.25,
step.y * 1.25,
step.z * 1.25,
),
);
}
trail.geometry.setFromPoints(path);
return angleBetweenQuats(FROM, q);
};
}
const naive = rig(-1.6, 0xf0883e);
const fixed = rig(1.6, 0x39d3c3);
const show = addReadout(el);
const t = addSlider(
el,
"blend from one to the other",
0,
1,
0.35,
draw,
"",
0.01,
);
function draw() {
const sweptNaive = naive(TO, t());
const sweptFixed = fixed(shortWayFrom(FROM, TO), t());
show(
`dot is ${dotQuat(FROM, TO).toFixed(2)}, so the flip is needed \u00B7 ` +
`orange has turned ${sweptNaive.toFixed(0)}\u00B0, teal ${sweptFixed.toFixed(0)}\u00B0`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Drag the blend slider and watch the trails. Teal takes the 40 degrees. Orange goes 320 degrees the other way round to reach the same place.
The signal is a single number. The dot product of the two quaternions is , and
So flip it. That is the entire fix:
if (dot(from, to) < 0) to = negate(to);source The code both scenes run on
/**
* Four numbers that hold an orientation, with no poles and nothing to lock.
*
* The whole construction hangs off one decision: store **half** the angle. That is what makes
* multiplication compose rotations, makes the conjugate an inverse, and makes the four numbers
* cover every orientation exactly twice - the double cover, which is the one place quaternions
* ask something of you in return.
*/
import { direction, point, type Mat4, type Vec3 } from "./matrices.ts";
/** `x, y, z` are the axis scaled by `sin(angle/2)`. `w` is `cos(angle/2)`. */
export type Quat = { x: number; y: number; z: number; w: number };
/** No rotation: a zero-length axis part and a full-size `w`. */
export const IDENTITY_QUAT: Quat = { x: 0, y: 0, z: 0, w: 1 };
const DEG = Math.PI / 180;
export function quatLength(q: Quat): number {
return Math.hypot(q.x, q.y, q.z, q.w);
}
/** Scale back to unit length. Returns `null` for the one quaternion that has no direction. */
export function normalizeQuat(q: Quat): Quat | null {
const len = quatLength(q);
if (len < 1e-12) return null;
return { x: q.x / len, y: q.y / len, z: q.z / len, w: q.w / len };
}
export function negateQuat(q: Quat): Quat {
return { x: -q.x, y: -q.y, z: -q.z, w: -q.w };
}
export function dotQuat(a: Quat, b: Quat): number {
return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
}
/**
* Build a rotation from an axis and an angle.
*
* Note the halves. Turning by 90 degrees stores `cos(45)` and `sin(45)`, and if you ever see
* a quaternion whose `w` is `cos` of the angle you meant rather than half of it, this is the
* line to look at.
*/
export function fromAxisAngle(axis: Vec3, degrees: number): Quat | null {
const len = Math.hypot(axis.x, axis.y, axis.z);
if (len < 1e-12) return null;
const half = degrees * DEG * 0.5;
const s = Math.sin(half) / len;
return {
x: axis.x * s,
y: axis.y * s,
z: axis.z * s,
w: Math.cos(half),
};
}
/** Read the axis and angle back out. The angle comes back doubled, for the same reason. */
export function toAxisAngle(q: Quat): { axis: Vec3; degrees: number } {
const n = normalizeQuat(q) ?? IDENTITY_QUAT;
const w = Math.min(1, Math.max(-1, n.w));
const sinHalf = Math.sqrt(Math.max(0, 1 - w * w));
// Below this the rotation is too small to have a meaningful axis, so name one.
if (sinHalf < 1e-9) return { axis: { x: 0, y: 1, z: 0 }, degrees: 0 };
return {
axis: { x: n.x / sinHalf, y: n.y / sinHalf, z: n.z / sinHalf },
degrees: (2 * Math.acos(w)) / DEG,
};
}
/**
* Compose two rotations: apply `second` after `first`.
*
* Same right-to-left reading as `multiplyMat4`, and for the same reason - so the two can be
* swapped for each other without any code changing its meaning. Quaternion multiplication does
* not commute either, which it had better not, since rotations do not.
*/
export function multiplyQuat(second: Quat, first: Quat): Quat {
const a = second;
const b = first;
return {
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
};
}
/**
* Flip the axis part. For a unit quaternion this **is** the inverse - same axis, opposite
* angle - which is a division replaced by three sign changes.
*/
export function conjugate(q: Quat): Quat {
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
}
/**
* Rotate a vector, by sandwiching it between the quaternion and its conjugate.
*
* Written out longhand because the sandwich is the thing worth seeing. Production code uses an
* algebraically identical shortcut with fewer multiplies.
*
* If `q` is **not** unit length the sandwich also scales the vector, by exactly `|q|` squared.
* That is the mechanism behind the classic "I forgot to normalize and my model shrank".
*/
export function rotateVector(q: Quat, v: Vec3): Vec3 {
const asQuat: Quat = { x: v.x, y: v.y, z: v.z, w: 0 };
const out = multiplyQuat(multiplyQuat(q, asQuat), conjugate(q));
return { x: out.x, y: out.y, z: out.z };
}
/** The same rotation as a 4x4, so it drops straight into Part 2's machinery. */
export function quatToMat4(q: Quat): Mat4 {
const n = normalizeQuat(q) ?? IDENTITY_QUAT;
const { x, y, z, w } = n;
return {
i: direction(
1 - 2 * (y * y + z * z),
2 * (x * y + z * w),
2 * (x * z - y * w),
),
j: direction(
2 * (x * y - z * w),
1 - 2 * (x * x + z * z),
2 * (y * z + x * w),
),
k: direction(
2 * (x * z + y * w),
2 * (y * z - x * w),
1 - 2 * (x * x + y * y),
),
t: point(0, 0, 0),
};
}
/** How far apart two orientations are, in degrees. The absolute value handles double cover. */
export function angleBetweenQuats(a: Quat, b: Quat): number {
const na = normalizeQuat(a);
const nb = normalizeQuat(b);
if (na === null || nb === null) return 0;
const d = Math.min(1, Math.abs(dotQuat(na, nb)));
return (2 * Math.acos(d)) / DEG;
}
/**
* The double cover fix, and the single most load-bearing `if` in rotation code.
*
* Every orientation has **two** quaternions: `q` and `-q`. They produce the identical matrix,
* so nothing you can observe about the object tells them apart. But interpolation is arithmetic
* on the four numbers, and those differ - so blending towards the wrong one of the pair sends
* the object the long way round, up to 360 degrees when 0 would have done.
*
* A negative dot product is exactly the signal that you have the far one. Flip it.
*/
export function shortWayFrom(from: Quat, to: Quat): Quat {
return dotQuat(from, to) < 0 ? negateQuat(to) : to;
}
/**
* Blend two orientations the cheap way: interpolate all four numbers, then renormalize.
*
* Deliberately does **not** call `shortWayFrom` itself, so a demo can show what happens
* without it. Section 3.3 compares this against slerp; the short-way fix is needed either way
* and is not what distinguishes them.
*/
export function nlerpQuat(from: Quat, to: Quat, t: number): Quat | null {
return normalizeQuat({
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
z: from.z + (to.z - from.z) * t,
w: from.w + (to.w - from.w) * t,
});
}
/**
* Straight linear interpolation of the four numbers, with **no** renormalizing.
*
* Included because it is what people write first, and because the result is not a unit
* quaternion - so anything using it either renormalizes later or quietly scales the object.
*/
export function lerpQuat(from: Quat, to: Quat, t: number): Quat {
return {
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
z: from.z + (to.z - from.z) * t,
w: from.w + (to.w - from.w) * t,
};
}
/**
* Spherical interpolation: travel the arc between two orientations at a **constant rate**.
*
* `nlerpQuat` already follows the right arc - normalizing a straight line between two points on
* a sphere lands you on the great circle through them. What it gets wrong is the *speed*, and
* that is the only thing slerp fixes.
*
* Like `nlerpQuat`, this does not apply the double-cover flip. Call `shortWayFrom` first, or
* you will slerp smoothly along the 320-degree route from Section 3.2.
*/
export function slerpQuat(from: Quat, to: Quat, t: number): Quat | null {
const a = normalizeQuat(from);
const b = normalizeQuat(to);
if (a === null || b === null) return null;
const cos = Math.min(1, Math.max(-1, dotQuat(a, b)));
/* When the two are nearly the same orientation, `sin(omega)` heads for zero and the weights
below turn into 0/0. A straight blend is indistinguishable from the arc at that scale, so
hand off rather than divide. This guard is not optional. */
if (Math.abs(cos) > 0.9995) return nlerpQuat(a, b, t);
const omega = Math.acos(cos);
const sin = Math.sin(omega);
const wa = Math.sin((1 - t) * omega) / sin;
const wb = Math.sin(t * omega) / sin;
return {
x: a.x * wa + b.x * wb,
y: a.y * wa + b.y * wb,
z: a.z * wa + b.z * wb,
w: a.w * wa + b.w * wb,
};
} No Poles
Section titled “No Poles”The payoff against Section 3.1, stated as a property rather than a claim.
Blend from looking straight up to looking straight down - through the exact orientations where Euler angles are undefined and where the separation formula reads zero. Every intermediate step stays a unit quaternion, stays finite, and moves monotonically further from the start, ending at exactly 180 degrees. The check walks 201 steps of it and asserts all four properties.
There is no configuration to avoid, no angle to clamp, and no atan2 whose arguments both
vanish. The reason is structural: a quaternion never decomposes an orientation into nested
turns, so there is no inner axis to collapse into an outer one.
When to Use Which
Section titled “When to Use Which”Three representations now, with honest jobs:
| Use | For |
|---|---|
| Euler angles | anything a person reads or types. Section 3.1 |
| Quaternion | storing, composing and interpolating orientation. This Section |
| Matrix | transforming vertices, and combining with position and scale |
The usual pipeline: accept Euler angles at the edges, hold quaternions while you compute, and convert to a matrix once per object per frame for the renderer. Each conversion is exact, so nothing is lost by moving between them - only by picking the wrong one to compute in.
Where This Shows Up
Section titled “Where This Shows Up”- Every animated skeleton. Bone rotations are stored as quaternions because they get blended constantly and must not gimbal lock mid-animation.
- Turning a character towards a direction, which is a blend from the current orientation to a target one, and which needs the sign flip.
- Camera orbits and free-look in space games, where there is no up axis to privilege and no pitch to clamp.
- Networked orientation, where four numbers compress better than nine and can be sent as three, reconstructing from the other three since the length is known to be 1.
- Physics angular velocity, which integrates onto an orientation as a quaternion product.