In two dimensions, complex numbers rotate vectors effortlessly through Euler’s formula $e^{i\theta} = \cos\theta + i\sin\theta$. In three dimensions, rotation has historically been fraught with numerical and geometric pitfalls.

Euler angles (pitch, yaw, roll) suffer from gimbal lock, an irreversible loss of a rotational degree of freedom when two axes align. Standard $3 \times 3$ rotation matrices require 9 numbers to store 3 degrees of freedom. Over thousands of animation frames, matrix multiplications accumulate numerical precision drift that warps geometry unless restored by costly Gram-Schmidt orthonormalization.

In 1843, William Rowan Hamilton realized that rotating vectors in 3D space requires a 4D algebraic system called quaternions.

A quaternion $q$ consists of a real scalar component $s$ and a 3D imaginary vector component $\mathbf{v}$:

$$ q = [s, \mathbf{v}] = s + v_x \mathbf{i} + v_y \mathbf{j} + v_z \mathbf{k} $$

The imaginary fundamental units satisfy Hamilton’s defining relation:

$$ \mathbf{i}^2 = \mathbf{j}^2 = \mathbf{k}^2 = \mathbf{i}\mathbf{j}\mathbf{k} = -1 $$

NON-COMMUTATIVE CYCLIC GRAPH i j k +k +i +j Clockwise = (+) Counter-CW = (−) HAMILTON MULTIPLICATION RULES Fundamental Identity: i² = j² = k² = ijk = −1 Direct Order (CW): ij = k,   jk = i,   ki = j Reversed (CCW): ji = −k,   kj = −i,   ik = −j Anti-Commutativity: ab = −ba   (pure orthogonal) Associative, distributive, non-commutative

Conjugate, Norm, and Unit Quaternions

The conjugate of a quaternion $q = [s, \mathbf{v}]$ negates the imaginary vector component:

$$ q^\ast = [s, -\mathbf{v}] $$

The Euclidean norm of a quaternion is $\lVert q \rVert = \sqrt{q q^\ast} = \sqrt{s^2 + \lVert \mathbf{v} \rVert^2}$. Every non-zero quaternion factors into its scalar magnitude $\lVert q \rVert$ and a unit direction $\hat{q}$:

$$ q = \lVert q \rVert \hat{q} \quad \text{where } \lVert \hat{q} \rVert = 1 $$

For a unit quaternion ($\lVert q \rVert = 1$), the algebraic inverse equals the conjugate: $q^{-1} = q^\ast$.

Embedding 3D Vectors

To manipulate geometric points and vectors in 3D space, we embed a 3D vector $\mathbf{v}$ into quaternion space as a pure quaternion with a zero scalar component:

$$ p = [0, \mathbf{v}] $$

Deriving 3D Rotation: From One-Sided to Sandwich Product

In 2D, a rotor $e^{i\theta} = \cos\theta + i\sin\theta$ rotates points in the Cartesian plane through a single one-sided multiplication $z^\prime = e^{i\theta} z = (\cos\theta + i\sin\theta)(x + iy)$.

To rotate vectors in 3D, let us propose a unit quaternion rotor defined by a scalar $s$ and a unit rotation axis $\hat{\mathbf{n}}$:

$$ q = [s, \lambda \hat{\mathbf{n}}] \quad \text{where } \lVert \hat{\mathbf{n}} \rVert = 1 \text{ and } s^2 + \lambda^2 = 1 $$

Here:

  • $s$ is the scalar component.
  • $\hat{\mathbf{n}}$ is the unit directional vector defining the 3D rotation axis ($\lVert \hat{\mathbf{n}} \rVert = 1$).
  • $\lambda$ is the scalar amplitude (weight) of the vector component.
  • $s^2 + \lambda^2 = 1$ enforces the unit norm constraint $\lVert q \rVert^2 = s^2 + \lVert \lambda \hat{\mathbf{n}} \rVert^2 = s^2 + \lambda^2 = 1$. Because rotation scales lengths as $\lVert \mathbf{v}^\prime \rVert = \lVert q \rVert^2 \lVert \mathbf{v} \rVert$, setting $s^2 + \lambda^2 = 1$ guarantees a rigid rotation ($\lVert \mathbf{v}^\prime \rVert = \lVert \mathbf{v} \rVert$) without stretching or shrinking.

