Rotation matrices & quaternions in robotics
A robot's end-effector doesn't just need a position — it needs an orientation: which way the gripper is facing. Rotation matrices, Euler angles, axis-angle and quaternions are four different ways to encode the same 3D rotation, each with its own trade-offs. This article builds up from SO(3) (the mathematical group of all possible 3D rotations) to quaternions and shows why almost every modern robotics stack (ROS, Unity, Unreal, Three.js) ultimately stores orientation as a quaternion.
The rotation group SO(3)
Every rigid orientation in 3D space can be represented by a 3×3 rotation matrix R belonging to the special orthogonal group SO(3). Two conditions define membership:
det(R) = +1 (proper rotation, not a reflection)
Composing rotations is just matrix multiplication:
R_total = R₂ · R₁ applies R₁ first, then R₂. Rotating a
point is a matrix-vector product: p' = R · p. A
rotation matrix is easy to apply to vectors and easy to compose, but
it stores 9 numbers for 3 degrees of freedom —
redundant, and prone to drifting away from orthogonality after
repeated floating-point multiplication (numerical error accumulates
and R slowly stops being a true rotation).
Euler angles and gimbal lock
Euler angles describe any orientation as three sequential rotations about coordinate axes — e.g. roll (X), pitch (Y), yaw (Z), or the aerospace convention Z-Y-X. They're intuitive for humans (three numbers, each in degrees) but suffer from gimbal lock: when the middle rotation reaches ±90°, the first and third axes become aligned, and one degree of rotational freedom is lost.
at pitch = ±90°, yaw and roll rotate around the same axis → gimbal lock
For a 6-DOF robot arm this matters concretely: near a gimbal-lock pose, an infinitesimal target orientation change can demand a huge, discontinuous jump in one Euler angle — the arm "flips" instead of moving smoothly. This is why Euler angles are used for human-readable UI display (sliders, readouts) but rarely for internal orientation state.
Axis-angle and Rodrigues' formula
Any 3D rotation can be described by a single unit axis n̂ and an angle θ to rotate around it (Euler's rotation theorem). Rodrigues' rotation formula converts axis-angle directly to a rotation matrix without going through Euler angles at all:
where K is the cross-product (skew-symmetric) matrix of n̂:
K = [ 0, −nz, ny; nz, 0, −nx; −ny, nx, 0 ]
Axis-angle is compact (4 numbers: 3 for the axis + 1 for the angle, or 3 if you scale the axis by θ), has no gimbal lock, and is the representation most joint-velocity and angular-velocity integrators use internally — an angular velocity ω is exactly an axis-angle rate (axis = ω/|ω|, rate = |ω|).
Quaternions
A unit quaternion q = w + xi + yj + zk (with w² + x² + y² + z² = 1) encodes exactly the same information as axis-angle, but in a form that composes and interpolates far more cleanly:
w = scalar (real) part, (x,y,z) = vector (imaginary) part
Quaternions have exactly 4 numbers for 3 degrees of freedom (one constraint: unit norm) — less redundant than a 9-number matrix, more compact than storing an explicit axis and angle separately, and crucially: no gimbal lock, because there is no "middle axis" to align with the others.
Hamilton product and rotating vectors
Composing two rotations q₁ then q₂ is the Hamilton product q₂ * q₁ (quaternion multiplication is not commutative — order matters, exactly like matrix multiplication):
To rotate a vector p by quaternion q, sandwich it between q and its conjugate q⁻¹ (for a unit quaternion, the conjugate q* = (w,−v) equals the inverse):
In practice you never implement this literally — you expand the sandwich product algebraically once and get a direct formula (see the code below), which is what every game engine and robotics library actually runs.
Interpolation: SLERP vs LERP
Interpolating orientation — e.g. smoothly moving an end-effector from orientation q_a to q_b over a trajectory — is where quaternions truly shine. Naïve linear interpolation (LERP) of the four components followed by re-normalization gives visually uneven speed; SLERP (Spherical Linear Interpolation) moves at constant angular velocity along the shortest great-circle arc on the 4D unit hypersphere:
Ω = angle between q_a and q_b: cos(Ω) = q_a · q_b
When Ω is very small (nearly identical orientations), SLERP is numerically unstable (division by ~0) — production code falls back to a normalized LERP in that case, since the difference is imperceptible over a tiny angle.
Comparison table
| Representation | Numbers | Gimbal lock | Composition cost | Best use |
|---|---|---|---|---|
| Rotation matrix | 9 | No | 9 mults × 3 | Applying to many vectors (GPU, batch transforms) |
| Euler angles | 3 | Yes | Convert to matrix first | Human-readable UI, sliders |
| Axis-angle | 3–4 | No | Convert to matrix/quat first | Angular velocity, joint rates |
| Quaternion | 4 | No | 16 mults (Hamilton product) | Storage, composition, interpolation (SLERP) |
Most robotics stacks store orientation as a quaternion internally, convert to Euler angles only for display, and convert to a rotation matrix just before batch-transforming large arrays of points (e.g. a point cloud) — because matrix-vector multiplication vectorizes better on GPU/SIMD than repeated Hamilton products.
JavaScript implementation
Quaternion class
class Quat {
constructor(w = 1, x = 0, y = 0, z = 0) {
this.w = w; this.x = x; this.y = y; this.z = z;
}
// Build from axis (unit vector) + angle in radians
static fromAxisAngle(axis, theta) {
const h = theta * 0.5, s = Math.sin(h);
return new Quat(Math.cos(h), axis.x * s, axis.y * s, axis.z * s);
}
// Hamilton product: this * other
mul(o) {
return new Quat(
this.w*o.w - this.x*o.x - this.y*o.y - this.z*o.z,
this.w*o.x + this.x*o.w + this.y*o.z - this.z*o.y,
this.w*o.y - this.x*o.z + this.y*o.w + this.z*o.x,
this.w*o.z + this.x*o.y - this.y*o.x + this.z*o.w
);
}
conjugate() { return new Quat(this.w, -this.x, -this.y, -this.z); }
// Rotate a vector {x,y,z} by this unit quaternion (expanded sandwich product)
rotateVec(v) {
const qv = new Quat(0, v.x, v.y, v.z);
const r = this.mul(qv).mul(this.conjugate());
return { x: r.x, y: r.y, z: r.z };
}
normalize() {
const n = Math.hypot(this.w, this.x, this.y, this.z);
return new Quat(this.w/n, this.x/n, this.y/n, this.z/n);
}
}
SLERP
function slerp(qa, qb, t) {
let { w: w2, x: x2, y: y2, z: z2 } = qb;
let cosOmega = qa.w*w2 + qa.x*x2 + qa.y*y2 + qa.z*z2;
// Take the shorter path on the hypersphere (double cover fix)
if (cosOmega < 0) { cosOmega = -cosOmega; w2 = -w2; x2 = -x2; y2 = -y2; z2 = -z2; }
let s1, s2;
if (cosOmega > 0.9995) {
// nearly identical: fall back to normalized LERP to avoid 0/0
s1 = 1 - t; s2 = t;
} else {
const omega = Math.acos(cosOmega);
const sinOmega = Math.sin(omega);
s1 = Math.sin((1 - t) * omega) / sinOmega;
s2 = Math.sin(t * omega) / sinOmega;
}
return new Quat(
s1*qa.w + s2*w2, s1*qa.x + s2*x2,
s1*qa.y + s2*y2, s1*qa.z + s2*z2
).normalize();
}
Using orientation in a robot arm
In a 6-DOF manipulator, every joint transform in the forward
kinematics chain carries both a translation and a rotation. When
using Three.js, every Object3D
already stores its rotation internally as a quaternion
(object3d.quaternion) — Euler angles
(object3d.rotation) are just a getter/setter view onto
it, converted back and forth. Setting joint angles by writing to
.rotation.x repeatedly for a revolute joint is fine
(single-axis rotation never hits gimbal lock), but the
end-effector's world orientation — the accumulated
product of all six joint rotations — should be read out and
interpolated as a quaternion, not decomposed into Euler angles,
whenever you need smooth motion between target poses.
🦾 Try Robot Arm Kinematics
See forward and inverse kinematics — and orientation propagation through the joint chain — live in the browser.