Robotics · Linear Algebra
📅 July 2026 ⏱ ≈ 14 min read 🎯 Intermediate–Advanced · Last updated: 9 July 2026

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.

TL;DR: Robots need to represent 3D orientation, and there are four competing ways to do it: rotation matrices, Euler angles, axis-angle, and quaternions. Euler angles are readable but suffer gimbal lock; quaternions avoid it, stay compact, and interpolate smoothly (SLERP), which is why most robotics stacks store orientation as a quaternion and convert to matrices only for bulk point-cloud transforms.

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:

Rᵀ · R = I (orthogonal — columns are unit vectors, mutually perpendicular)
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).

Re-orthogonalization: long-running robot controllers periodically re-orthogonalize R (e.g. via Gram-Schmidt or SVD) to correct for accumulated floating-point drift — otherwise the "rotation" matrix slowly deforms into a general linear transform.

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.

R = Rz(yaw) · Ry(pitch) · Rx(roll)
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:

R = I + sin(θ)·K + (1 − cos(θ))·K²
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:

q = ( cos(θ/2), n̂ · sin(θ/2) ) = (w, x, y, z)
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.

Double cover: q and −q represent the exact same rotation. This is harmless for rotating vectors, but matters for interpolation — always check the dot product of two quaternions before SLERP-ing between them (see below), and negate one if it's negative, to take the shorter path.

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):

(w₁,v₁) * (w₂,v₂) = ( w₁w₂ − v₁·v₂, w₁v₂ + w₂v₁ + v₁×v₂ )

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):

p' = q * (0, p) * q⁻¹

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:

slerp(q_a, q_b, t) = [sin((1−t)Ω)·q_a + sin(tΩ)·q_b] / sin(Ω)
Ω = 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.

IMU / sensor fusion: orientation estimators (Madgwick, Mahony, complementary filters) also output quaternions directly, because integrating angular velocity readings as quaternion rates avoids the same gimbal-lock and drift problems as integrating Euler rates.

🦾 Try Robot Arm Kinematics

See forward and inverse kinematics — and orientation propagation through the joint chain — live in the browser.

Open simulation →