To test whether 3D vector rotation works with a single multiplication like complex numbers, we compute the one-sided product $q p$:

$$ \begin{aligned} qp &= [s, \lambda \hat{\mathbf{n}}][0, \mathbf{v}] \\ &= [-\lambda (\hat{\mathbf{n}} \cdot \mathbf{v}), s \mathbf{v} + \lambda (\hat{\mathbf{n}} \times \mathbf{v})] \end{aligned} $$

This product splits into a scalar part $-\lambda (\hat{\mathbf{n}} \cdot \mathbf{v})$ and a 3D vector part $s \mathbf{v} + \lambda (\hat{\mathbf{n}} \times \mathbf{v})$. For an arbitrary 3D vector, the dot product $\hat{\mathbf{n}} \cdot \mathbf{v}$ is non-zero, causing vector information to leak into the real scalar dimension. Thus, a single one-sided multiplication fails to produce a pure 3D vector for general rotations.

Perpendicular Vector Case

When the vector $\mathbf{v}$ is strictly perpendicular to the rotation axis $\hat{\mathbf{n}}$ ($\hat{\mathbf{n}} \cdot \mathbf{v} = 0$), the scalar dot product vanishes:

$$ qp = [0, s \mathbf{v} + \lambda (\hat{\mathbf{n}} \times \mathbf{v})] $$

Because $\hat{\mathbf{n}} \perp \mathbf{v}$ and $\lVert \hat{\mathbf{n}} \rVert = 1$, the cross product vector $\hat{\mathbf{n}} \times \mathbf{v}$ has the exact same length as $\mathbf{v}$:

$$ \lVert \hat{\mathbf{n}} \times \mathbf{v} \rVert = \lVert \hat{\mathbf{n}} \rVert \lVert \mathbf{v} \rVert \sin(90^\circ) = \lVert \mathbf{v} \rVert $$

The vectors $\mathbf{v}$ and $\hat{\mathbf{n}} \times \mathbf{v}$ form an orthonormal basis for the plane perpendicular to $\hat{\mathbf{n}}$. Setting $s = \cos\theta$ and $\lambda = \sin\theta$ evaluates the vector component to:

$$ \mathbf{v}^\prime = \cos\theta \mathbf{v} + \sin\theta (\hat{\mathbf{n}} \times \mathbf{v}) $$

Which is the exact equation for a 2D planar rotation by angle $\theta$.

The one-sided product $q p$ works only under this strict perpendicular condition:

  1. The scalar part stays strictly zero because $\hat{\mathbf{n}} \cdot \mathbf{v} = 0$, preventing 4D scalar leakage.
  2. The basis vectors $\mathbf{v}$ and $\hat{\mathbf{n}} \times \mathbf{v}$ have equal lengths and are orthogonal, constraining the rotation to a 2D circle around $\hat{\mathbf{n}}$.

General Case & The Sandwich Product

When $\mathbf{v}$ is not perpendicular to $\hat{\mathbf{n}}$, the scalar term $-\lambda (\hat{\mathbf{n}} \cdot \mathbf{v})$ is non-zero. Vector information leaks into the scalar component, and the one-sided product $qp$ fails to produce a pure 3D vector.

Post-multiplying by the conjugate $q^\ast = [s, -\lambda \hat{\mathbf{n}}]$ cancels this scalar leakage and rotates the vector via its orthogonal decomposition $\mathbf{v} = \mathbf{v}_{\parallel} + \mathbf{v}_{\perp}$:

$$ p^\prime = q p q^\ast $$

This is the sandwich product. It acts on the two vector components in two stages:

  1. Parallel Component $\mathbf{v}_{\parallel} = (\mathbf{v} \cdot \hat{\mathbf{n}})\hat{\mathbf{n}}$: Commutes directly with the rotor and remains invariant ($q \mathbf{v}_{\parallel} q^\ast = \mathbf{v}_{\parallel}$).
  2. Perpendicular Component $\mathbf{v}_{\perp} = \mathbf{v} - \mathbf{v}_{\parallel}$: Pre-multiplication by $q$ rotates $\mathbf{v}_{\perp}$ by an angle $+\phi$, and post-multiplication by $q^\ast$ rotates it by an additional $+\phi$, producing a total rotation of $2\phi$.

