Article
Geometry · ⏱ ~13 min read

Quaternions Without Fear

William Rowan Hamilton carved i² = j² = k² = ijk = −1 into a Dublin bridge in 1843 after years of trying to extend complex numbers to three dimensions. The resulting quaternions — a four-dimensional number system — turned out to be the most elegant way to represent 3D rotations: no gimbal lock, smooth spherical interpolation, and only 4 numbers instead of 9 matrix entries. Every game engine and 3D renderer uses them internally.

1. From Complex Numbers to ℍ

Complex numbers ℂ = {a + bi} extend ℝ with one imaginary unit satisfying i² = −1. They represent 2D rotations: multiplying by e^{iθ} = cos θ + i sin θ rotates a point by θ.

Hamilton's insight: to represent 3D rotations we need three imaginary units i, j, k satisfying:

i² = j² = k² = −1 ij = k, jk = i, ki = j ji = −k, kj = −i, ik = −j (non-commutative!) A quaternion: q = w + xi + yj + zk (w, x, y, z ∈ ℝ) q = (w, v) where v = (x,y,z) is the vector part

The quaternions ℍ form a division algebra — every non-zero quaternion has a multiplicative inverse. They are the last associative division algebra (by Frobenius's theorem: only ℝ, ℂ, ℍ qualify).

2. Quaternion Algebra

Addition: p + q = (w₁+w₂, x₁+x₂, y₁+y₂, z₁+z₂) Hamilton product: pq = (w₁w₂ − v₁·v₂, w₁v₂ + w₂v₁ + v₁×v₂) Conjugate: q* = (w, −x, −y, −z) Norm: |q| = √(w²+x²+y²+z²) Inverse: q⁻¹ = q* / |q|² Unit quaternion: |q| = 1 → q⁻¹ = q*

Non-commutativity

pq ≠ qp in general. This mirrors the fact that 3D rotations don't commute: rotate X first, then Y is different from Y first, then X.

Anti-podal identity

q and −q represent the same rotation. The double-cover S³ → SO(3) means every rotation corresponds to two unit quaternions.

Pure quaternion

A quaternion with w=0. Products of pure quaternions contain the dot product and cross product of 3D vectors — Hamilton's reason for inventing them.

Exponential map

exp(θ/2 · n̂) = cos(θ/2) + n̂ sin(θ/2). Analogous to Euler's formula, this generates all unit quaternions from axis-angle (n̂, θ).

3. Rotations via Quaternions

To rotate a 3D vector v by angle θ around unit axis , form the unit quaternion:

q = cos(θ/2) + n̂ · sin(θ/2) = (cos(θ/2), nx·sin(θ/2), ny·sin(θ/2), nz·sin(θ/2)) Rotation "sandwich": v' = q · (0,v) · q* (embed v as pure quaternion, then sandwich) v' is also a pure quaternion — its vector part is the rotated v. Composing rotations: first q₁ then q₂ → apply q₂q₁ (right to left) v' = (q₂q₁) · v · (q₂q₁)*

The angle/2 appears because unit quaternions live on S³ (the 3-sphere in ℝ⁴) which double-covers SO(3). A full rotation of 2π corresponds to q going halfway around S³.

4. Gimbal Lock in Euler Angles

Euler angles represent a rotation as three sequential rotations about coordinate axes (e.g., ZYX convention: yaw, pitch, roll). The middle rotation by 90° aligns two axes, reducing the effective degrees of freedom from 3 to 2 — gimbal lock:

R = Rz(ψ) · Ry(θ) · Rx(φ) When θ = π/2: Ry(π/2) maps x-axis to z-axis Rz(ψ) · Rx(φ) reduce to rotations in the SAME plane → yaw and roll become indistinguishable → lost DOF In practice: aircraft nose-up 90° causes autopilot instability Animation joint at extreme poses snaps / flips Numerical: Jacobian loses rank → integration diverges

Quaternions have no gimbal lock because they parametrise SO(3) globally (minus the double-cover antipodal identification) without singularities. This is why flight control systems, spacecraft attitude control, and 3D engines all use quaternions internally.

5. Quaternion ↔ Matrix ↔ Euler

Unit quaternion q = (w, x, y, z) → 3×3 rotation matrix: R = | 1−2(y²+z²) 2(xy−wz) 2(xz+wy) | | 2(xy+wz) 1−2(x²+z²) 2(yz−wx) | | 2(xz−wy) 2(yz+wx) 1−2(x²+y²) | Matrix → quaternion (Shepperd method): t = trace(R) = 3 − 4(x²+y²+z²) w = √(1+t)/2, then x,y,z from off-diagonal elements Quaternion → Euler (ZYX): yaw ψ = atan2(2(wz+xy), 1−2(y²+z²)) pitch θ = arcsin(clamp(2(wy−zx), −1, 1)) roll φ = atan2(2(wx+yz), 1−2(x²+y²))

6. SLERP — Spherical Linear Interpolation

Linear interpolation (LERP) of rotations does not stay on the unit sphere. SLERP interpolates along the great circle arc — the geodesic — between two unit quaternions:

SLERP(q₀, q₁, t) = q₀ · (q₀⁻¹ · q₁)^t = sin((1−t)Ω)/sinΩ · q₀ + sin(tΩ)/sinΩ · q₁ where Ω = arccos(q₀ · q₁) (half the angle between rotations) Special case: if Ω ≈ 0, use NLERP (normalised LERP) to avoid division by zero: q(t) = normalise((1−t)·q₀ + t·q₁) NLERP is slightly faster and nearly identical for small angles.

SLERP produces constant angular velocity motion between two orientations — key for smooth camera panning, character animation blending, and spacecraft attitude manoeuvres.

7. JavaScript Quaternion Class

// Minimal quaternion library for 3D rotations
class Quat {
  constructor(w = 1, x = 0, y = 0, z = 0) {
    this.w = w; this.x = x; this.y = y; this.z = z;
  }
  static fromAxisAngle(ax, ay, az, angle) {
    const s = Math.sin(angle / 2);
    return new Quat(Math.cos(angle / 2), ax*s, ay*s, az*s);
  }
  mul(q) {
    return new Quat(
      this.w*q.w - this.x*q.x - this.y*q.y - this.z*q.z,
      this.w*q.x + this.x*q.w + this.y*q.z - this.z*q.y,
      this.w*q.y - this.x*q.z + this.y*q.w + this.z*q.x,
      this.w*q.z + this.x*q.y - this.y*q.x + this.z*q.w
    );
  }
  conjugate() { return new Quat(this.w, -this.x, -this.y, -this.z); }
  norm() { return Math.sqrt(this.w**2+this.x**2+this.y**2+this.z**2); }
  normalise() { const n=this.norm(); return new Quat(this.w/n,this.x/n,this.y/n,this.z/n); }

  // Rotate vector v = {x,y,z} using sandwich product q·v·q*
  rotateVec(v) {
    const p = new Quat(0, v.x, v.y, v.z);
    const r = this.mul(p).mul(this.conjugate());
    return {x: r.x, y: r.y, z: r.z};
  }

  // SLERP between this and q at parameter t ∈ [0,1]
  slerp(q, t) {
    let dot = this.w*q.w + this.x*q.x + this.y*q.y + this.z*q.z;
    // Ensure shortest path
    if (dot < 0) { q = new Quat(-q.w,-q.x,-q.y,-q.z); dot = -dot; }
    if (dot > 0.9995) {
      // Use NLERP for near-identical quaternions
      return new Quat(
        this.w + t*(q.w-this.w), this.x + t*(q.x-this.x),
        this.y + t*(q.y-this.y), this.z + t*(q.z-this.z)
      ).normalise();
    }
    const theta = Math.acos(dot);
    const sinT  = Math.sin(theta);
    const s0 = Math.sin((1-t)*theta)/sinT;
    const s1 = Math.sin(t*theta)/sinT;
    return new Quat(s0*this.w+s1*q.w, s0*this.x+s1*q.x,
                     s0*this.y+s1*q.y, s0*this.z+s1*q.z);
  }

  toMatrix4() {
    const {w,x,y,z} = this;
    return [/* column-major 4×4 for WebGL */
      1-2*(y*y+z*z), 2*(x*y+w*z),   2*(x*z-w*y),   0,
      2*(x*y-w*z),   1-2*(x*x+z*z), 2*(y*z+w*x),   0,
      2*(x*z+w*y),   2*(y*z-w*x),   1-2*(x*x+y*y), 0,
      0,               0,               0,               1
    ];
  }
}

// Example: rotate (1,0,0) by 90° around Y-axis → should give (0,0,−1)
const q = Quat.fromAxisAngle(0, 1, 0, Math.PI / 2);
const v = q.rotateVec({x:1, y:0, z:0});
console.log(v); // → {x: ≈0, y: 0, z: ≈−1}

8. Applications

Tip: Three.js exposes THREE.Quaternion with setFromAxisAngle, multiply, slerp and toArray — the same operations as our class above, optimised for WebGL.