Quaternions represent 3D orientations as a 4-component number
(x, y, z, w). They avoid gimbal lock, compose rotations without
matrix multiplication, and interpolate smoothly via slerp. This guide
builds intuition from scratch and connects each concept to the Three.js
Quaternion API.
1What is a quaternion?
A quaternion stores a 3D rotation as a rotation axis and an angle,
encoded compactly as four numbers:
q = (x, y, z, w) = sin(θ/2)·n̂ + cos(θ/2)
Where n̂ is a unit axis vector and
θ is the rotation angle. The unit quaternion
constraint is x² + y² + z² + w² = 1. For no rotation,
q = (0, 0, 0, 1) — the identity.
// Manual axis-angle → quaternion function fromAxisAngle(ax, ay, az,
angle) { const half = angle / 2; const s = Math.sin(half); return { x:
ax * s, y: ay * s, z: az * s, w: Math.cos(half) }; } // Rotate 90°
around Y const q = fromAxisAngle(0, 1, 0, Math.PI / 2); // Result: {
x:0, y:0.7071, z:0, w:0.7071 }
Quaternions live in 4D space — the unit sphere in 4D. Both
q and -q represent the same rotation (double
cover). This is why slerp can take the short path by checking the dot
product sign.
2Euler angles and gimbal lock
Euler angles (yaw, pitch, roll) apply three sequential rotations. The
problem: when pitch reaches ±90°, yaw and roll become aligned — the
object loses one degree of freedom. This is
gimbal lock.
// Euler rotation in Three.js — order-dependent! mesh.rotation.order =
'YXZ'; // yaw → pitch → roll mesh.rotation.y = Math.PI / 4; // yaw 45°
mesh.rotation.x = Math.PI / 2; // pitch 90° → GIMBAL LOCK
mesh.rotation.z = Math.PI / 4; // this roll is now the same axis as
yaw! // The quaternion equivalent is always safe: const q = new
THREE.Quaternion(); q.setFromEuler(new THREE.Euler(Math.PI/2,
Math.PI/4, Math.PI/4, 'YXZ')); mesh.quaternion.copy(q); // no gimbal
lock, same orientation
3The Hamilton product — composing
rotations
To apply rotation B after rotation
A, multiply quaternions: q = B * A. Note
the order: the rightmost quaternion is applied first (same as
matrix multiplication convention).
// Hamilton product of two quaternions function mulQ(a, b) { return {
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, w:
a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z, }; } // Rotate vector by
quaternion: v' = q * (0,v) * q⁻¹ // In Three.js: v.applyQuaternion(q)
// Three.js — compose two rotations const qA = new
THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0),
Math.PI/2); const qB = new THREE.Quaternion().setFromAxisAngle(new
THREE.Vector3(1,0,0), Math.PI/4); const combined =
qB.clone().multiply(qA); // B after A mesh.quaternion.copy(combined);
4Converting: axis-angle, matrix, Euler
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
const q = new THREE.Quaternion(); // FROM axis-angle
q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 3); // FROM
Euler object q.setFromEuler(new THREE.Euler(0.1, 0.5, 0.2, 'XYZ')); //
FROM rotation matrix (extracts quaternion from upper 3×3) const mat =
new THREE.Matrix4().makeRotationY(Math.PI / 6);
q.setFromRotationMatrix(mat); // FROM two direction vectors (rotation
FROM a TO b) const from = new THREE.Vector3(0, 1, 0); // up const to =
new THREE.Vector3(1, 0, 0); // right q.setFromUnitVectors(from, to);
// TO Euler const euler = new THREE.Euler().setFromQuaternion(q,
'YXZ'); // TO axis-angle (recover the encoding) const axis = new
THREE.Vector3(); const angle = 2 * Math.acos(q.w); axis.set(q.x, q.y,
q.z).normalize();
5Slerp — smooth rotation interpolation
Slerp (Spherical Linear intERPolation) travels along the great circle
on the 4D unit sphere, giving constant angular velocity and smooth
results unlike linear lerp on Euler angles.
const qStart = new THREE.Quaternion().setFromEuler(new THREE.Euler(0,
0, 0)); const qEnd = new THREE.Quaternion().setFromEuler(new
THREE.Euler(0, Math.PI, 0)); const qCur = new THREE.Quaternion(); //
In animation loop (t goes 0 → 1) function animate(t) { // Always take
the shortest arc — flip if dot product is negative if
(qStart.dot(qEnd) < 0) qEnd.negate(); qCur.slerpQuaternions(qStart,
qEnd, t); mesh.quaternion.copy(qCur); } // Squad — smooth quaternion
spline (through multiple keyframes) const squad = new
THREE.Quaternion(); THREE.Quaternion.slerpFlat( squad.toArray(), 0, //
output qStart.toArray(), 0, // from qEnd.toArray(), 0, // to t // 0..1
);
Nlerp vs Slerp: Nlerp (normalize a lerped quaternion)
is faster and provides nearly identical results for small angles (t
< 0.3). Use Slerp for smooth camera, character, or large-angle
animations. Use Nlerp for physics integrators where small angles
dominate.
6Three.js Quaternion API cheatsheet
const q = new THREE.Quaternion(x, y, z, w); // defaults to identity
(0,0,0,1) // Modify q.identity() // reset to no-rotation q.copy(other)
// copy from another quaternion q.set(x, y, z, w) // set components
directly q.normalize() // ensure unit length (fix drift) // Combine
q.multiply(other) // q = q * other (right-multiply)
q.premultiply(other) // q = other * q (left-multiply) q.conjugate() //
invert a unit quaternion (negate x,y,z) // Interpolate q.slerp(qEnd,
t) // in-place slerp toward qEnd THREE.Quaternion.slerpFlat(dst,dOff,
srcA,aOff, srcB,bOff, t) // static flat-array // Apply to vector const
rotated = someVec3.clone().applyQuaternion(q); // Extract
console.log(q.x, q.y, q.z, q.w); const arr = q.toArray(); // [x, y, z,
w] const axis = new THREE.Vector3(); const angle = 2 * Math.acos(q.w);
if (angle > 0.00001) axis.set(q.x, q.y,
q.z).divideScalar(Math.sin(angle / 2));