Because the sandwich product applies the rotor on both sides, the resulting rotation angle doubles ($2\phi$). To rotate an arbitrary vector $\mathbf{v}$ around unit axis $\hat{\mathbf{n}}$ by an angle $\theta$, we set $\phi = \frac{\theta}{2}$, producing the fundamental unit rotation quaternion:

$$ q = \left[\cos\left(\tfrac{\theta}{2}\right), \sin\left(\tfrac{\theta}{2}\right)\hat{\mathbf{n}}\right] $$

The Double Cover and Antipodal Rotors

Because the rotation quaternion uses half-angles ($\theta/2$), rotating a 3D vector by $360^\circ$ ($2\pi$) flips the sign of the quaternion:

3D Physical Rotation ($\theta$) Half-Angle ($\theta/2$) Unit Rotor $q(\theta) = [\cos(\theta/2), \sin(\theta/2)\hat{\mathbf{n}}]$
$0^\circ$ (Start) $0^\circ$ $[\cos(0), \sin(0)\hat{\mathbf{n}}] = [1, \mathbf{0}] = +1 = +q$
$360^\circ$ ($2\pi$) $180^\circ$ ($\pi$) $[\cos(\pi), \sin(\pi)\hat{\mathbf{n}}] = [-1, \mathbf{0}] = -1 = -q$
$720^\circ$ ($4\pi$) $360^\circ$ ($2\pi$) $[\cos(2\pi), \sin(2\pi)\hat{\mathbf{n}}] = [1, \mathbf{0}] = +1 = +q$

When rotating a vector with $-q$, the sandwich product cancels the negative sign:

$$ (-q) p (-q)^\ast = (-1)^2 (q p q^\ast) = q p q^\ast $$

Both $+q$ and $-q$ evaluate to the exact same 3D spatial rotation $\mathbf{R}(+q) \equiv \mathbf{R}(-q)$. This 2-to-1 mapping makes unit quaternions a double cover of 3D rotations ($\mathrm{SU}(2) \to \mathrm{SO}(3)$).

TWO-STAGE SANDWICH: $+\tfrac{\theta}{2} + \tfrac{\theta}{2} = \theta$
$\mathbf{v}_\perp$
$q \mathbf{v}_\perp \; (+\theta/2)$
$q \mathbf{v}_\perp q^\ast \; (+\theta)$
$\theta/2$
$\theta/2$
DOUBLE COVER: $\mathrm{SU}(2) \to \mathrm{SO}(3)$
$+q$
$-q$
$\mathbf{R}(+q) \equiv \mathbf{R}(-q)$
360° rotor turn (q → −q) reverses sign 720° rotor turn returns to original +q

Expanding $p^\prime = q p q^\ast$ evaluates directly to Rodrigues’ rotation formula $\mathbf{v}^\prime = \cos\theta \mathbf{v} + \sin\theta (\hat{\mathbf{n}} \times \mathbf{v}) + (1 - \cos\theta)(\mathbf{v} \cdot \hat{\mathbf{n}})\hat{\mathbf{n}}$.

The interactive 3D explorer below visualizes this orthogonal decomposition and sandwich product rotation in action:

Spherical Linear Interpolation (SLERP)

In keyframe animation, camera control, and physics engines, orientations must transition smoothly from an initial rotation $q_1$ to a target rotation $q_2$.

Linear interpolation of Euler angles $(\alpha(t), \beta(t), \gamma(t))$ produces variable angular velocity, axis wobble, and severe distortion near gimbal lock.

Because unit quaternions reside on the unit 3-sphere $S^3$, Ken Shoemake introduced Spherical Linear Interpolation (SLERP) to trace the shortest geodesic arc at constant angular velocity:

$$ \text{Slerp}(q_1, q_2, t) = \frac{\sin((1-t)\Omega)}{\sin\Omega} q_1 + \frac{\sin(t\Omega)}{\sin\Omega} q_2 $$

Where $\Omega$ represents the 4D angle between the two quaternions:

$$ \cos\Omega = q_1 \cdot q_2 = s_1 s_2 + x_1 x_2 + y_1 y_2 + z_1 z_2 $$

Because $q$ and $-q$ represent the exact same 3D orientation (antipodal symmetry on $S^3$), we check the sign of the dot product before interpolating. If $q_1 \cdot q_2 < 0$, we negate $q_2$ to take the shorter rotational path:

$$ \cos\Omega = |q_1 \cdot q_2| $$

SLERP ON S³ HYPERSPHERE GEODESIC Surface S³ (‖q‖ = 1) Origin O Ω = arccos(q₁ · q₂) q₁ (t = 0) q₂ (t = 1) t = 0.25 t = 0.50 t = 0.75 LERP Midpoint (‖q‖ < 1) TRAJECTORY COMPARISON ✔ SLERP (Geodesic Arc): • Constant speed: dθ/dt = Ω • Unit norm preserved (‖q‖ = 1) • Minimal rotation torque ✖ LERP (Straight Chord): • Speed: non-linear velocity warp

Geodesic vs Multi-Axis Interpolation

The difference in 3D trajectory between SLERP and Euler angle LERP lies in their rotational geometry:

  • Quaternion SLERP: Computes the relative rotation $\Delta q = q_2 q_1^\ast = [\cos(\Theta/2), \sin(\Theta/2)\hat{\mathbf{n}}]$. The entire 3D rigid body rotates around this constant invariant axis $\hat{\mathbf{n}}$ at constant angular speed $\Theta$. Because $\hat{\mathbf{n}}$ is fixed throughout interpolation, all points on the aircraft sweep circular arcs in flat planes parallel to the rotation disc.
  • Euler LERP: Linearly blends $(\text{pitch}(t), \text{yaw}(t), \text{roll}(t))$ as three independent scalars and applies them sequentially ($R = R_x R_y R_z$). Because the three rotational frames compound hierarchically, the instantaneous axis of rotation constantly tilts and wobbles. There is no single invariant plane of rotation.

Quaternions in Three.js

Every formula above maps to a method call in Three.js . The flight simulator below uses these exact calls, so it doubles as a live reference for what parameters go where.

Constructor Order

The article writes quaternions as $q = [w, x, y, z]$ with the scalar first. Three.js stores them in the opposite order, $q = (x, y, z, w)$, so the constructor takes four arguments with the scalar last:

// math notation: q = [w, x, y, z]
// Three.js:      new THREE.Quaternion(x, y, z, w)
// 90° about +Y: half angle θ/2 = 45°, cos(45°) = sin(45°) = √2/2 ≈ 0.707
// q = [cos(θ/2), sin(θ/2)·n̂] = [0.707, 0, 0.707, 0] → (x, y, z, w) = (0, 0.707, 0, 0.707)
const q = new THREE.Quaternion(0, 0.707, 0, 0.707)

This ordering is the most common source of bugs. The simulator engine reorders when it hands quaternions to Three.js:

// engine arrays are [w, x, y, z], Three.js wants (x, y, z, w)
const qThree = new THREE.Quaternion(qCurrent[1], qCurrent[2], qCurrent[3], qCurrent[0])

From an Axis and Angle

The rotor $q = [\cos(\theta/2), \sin(\theta/2)\hat{\mathbf{n}}]$ becomes setFromAxisAngle. Pass the full rotation angle $\theta$ in radians, not the half angle. Three.js applies the half-angle internally:

const axis = new THREE.Vector3(0, 1, 0) // unit length, matching ‖n̂‖ = 1
const q = new THREE.Quaternion().setFromAxisAngle(axis, Math.PI / 2)

From Euler Angles

Three.js can also build a quaternion from pitch, yaw, and roll with an explicit rotation order:

const euler = new THREE.Euler(pitch, yaw, roll, 'XYZ')
const q = new THREE.Quaternion().setFromEuler(euler)

This is convenient, but it is the path that hits gimbal lock, the failure the SLERP section above avoids.

Spherical Interpolation

The SLERP formula maps directly to slerp. The shortest-path sign check from $\cos\Omega = |q_1 \cdot q_2|$ happens internally:

const q = q1.clone().slerp(q2, t) // t in [0, 1]

The total angular distance between two orientations is q1.angleTo(q2), which computes exactly $2\arccos(|q_1 \cdot q_2|)$:

const angle = q1.angleTo(q2) // radians

Applying to an Object

Assign the quaternion to the object’s orientation and Three.js converts it into a rotation matrix for the renderer:

mesh.quaternion.copy(q)
// or
mesh.applyQuaternion(q)

For long-running simulations, renormalize periodically to counter drift:

q.normalize()

Quaternion Composition

Combining rotations follows the same right-to-left order as matrix products. Applying $q_1$ first and then $q_2$ is $q_2 q_1$:

const q = q2.clone().multiply(q1) // q1 first, then q2

This works because the sandwich products telescope. Substituting $p^\prime = q_1 p q_1^\ast$ into the second rotation:

$$ \begin{aligned} p^{\prime\prime} &= q_2 p^\prime q_2^\ast \\ &= q_2 (q_1 p q_1^\ast) q_2^\ast \\ &= (q_2 q_1) p (q_1^\ast q_2^\ast) \\ &= (q_2 q_1) p (q_2 q_1)^\ast \end{aligned} $$

A sequence of rotations $q_1, q_2, \dots, q_n$ combines into a single composite quaternion $q = q_n \dots q_2 q_1$. Composing two quaternions requires 16 scalar multiplications, compared to 27 multiplications for $3 \times 3$ matrices.

Putting It All Together: The Flight Simulator

The interactive flight simulator below lets you scrub or play both interpolation paths side by side, with the exact Three.js calls that produce the current orientation shown on the left.

Quaternions in the Rendering Pipeline

Quaternions are stored and interpolated in software, but the GPU rasterizes geometry through matrix multiplication. The conversion from the sandwich product $p^\prime = q p q^\ast$ to the equivalent $3 \times 3$ rotation matrix, for $q = [w, x, y, z]$ with unit norm ($\lVert q \rVert = 1$), is:

$$ \mathbf{R}(q) = \begin{bmatrix} 1 - 2(y^2 + z^2) & 2(xy - wz) & 2(xz + wy) \\ 2(xy + wz) & 1 - 2(x^2 + z^2) & 2(yz - wx) \\ 2(xz - wy) & 2(yz + wx) & 1 - 2(x^2 + y^2) \end{bmatrix} $$

Notice that negating the quaternion ($-q = [-w, -x, -y, -z]$) squares all terms and negates sign pairs identically, producing the exact same matrix $\mathbf{R}(-q) = \mathbf{R}(q)$. This reflects the 2-to-1 homomorphism $\text{SU}(2) \to \text{SO}(3)$.

Key Takeaways

Concept Formula Takeaway
Rotor $q = [\cos(\theta/2), \sin(\theta/2)\hat{\mathbf{n}}]$ A unit quaternion encodes a rotation of $\theta$ about the fixed axis $\hat{\mathbf{n}}$; the half angle keeps the rotor on the unit 3-sphere.
Sandwich product $p^\prime = q p q^\ast$ The two-sided product rotates a vector without leaking into the scalar dimension, unlike the one-sided product $q p$.
Double cover $\mathbf{R}(-q) = \mathbf{R}(q)$ $q$ and $-q$ represent the same orientation, so SLERP negates $q_2$ when $q_1 \cdot q_2 < 0$ to take the shorter path.
SLERP $\text{Slerp}(q_1, q_2, t) = \frac{\sin((1-t)\Omega)}{\sin\Omega} q_1 + \frac{\sin(t\Omega)}{\sin\Omega} q_2$ Traces the shortest geodesic on $S^3$ at constant angular speed, avoiding the axis wobble and gimbal lock of Euler LERP.
Composition $q = q_2 q_1$ Applying $q_1$ then $q_2$ multiplies quaternions right to left, like matrices, in 16 multiplications versus 27 for $3 \times 3$ matrices.
To the GPU $\mathbf{R}(q)$ Quaternions are stored and interpolated in software, then converted once into a rotation matrix for vertex transformation on the GPU.