Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Computer Graphics (rasterisation, ray tracing, WebGPU)  Module 5 – 3D transformations, rotations, cameras and projection

3D transformations, Euler angles and gimbal lock

Build 3D rotation, scale and translation matrices, turn about any axis with Rodrigues' formula, read Euler angles back in a stated order, and meet gimbal lock.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build 3D scale, rotation and translation matrices and rotate about an arbitrary axis
  • Convert between Euler angles and matrices for a stated axis order
  • Demonstrate gimbal lock and explain why it happens
  • Predict which way a rotation turns and when a matrix mirrors a model

Before you start

What you’ll learn · Before you start

What you will learn

  • Build 3D scale, rotation and translation matrices and rotate about an arbitrary axis
  • Convert between Euler angles and matrices for a stated axis order
  • Demonstrate gimbal lock and explain why it happens
  • Predict which way a rotation turns and when a matrix mirrors a model

Before you start

On this page

Turn a paper plane 30° about x, 45° about y and 60° about z, and its nose ends at (1.414, −0.707, 1.225). Apply the same three angles in the opposite order and the nose ends 37° away. Three numbers do not describe an orientation until someone says in which order, and about which axes, they act, and at one pitch they stop describing it at all.

You will build the 4 × 4 matrices that scale, turn and move a 3D model, turn it about any axis with Rodrigues’ formula, multiply Euler angles into a matrix and read them back in two orders, and watch gimbal lock make a camera spin instead of turn. Every number comes from a program that runs on this page.

Every move of a 3D model is one matrix

The paper plane of this lesson uses glTF’s frame: its specification puts +Y up and makes the front of an asset face +Z, with −X to its right, so +X is the plane’s left. The nose is at (0, 0, 2) and the left wing tip at (1.5, 0, −1). In homogeneous coordinates: Coordinates with an extra component w. Points have w = 1, so a 4 × 4 matrix can translate them; directions have w = 0, so translation leaves them alone. More in “Matrices and homogeneous coordinates” a point is the column (x, y, z, 1), and the translation and the scale are the 3D versions of the 2D matrices you know:

T(tx,ty,tz)=(100tx010ty001tz0001),S(sx,sy,sz)=(sx0000sy0000sz00001)T(t_x, t_y, t_z) = \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix}, \qquad S(s_x, s_y, s_z) = \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

A rotation turns about an axis, and each coordinate axis has its own matrix. The axis it turns about keeps its coordinate, and the other two mix by the cosine and sine of the angle; only the 3 × 3 part is written here, the rest is the identity:

Rx(θ)=(1000cos⁡θ−sin⁡θ0sin⁡θcos⁡θ),Ry(θ)=(cos⁡θ0sin⁡θ010−sin⁡θ0cos⁡θ),Rz(θ)=(cos⁡θ−sin⁡θ0sin⁡θcos⁡θ0001)R_x(\theta) = \begin{pmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{pmatrix}, \quad R_y(\theta) = \begin{pmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{pmatrix}, \quad R_z(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{pmatrix}

In words: a positive angle turns counter-clockwise when the axis points at you, as glTF states for its rotations, so Rz(90°) turns the x axis into the y axis. Ry looks odd, with its minus sign below the diagonal, because it turns z towards x, and z comes before x when you go round the cycle x, y, z. Look at the plane under a quarter turn about each axis:

Four panels: a paper plane in 3D, then turned a quarter turn about x (nose down), about y (turned left) and about z (left wing up).The paper plane under three quarter turnsDashed: before. Solid: after. Dot: the nose.the planexyzRx(90°): nose downxyzRy(90°): turns leftxyzRz(90°): left wing upxyzNose (0, 0, 2); left wing tip (1.5, 0, −1).

The paper plane under Rx(90°), Ry(90°) and Rz(90°)

Text description of the diagram

Four panels in two columns. Each shows the x, y and z axes from the origin in an isometric view: x goes to the lower right, z to the lower left and y straight up.

The first panel shows the plane itself: a flat triangle for the wings, with the nose at (0, 0, 2) towards the lower left, marked with a dot, the wing tips at (1.5, 0, −1) and (−1.5, 0, −1), and a small upright triangle for the tail fin.

The other three panels show the plane dashed where it was and solid where a quarter turn puts it. After Rx(90°) the nose points straight down, at (0, −2, 0), and the wing triangle stands upright. After Ry(90°) the nose points along x, at (2, 0, 0), which is the plane's left, and the wings still lie flat. After Rz(90°) the nose stays at (0, 0, 2) while the left wing tip rises to (0, 1.5, −1), so the wing triangle stands on its edge.

The program applies the five matrices to the plane and measures what each does to volume:

Scale, rotation and translation on the paper plane JavaScript · basic/transform3d.mjs
// Scale, rotation and translation in 3D as 4 x 4 matrices that act on column vectors and are
// stored column-major, as WGSL and glTF store them: entry (row r, column c) is m[c * 4 + r].
// The paper plane uses glTF's frame: +Y up, its nose towards +Z, and +X on its left.

const at = (m, r, c) => m[c * 4 + r];
function fromRows(rows) {
  const m = new Array(16);
  for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) m[c * 4 + r] = rows[r][c];
  return m;
}
function mul(a, b) {
  const m = new Array(16).fill(0);
  for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) for (let k = 0; k < 4; k++) m[c * 4 + r] += at(a, r, k) * at(b, k, c);
  return m;
}
const rad = (degrees) => (degrees * Math.PI) / 180;
const T = (x, y, z) => fromRows([[1, 0, 0, x], [0, 1, 0, y], [0, 0, 1, z], [0, 0, 0, 1]]);
const S = (x, y, z) => fromRows([[x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [0, 0, 0, 1]]);
function Rx(a) {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  return fromRows([[1, 0, 0, 0], [0, c, -s, 0], [0, s, c, 0], [0, 0, 0, 1]]);
}
function Ry(a) {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  return fromRows([[c, 0, s, 0], [0, 1, 0, 0], [-s, 0, c, 0], [0, 0, 0, 1]]);
}
function Rz(a) {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  return fromRows([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);
}
// w = 1 for a point (it picks up the translation), w = 0 for a direction (it does not).
const apply = (m, [x, y, z], w = 1) => [0, 1, 2].map((r) => at(m, r, 0) * x + at(m, r, 1) * y + at(m, r, 2) * z + at(m, r, 3) * w);
// The determinant of the upper 3 x 3 part: the factor by which the matrix scales volumes.
const det3 = (m) =>
  at(m, 0, 0) * (at(m, 1, 1) * at(m, 2, 2) - at(m, 1, 2) * at(m, 2, 1)) -
  at(m, 0, 1) * (at(m, 1, 0) * at(m, 2, 2) - at(m, 1, 2) * at(m, 2, 0)) +
  at(m, 0, 2) * (at(m, 1, 0) * at(m, 2, 1) - at(m, 1, 1) * at(m, 2, 0));
const sub = (a, b) => a.map((v, i) => v - b[i]);
const cross = ([ax, ay, az], [bx, by, bz]) => [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx];
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;

const nose = [0, 0, 2];
const leftTip = [1.5, 0, -1];
const rightTip = [-1.5, 0, -1];

console.log(`Rx(90°) turns the nose ${show(nose)} to ${show(apply(Rx(rad(90)), nose))}: it points down`);
console.log(`Ry(90°) turns the nose to ${show(apply(Ry(rad(90)), nose))}: it points to the plane's left`);
console.log(`Rz(90°) turns the left tip ${show(leftTip)} to ${show(apply(Rz(rad(90)), leftTip))}: the left wing rises`);
console.log(`S(2, 1, 1) moves the left tip to ${show(apply(S(2, 1, 1), leftTip))}`);
console.log(`T(0, 1, 5) moves the nose to ${show(apply(T(0, 1, 5), nose))}`);
console.log(`and leaves the direction (0, 0, 1) as ${show(apply(T(0, 1, 5), [0, 0, 1], 0))}`);

console.log('');
console.log('determinants of the upper 3 x 3 parts:');
const examples = [
  ['Rx(90°)', Rx(rad(90))],
  ['Rz(30°) Ry(45°)', mul(Rz(rad(30)), Ry(rad(45)))],
  ['S(2, 1, 1)', S(2, 1, 1)],
  ['S(-1, 1, 1), a mirror', S(-1, 1, 1)],
];
for (const [name, m] of examples) console.log(`  ${name.padEnd(22)}${num(det3(m))}`);

// The wing triangle (nose, left tip, right tip): its corner order gives a normal by the cross product.
const normalOf = (a, b, c) => cross(sub(b, a), sub(c, a));
const F = S(-1, 1, 1);
console.log('');
console.log(`normal from the wing's corner order: ${show(normalOf(nose, leftTip, rightTip))}, pointing up`);
const mirrored = normalOf(apply(F, nose), apply(F, leftTip), apply(F, rightTip));
console.log(`after the mirror S(-1, 1, 1):        ${show(mirrored)}, pointing down`);

Output

Rx(90°) turns the nose (0, 0, 2) to (0, -2, 0): it points down
Ry(90°) turns the nose to (2, 0, 0): it points to the plane's left
Rz(90°) turns the left tip (1.5, 0, -1) to (0, 1.5, -1): the left wing rises
S(2, 1, 1) moves the left tip to (3, 0, -1)
T(0, 1, 5) moves the nose to (0, 1, 7)
and leaves the direction (0, 0, 1) as (0, 0, 1)

determinants of the upper 3 x 3 parts:
  Rx(90°)               1
  Rz(30°) Ry(45°)       1
  S(2, 1, 1)            2
  S(-1, 1, 1), a mirror -1

normal from the wing's corner order: (0, 9, 0), pointing up
after the mirror S(-1, 1, 1):        (0, -9, 0), pointing down

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node transform3d.mjs

Rx(90°) points the nose down, to (0, −2, 0), Ry(90°) swings it to the plane’s left, (2, 0, 0), and Rz(90°) lifts the left wing tip to (0, 1.5, −1). The translation moves the nose to (0, 1, 7) and leaves the direction (0, 0, 1) alone, because a direction has w = 0. The determinant: A number computed from a square matrix: the factor by which it scales areas or volumes. It is negative when the matrix mirrors and 0 when the matrix has no inverse. More in “Matrices and homogeneous coordinates” of the 3 × 3 part says what happens to volume: 1 for every rotation and every product of rotations, 2 for S(2, 1, 1), and −1 for the mirror S(−1, 1, 1). That minus sign has a visible cost. The wing’s corners, taken in order, give the normal (0, 9, 0), pointing up; after the mirror the same corner order gives (0, −9, 0). The winding order: The order of a triangle's vertices as seen on screen, clockwise or counter-clockwise. A pipeline uses it to decide which triangles face the viewer. More in “Vectors, points, dot and cross products” has flipped, so a renderer that culls back faces by winding now hides the side you should see. glTF settles this in its specification: the sign of the determinant of a node’s global transform decides whether its triangles wind counter-clockwise or clockwise.

Checkpoint

Check: Scale, rotation and translation in 3D

  1. Question 1 of 2 The nose of the paper plane is at (0, 0, 2). Where does Ry(90°) send it?

    Choose one answer.

    A hint

    Ry keeps y and turns z towards x.

    Show the answer: Question 1 of 2

    Answer: (2, 0, 0)

    Ry(θ) has the rows (cos θ, 0, sin θ), (0, 1, 0) and (−sin θ, 0, cos θ). At 90° they are (0, 0, 1), (0, 1, 0) and (−1, 0, 0), so (0, 0, 2) goes to (2, 0, 0).

  2. Question 2 of 2 The wing's corners, taken in order, give the normal (0, 9, 0). After S(−1, 1, 1) the same order gives (0, −9, 0). Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The mirror's determinant is −1, so it reverses the winding of every triangle

    The determinant of S(−1, 1, 1) is −1. glTF uses the sign of a node's global determinant to decide whether its triangles wind counter-clockwise or clockwise, so a mirrored node keeps facing outwards.

Rodrigues’ formula turns a vector about any axis

A turn about the diagonal (1, 1, 1) by 120° should cycle the axes: x becomes y, y becomes z and z becomes x, because the diagonal treats the three alike. No product of one or two basic rotations is that obvious, but one formula turns any vector v about any unit axis k by any angle θ. Split v into the part along the axis, which stays, and the part across it, which turns in the plane across the axis:

R𝐯=𝐯cos⁡θ+(𝐤×𝐯)sin⁡θ+𝐤(𝐤⋅𝐯)(1−cos⁡θ)R\,\mathbf{v} = \mathbf{v}\cos\theta + (\mathbf{k} \times \mathbf{v})\sin\theta + \mathbf{k}\,(\mathbf{k}\cdot\mathbf{v})(1 - \cos\theta)

This is Rodrigues' formula: The rotation of a vector v by an angle θ about a unit axis k: R v = v cos θ + (k × v) sin θ + k (k · v)(1 − cos θ). The part of v along k stays; the part across it turns., named after Olinde Rodrigues’s paper on the displacements of a solid body. Its three terms are easier to see in two views. The side view splits v into v∥ = (k · v) k along the axis and v⊥ = v − v∥ across it; looking down the axis, v⊥ turns by θ, and the turned part is cos θ of v⊥ plus sin θ of k × v, which is v⊥ turned a quarter turn about k:

Two views of Rodrigues' formula: v split along the axis k, and the part across k turning by 120° as seen looking down k.Rodrigues' formula in two viewsk along (1, 1, 1), v = (1, 0, 0), θ = 120°side view: split v along kkv, length 1v⊥ = v − v∥, length 0.816v∥ = (k · v) klength 0.577looking down k: v⊥ turns by θv⊥k × vturnedθthe turned part:cos θ = −0.5of v⊥, plussin θ = 0.866of k × vk × v is v⊥ turned 90° about kR v = v∥ + v⊥ cos θ + (k × v) sin θHere R v = (0, 1, 0): x turns into y.

Rodrigues' formula for the axis along (1, 1, 1), v = (1, 0, 0) and θ = 120°

Text description of the diagram

The top panel is a side view. The axis k points straight up. The vector v, of length 1, leans 54.7° away from it. A dashed line runs from the tip of v across to the axis, and splits v into two parts: v∥ = (k · v) k along the axis, of length 0.577, and v⊥ = v − v∥ across it, of length 0.816. The part along the axis stays where it is; only the part across it turns.

The bottom panel looks down the axis, so the plane across k appears flat. A circle of radius 0.816 is centred on the axis. v⊥ points right; k × v, of the same length, points up, a quarter turn ahead of it. The turned vector points up and to the left at 120° from v⊥. Dotted lines drop from its tip to the two arrows: it is cos 120° = −0.5 of v⊥ plus sin 120° = 0.866 of k × v.

Under the panels: R v = v∥ + v⊥ cos θ + (k × v) sin θ, which here is (0, 1, 0), so the x axis turns into the y axis.

Regrouped, v cos θ + v∥(1 − cos θ) is v∥ + v⊥ cos θ, so the formula and the figure say the same thing. The two products it needs are on the figure below. Set a to the axis (1, 1, 1) and b to v = (1, 0, 0): the cross product (0, 1, −1) points where the tip of v starts to move, and divided by the axis’s length, √3, it is k × v = (0, 0.577, −0.577). Try other vectors: one along the axis has a cross product of zero and does not move at all.

Interactive · in 3D The axis (1, 1, 1) and the vector v = (1, 0, 0) Set the coordinates of a and b. The dot product measures how much they point the same way; the cross product is perpendicular to both, as long as the area of the parallelogram they span.
Vectors a = (1, 1, 1) and b = (1, 0, 0) on x, y and z axes, with a × b.xyzaba × b (half length)

a = (1, 1, 1), b = (1, 0, 0): a · b = 1, a × b = (0, 1, −1).

a · b 1 · aₓbₓ + a_yb_y + a_zb_z
Angle between a and b 54.7° · cos θ = a · b ÷ (|a| |b|)
a × b (0, 1, −1) · (a_yb_z − a_zb_y, a_zb_x − a_xb_z, a_xb_y − a_yb_x)
|a × b| (parallelogram area) 1.414
Projection of a on b 1.000 · a · b ÷ |b|

The program works the three terms for v = (1, 0, 0), builds the formula as one matrix, and compares it with the method many textbooks teach: turn the axis onto z, rotate about z, and turn it back.

Rodrigues against the turn-onto-z method, 1,000 axes JavaScript · axis/rodrigues.mjs
// Rotation about any axis through the origin: Rodrigues' formula, and the textbook method that
// turns the axis onto z, rotates about z and turns it back. 3 x 3 matrices as lists of rows.

const rad = (degrees) => (degrees * Math.PI) / 180;
const mul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const apply = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const transpose = (m) => [0, 1, 2].map((c) => m.map((row) => row[c]));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = ([ax, ay, az], [bx, by, bz]) => [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx];
const scale = (v, k) => v.map((x) => x * k);
const add = (...vs) => [0, 1, 2].map((i) => vs.reduce((s, v) => s + v[i], 0));
const unit = (v) => scale(v, 1 / Math.hypot(...v));
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const Rz = (a) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]];

// Rodrigues: v cos θ + (k × v) sin θ + k (k · v)(1 − cos θ), written as one matrix.
function rodrigues(axis, angle) {
  const [x, y, z] = unit(axis);
  const [c, s] = [Math.cos(angle), Math.sin(angle)];
  const t = 1 - c;
  return [
    [t * x * x + c, t * x * y - s * z, t * x * z + s * y],
    [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
    [t * x * z - s * y, t * y * z + s * x, t * z * z + c],
  ];
}

// The textbook way: A turns the axis onto +z (a turn about y, then one about x), then Rz, then A undone.
function viaZ(axis, angle) {
  const [x, y, z] = unit(axis);
  const A = mul(Rx(Math.atan2(y, Math.hypot(x, z))), Ry(-Math.atan2(x, z)));
  return mul(transpose(A), mul(Rz(angle), A));
}

const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;

const k = unit([1, 1, 1]);
const v = [1, 0, 0];
const theta = rad(120);
console.log('120° about (1, 1, 1), applied to v = (1, 0, 0):');
console.log(`  v cos θ               ${show(scale(v, Math.cos(theta)))}`);
console.log(`  (k × v) sin θ         ${show(scale(cross(k, v), Math.sin(theta)))}`);
console.log(`  k (k · v)(1 − cos θ)  ${show(scale(k, dot(k, v) * (1 - Math.cos(theta))))}`);
console.log(`  sum                   ${show(add(scale(v, Math.cos(theta)), scale(cross(k, v), Math.sin(theta)), scale(k, dot(k, v) * (1 - Math.cos(theta)))))}`);
const R = rodrigues([1, 1, 1], theta);
console.log(`the whole matrix sends x to ${show(apply(R, [1, 0, 0]))}, y to ${show(apply(R, [0, 1, 0]))} and z to ${show(apply(R, [0, 0, 1]))}`);
console.log(`the axis itself stays: ${show(apply(R, [1, 1, 1]))}`);

// 1,000 seeded axes and angles: both methods must give the same rotation matrix.
let seed = 7; // mulberry32
const random = () => {
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let agree = 0;
let orthonormal = 0;
for (let i = 0; i < 1000; i++) {
  const axis = [random() * 2 - 1, random() * 2 - 1, random() * 2 - 1];
  const angle = (random() * 2 - 1) * Math.PI;
  const a = rodrigues(axis, angle);
  const b = viaZ(axis, angle);
  if (a.every((row, r) => row.every((x, c) => Math.abs(x - b[r][c]) < 1e-12))) agree++;
  const I = mul(transpose(a), a);
  if (I.every((row, r) => row.every((x, c) => Math.abs(x - (r === c ? 1 : 0)) < 1e-12))) orthonormal++;
}
console.log('');
console.log(`1000 seeded axes and angles: Rodrigues and the turn-onto-z method agree within 1e-12 for ${agree}`);
console.log(`and R-transposed R is the identity within 1e-12 for ${orthonormal}`);

Output

120° about (1, 1, 1), applied to v = (1, 0, 0):
  v cos θ               (-0.5, 0, 0)
  (k × v) sin θ         (0, 0.5, -0.5)
  k (k · v)(1 − cos θ)  (0.5, 0.5, 0.5)
  sum                   (0, 1, 0)
the whole matrix sends x to (0, 1, 0), y to (0, 0, 1) and z to (1, 0, 0)
the axis itself stays: (1, 1, 1)

1000 seeded axes and angles: Rodrigues and the turn-onto-z method agree within 1e-12 for 1000
and R-transposed R is the identity within 1e-12 for 1000

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rodrigues.mjs

The three terms are (−0.5, 0, 0), (0, 0.5, −0.5) and (0.5, 0.5, 0.5), and they add up to (0, 1, 0): x has become y. The whole matrix also sends y to z and z to x, and it leaves the axis where it is. On 1,000 seeded axes and angles the two methods agree within 10⁻¹², and every matrix is orthonormal, its transpose its inverse, as a rotation must be. An axis that does not pass through the origin needs one more step: move a point p of the axis to the origin, turn, and move it back, T(p) R T(−p), the sandwich of the 2D lesson.

Rotation about an arbitrary axis in the syllabi

UGC NET’s Computer Science and Applications syllabus heads this material “3-D Object Representation, Geometric Transformations and Viewing” in Unit 3, and GTU’s BE05000161 lists 3D translation, rotation and scaling in its unit on 3D transformations and projection. Exam answers usually build the rotation about an arbitrary axis from basic rotations; Rodrigues’ formula gives the same matrix and is a quick way to check one.

Checkpoint

Check: Rotation about any axis

  1. Question 1 of 2 R turns by 120° about the axis through the origin along (1, 1, 1). What does it do to the point (2, 2, 2)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing; it stays at (2, 2, 2)

    In R v = v cos θ + (k × v) sin θ + k (k · v)(1 − cos θ), a vector along k has k × v = 0 and k (k · v) = v, so R v = v cos θ + v (1 − cos θ) = v.

  2. Question 2 of 2 A vector v is perpendicular to the unit axis k. Which term of Rodrigues' formula drops out?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: k (k · v)(1 − cos θ), because k · v = 0

    With k · v = 0 the formula becomes R v = v cos θ + (k × v) sin θ: a 2D rotation in the plane spanned by v and k × v, which is how the figure's lower panel draws it.

Euler angles need an order and a reading

The 30°, 45° and 60° of the opening are Euler angles: Three angles of turns about coordinate axes, applied in a stated order such as XYZ, that together describe an orientation. Without the order and its reading they are ambiguous.: three turns about coordinate axes, in a stated order. three.js’s Euler class defaults to the order 'XYZ', and its source builds that order as the product Rx Ry Rz. Read from the right, that product turns about the world’s z axis first, then the world’s y axis, then the world’s x axis: an extrinsic rotation: A sequence of turns, each about a fixed axis of the world. Extrinsic Z, then Y, then X gives the matrix Rx Ry Rz, the same as intrinsic X, Y, Z. reading. Read from the left, it turns about the object’s own x axis, then about its own y axis as that first turn left it, then about its own z axis: an intrinsic rotation: A sequence of turns, each about an axis of the object as the earlier turns left it. Intrinsic X, then Y, then Z gives the matrix Rx Ry Rz. reading. Step through both readings on the left wing tip:

Interactive · step by step One Euler order, two ways to read it, one matrix Step down both columns. Each row makes one turn and shows where the left wing tip goes; the bottom row is the same in both.
One Euler order, two ways to read it, one matrix, step 8 of 8. Showing tip (1.5, 0, −1), tip (1.5, 0, −1), (1.5, 0.5, −0.87), (0.75, 1.3, −1), (0.35, 0.88, −1.53), (−0.18, 1.3, −1.24), (−0.18, 1.74, −0.42), (−0.18, 1.74, −0.42). In focus: (−0.18, 1.74, −0.42), (−0.18, 1.74, −0.42).tip (1.5, 0, −1)own axes: X, Y, Ztip (1.5, 0, −1)world axes: Z, Y, X(1.5, 0.5, −0.87)30° about its own X(0.75, 1.3, −1)60° about world Z(0.35, 0.88, −1.53)45° about its new Y(−0.18, 1.3, −1.24)45° about world Y(−0.18, 1.74, −0.42)60° about its new Z(−0.18, 1.74, −0.42)30° about world X

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Two columns, one order. Both columns apply the Euler order XYZ with x = 30°, y = 45° and z = 60° to the left wing tip. The left column turns about the plane's own axes; the right one turns about the fixed world axes.
  2. X first, about its own axis. On the left, the plane turns 30° about its own x axis, which is still the world's x axis. The tip rises to (1.5, 0.5, −0.87).
  3. Then its new Y. The plane's y axis has tipped with it, and the second turn, 45°, is about that tipped axis. The tip moves to (0.35, 0.88, −1.53).
  4. Then its new Z. The last turn, 60°, is about the plane's own z axis, its nose line after both turns. The tip ends at (−0.18, 1.74, −0.42).
  5. Z first, about the world. On the right, the same angles turn about the world's axes, in the reverse order. First 60° about world z, which moves the tip to (0.75, 1.3, −1).
  6. Then world Y. Next 45° about the world's y axis, which has not moved. The tip is at (−0.18, 1.3, −1.24), nowhere near where the left column had it after two turns.
  7. Then world X. Last, 30° about the world's x axis. The tip lands at (−0.18, 1.74, −0.42), exactly where the left column put it.
  8. One matrix. Both columns built the matrix Rx Ry Rz. The steps on the way differ, the result does not, so an order name such as XYZ still has to say which reading it means.

The two columns pass through different positions and finish at the same (−0.18, 1.74, −0.42), because both are the same matrix. The program checks that, and measures what changes when the order itself changes:

One set of angles, two orders, two readings JavaScript · order/euler_orders.mjs
// The same three Euler angles in two axis orders, and two ways to read one order: turning about the
// object's own axes (intrinsic) or about the fixed world axes (extrinsic). 3 x 3 matrices as rows.
// An order is named the way three.js names it: 'XYZ' is the matrix Rx Ry Rz.

const rad = (degrees) => (degrees * Math.PI) / 180;
const mul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const apply = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const Rz = (a) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]];
const I = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
function about([x, y, z], a) {
  // Rodrigues' formula, for a unit axis.
  const [c, s, t] = [Math.cos(a), Math.sin(a), 1 - Math.cos(a)];
  return [
    [t * x * x + c, t * x * y - s * z, t * x * z + s * y],
    [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
    [t * x * z - s * y, t * y * z + s * x, t * z * z + c],
  ];
}
const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
const BASIC = { X: Rx, Y: Ry, Z: Rz };

// The matrix of an order: the basic rotations multiplied left to right as the order is written.
const euler = (order, angles) => [...order].reduce((m, axis) => mul(m, BASIC[axis](angles[axis])), I);

// Intrinsic: each turn is about the object's own axis as it is now, M e, and acts after the turns so far.
function intrinsic(order, angles) {
  let M = I;
  for (const axis of order) M = mul(about(apply(M, AXIS[axis]), angles[axis]), M);
  return M;
}
// Extrinsic: each turn is about a fixed world axis.
function extrinsic(order, angles) {
  let M = I;
  for (const axis of order) M = mul(BASIC[axis](angles[axis]), M);
  return M;
}

const same = (a, b) => a.every((row, r) => row.every((x, c) => Math.abs(x - b[r][c]) < 1e-12));
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const show = (v) => `(${v.map(num).join(', ')})`;
const angleBetween = (a, b) => (Math.acos(Math.min(1, Math.max(-1, a.reduce((s, x, i) => s + x * b[i], 0) / (Math.hypot(...a) * Math.hypot(...b))))) * 180) / Math.PI;

const angles = { X: rad(30), Y: rad(45), Z: rad(60) };
const nose = [0, 0, 2];
const xyz = euler('XYZ', angles);
const zyx = euler('ZYX', angles);
console.log('x = 30°, y = 45°, z = 60°');
console.log(`  order XYZ, the matrix Rx Ry Rz, puts the nose at ${show(apply(xyz, nose))}`);
console.log(`  order ZYX, the matrix Rz Ry Rx, puts the nose at ${show(apply(zyx, nose))}`);
console.log(`  the two noses are ${num(angleBetween(apply(xyz, nose), apply(zyx, nose)))}° apart`);
console.log('');
console.log(`turning about the plane's own axes X, then Y, then Z gives Rx Ry Rz: ${same(intrinsic('XYZ', angles), xyz)}`);
console.log(`turning about the world's axes Z, then Y, then X gives Rx Ry Rz: ${same(extrinsic('ZYX', angles), xyz)}`);
console.log(`turning about the world's axes X, then Y, then Z gives Rx Ry Rz: ${same(extrinsic('XYZ', angles), xyz)}`);
console.log(`turning about the world's axes X, then Y, then Z gives Rz Ry Rx: ${same(extrinsic('XYZ', angles), zyx)}`);

Output

x = 30°, y = 45°, z = 60°
  order XYZ, the matrix Rx Ry Rz, puts the nose at (1.414, -0.707, 1.225)
  order ZYX, the matrix Rz Ry Rx, puts the nose at (1.478, 0.561, 1.225)
  the two noses are 37.005° apart

turning about the plane's own axes X, then Y, then Z gives Rx Ry Rz: true
turning about the world's axes Z, then Y, then X gives Rx Ry Rz: true
turning about the world's axes X, then Y, then Z gives Rx Ry Rz: false
turning about the world's axes X, then Y, then Z gives Rz Ry Rx: true

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node euler_orders.mjs

With the order XYZ the nose lands at (1.414, −0.707, 1.225), with ZYX at (1.478, 0.561, 1.225), and the two noses are 37.005° apart. Turning about the plane’s own axes X, Y, Z and about the world’s axes Z, Y, X both give Rx Ry Rz, while turning about the world’s axes X, Y, Z gives Rz Ry Rx instead. Every sequence of three axes in which no axis follows itself is a valid order: 3 × 2 × 2 = 12 of them, six like XYZ that use all three axes and six like ZXZ that repeat the first, and each can be read either way. Two tools that both say “Euler angles in degrees” can mean different rotations.

Copying angles between tools without the order

Angles exported as x 30°, y 45°, z 60° from a tool that applies them in the order ZYX, and imported into a scene that uses XYZ, point the nose 37° away from where it was. Carry the order and the reading with the angles, or move orientations between tools as matrices or quaternions, which mean one rotation only.

Checkpoint

Check: Euler orders and their two readings

  1. Question 1 of 2 Which matrix does three.js build for the Euler order 'XYZ'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Rx Ry Rz

    three.js's Matrix4.makeRotationFromEuler builds 'XYZ' as Rx Ry Rz. Read as turns about the object's own axes it is X, then the new Y, then the new Z; read as turns about the world's axes it is Z, then Y, then X.

  2. Question 2 of 2 An editor turns objects about its fixed world axes, first about Z, then Y, then X. Which three.js order gives the same matrix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 'XYZ'

    Turns about fixed axes in the order Z, Y, X give Rx Ry Rz; turns about the object's own axes in the order X, Y, Z give the same product. The program printed true for both.

Reading the angles back from a matrix

An editor that shows a rotation as three angles has to read them out of a matrix. For the order XYZ, multiplying Rx Ry Rz out puts sin y alone in row 0, column 2, so the middle angle comes first, and the other two come from pairs of entries:

y=arcsin⁡m02,x=atan2⁡(−m12, m22),z=atan2⁡(−m01, m00)y = \arcsin m_{02}, \qquad x = \operatorname{atan2}(-m_{12},\ m_{22}), \qquad z = \operatorname{atan2}(-m_{01},\ m_{00})

In words: the middle angle is the arcsine of one entry, and each outer angle is the direction of a pair of entries, a cosine-weighted sine and cosine. atan2 looks at the signs of both numbers it is given, so it returns an angle in the right quadrant, from −180° to 180°, where an arctangent of their ratio could not. asin, by the ECMAScript standard, only returns angles from −90° to 90°. Step through two matrices, one ordinary and one locked:

Interactive · step by step Reading XYZ angles back from a matrix Step through two matrices. The first has an ordinary middle angle; the second sits exactly at gimbal lock and takes the branch on the right.
Reading XYZ angles back from a matrix, step 7 of 7. Showing y = asin(m02) (sin y = 1: y = 90°), |m02| < 0.9999999?, x and z by atan2 (x = −150°, z = −135°), Locked: z = 0 (x = 55°, z = 0), Three angles ((55°, 90°, 0°)). In focus: Three angles.yesnoy = asin(m02)sin y = 1: y = 90°|m02| < 0.9999999?is y near ±90°?x and z by atan2x = −150°, z = −135°Locked: z = 0x = 55°, z = 0Three angles(55°, 90°, 0°)same matrix

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. An ordinary matrix. Take the matrix of (30°, 120°, 45°) in the order XYZ. Its entry in row 0 and column 2 is sin y, so the middle angle comes first: asin gives 60°, not 120°, because asin only returns angles from −90° to 90°.
  2. Not near 90°. The entry is 0.866, well below 0.9999999, so the other two angles can be read from pairs of entries without dividing by a cosine close to zero.
  3. The outer angles. atan2 of the negated entry (1, 2) and the entry (2, 2) gives x = −150°; atan2 of the negated entry (0, 1) and the entry (0, 0) gives z = −135°. atan2 sees both signs, so it picks the right quadrant.
  4. A different triple. The reader returns (−150°, 60°, −135°). Multiplied back, it gives the same matrix as (30°, 120°, 45°) did: two triples, one rotation.
  5. A locked matrix. Now take (20°, 90°, 35°). The entry (0, 2) is exactly 1, so y = 90°, and the matrix only fixes x + z = 55°: (0°, 90°, 55°) and (55°, 90°, 0°) give the same matrix.
  6. The branch. The test fails, so the reader takes the branch three.js takes. It sets z = 0 and reads x = atan2(m21, m11) = 55°.
  7. Locked, but correct. (55°, 90°, 0°) multiplies back to the same matrix. The matrix lost nothing; what is lost at gimbal lock is any way to tell x and z apart.

The first matrix came from (30°, 120°, 45°) and reads back as (−150°, 60°, −135°): a different triple for the same rotation, because asin cannot return 120°. The second came from (20°, 90°, 35°), where the entry is exactly 1. There the formula for x and z would divide zeros by zeros, so three.js tests whether the entry’s size is below 0.9999999 and, when it is not, sets the last angle to 0 and reads the first from another pair. The program runs both readers on 1,000 seeded triples:

Euler angles to a matrix and back, XYZ and YXZ JavaScript · convert/matrix_to_euler.mjs
// Euler angles to a matrix and back, for the orders XYZ (Rx Ry Rz) and YXZ (Ry Rx Rz), with the
// formulas three.js uses (Euler.setFromRotationMatrix). m[r][c] is row r, column c of a 3 x 3 matrix.

const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
const mul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const Rz = (a) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]];
const clamp = (v) => Math.max(-1, Math.min(1, v));

function eulerToMatrix(order, [x, y, z]) {
  if (order === 'XYZ') return mul(Rx(x), mul(Ry(y), Rz(z)));
  if (order === 'YXZ') return mul(Ry(y), mul(Rx(x), Rz(z)));
  throw new Error(`order ${order} is not written here`);
}

// The middle angle comes from one entry; the other two from atan2 of a pair, unless the middle
// angle is ±90°: then only one combination of the first and last angles is fixed, and z is set to 0.
function matrixToEuler(order, m) {
  if (order === 'XYZ') {
    const y = Math.asin(clamp(m[0][2]));
    if (Math.abs(m[0][2]) < 0.9999999) return [Math.atan2(-m[1][2], m[2][2]), y, Math.atan2(-m[0][1], m[0][0])];
    return [Math.atan2(m[2][1], m[1][1]), y, 0];
  }
  if (order === 'YXZ') {
    const x = Math.asin(-clamp(m[1][2]));
    if (Math.abs(m[1][2]) < 0.9999999) return [x, Math.atan2(m[0][2], m[2][2]), Math.atan2(m[1][0], m[1][1])];
    return [x, Math.atan2(-m[2][0], m[0][0]), 0];
  }
  throw new Error(`order ${order} is not written here`);
}

const sameMatrix = (a, b) => a.every((row, r) => row.every((v, c) => Math.abs(v - b[r][c]) < 1e-12));
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const angles = (a) => `(${a.map((v) => `${num(deg(v))}°`).join(', ')})`;

let seed = 11; // mulberry32
const random = () => {
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (const order of ['XYZ', 'YXZ']) {
  const middle = order === 'XYZ' ? 1 : 0;
  let back = 0;
  for (let i = 0; i < 1000; i++) {
    const a = [0, 1, 2].map((k) => (k === middle ? rad(random() * 178 - 89) : rad(random() * 358 - 179)));
    const b = matrixToEuler(order, eulerToMatrix(order, a));
    if (a.every((v, k) => Math.abs(v - b[k]) < 1e-9)) back++;
  }
  console.log(`${order}: 1000 seeded triples, middle angle within ±89°, read back the same: ${back}`);
}

console.log('');
const wide = [rad(30), rad(120), rad(45)];
const read = matrixToEuler('XYZ', eulerToMatrix('XYZ', wide));
console.log(`XYZ ${angles(wide)} reads back as ${angles(read)}`);
console.log(`  the same rotation: ${sameMatrix(eulerToMatrix('XYZ', wide), eulerToMatrix('XYZ', read))}`);
const locked = [rad(20), rad(90), rad(35)];
const readLocked = matrixToEuler('XYZ', eulerToMatrix('XYZ', locked));
console.log(`XYZ ${angles(locked)} reads back as ${angles(readLocked)}`);
console.log(`  the same rotation: ${sameMatrix(eulerToMatrix('XYZ', locked), eulerToMatrix('XYZ', readLocked))}`);
const yxz = [rad(30), rad(45), rad(60)];
console.log(`YXZ ${angles(yxz)} read back as XYZ: ${angles(matrixToEuler('XYZ', eulerToMatrix('YXZ', yxz)))}`);

Output

XYZ: 1000 seeded triples, middle angle within ±89°, read back the same: 1000
YXZ: 1000 seeded triples, middle angle within ±89°, read back the same: 1000

XYZ (30°, 120°, 45°) reads back as (-150°, 60°, -135°)
  the same rotation: true
XYZ (20°, 90°, 35°) reads back as (55°, 90°, 0°)
  the same rotation: true
YXZ (30°, 45°, 60°) read back as XYZ: (39.232°, 37.761°, 33.435°)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node matrix_to_euler.mjs

Both orders read back all 1,000 triples whose middle angle stays within ±89°, and both odd cases give back the same matrix they started from. The last line reads a YXZ rotation as XYZ: (30°, 45°, 60°) becomes (39.232°, 37.761°, 33.435°), three new numbers for one rotation, which is why an editor that switches the order of an object rewrites its angles.

Checkpoint

Check: Reading Euler angles from a matrix

  1. Question 1 of 2 A rotation was built from (30°, 120°, 45°) in the order XYZ. Why does the reader return y = 60°?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The reader takes y from asin of one entry, and asin returns angles from −90° to 90° only

    The entry m02 is sin y. Many angles share a sine, and asin returns the one from −90° to 90°, so the reader gives (−150°, 60°, −135°), which multiplies back to the same matrix.

  2. Question 2 of 2 Why does the reader use atan2 for the outer angles rather than an arctangent of a ratio?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: atan2 sees the signs of both entries, so it returns the angle in the right quadrant, from −180° to 180°

    For XYZ, x = atan2(−m12, m22) and z = atan2(−m01, m00). Each pair is a cosine-weighted sine and cosine of the angle, and atan2 recovers the angle with its quadrant.

Gimbal lock: at 90° of pitch the first and last axes line up

A first-person camera is usually stored as yaw, pitch and roll in the order YXZ, the order three.js’s PointerLockControls uses: the matrix Ry(yaw) Rx(pitch) Rz(roll), with the camera looking along its own −z. Tip the camera to look straight up, pitch 90°, and something strange happens: Rx(90°) turns the camera’s own z axis onto the world’s y axis, so

Ry(a)Rx(90∘)Rz(c)=Ry(a−c)Rx(90∘)R_y(a)\,R_x(90^\circ)\,R_z(c) = R_y(a - c)\,R_x(90^\circ)

In words: once the pitch is 90°, yaw and roll turn about the same line, and only their difference survives. This is gimbal lock: The loss of one degree of freedom in Euler angles when the middle turn reaches ±90°: the first and last axes line up, and only a combination of the two outer angles is left., named after the nested rings, or gimbals, that physical instruments turn on. The figure draws each angle’s circle across the axis it turns about, at pitch 0° and at pitch 90°:

A camera's yaw, pitch and roll circles: at pitch 0° their three axes differ; at pitch 90° roll and yaw share one axis.Three axes, until the pitch reaches 90°Each circle is drawn across the axis it turns about.pitch 0°: three directionsblack: yaw,about Ydashed grey:pitch, about Xblue: roll, aboutthe view axispitch 90°: roll axis on yaw axisthe blue circlenow lies in theblack one: yawand roll turnabout one line;one turn is lostOrder YXZ: yaw, then pitch, then roll.

Yaw, pitch and roll of a YXZ camera at pitch 0° and pitch 90°

Text description of the diagram

Each angle is drawn as a circle across the axis it turns about, the way 3D editors draw their rotate handles: a black circle lying flat for yaw, about the vertical y axis; a dashed grey circle standing upright for pitch, about the x axis; and a blue circle for roll, about the camera's view axis.

In the top panel, at pitch 0°, the three circles stand in three perpendicular planes, and their axes point in three different directions: up, along x, and along the view.

In the bottom panel, at pitch 90°, the pitch has tipped the blue roll circle until it lies flat inside the black yaw circle. The roll axis now lies on the vertical yaw axis, so yaw and roll turn about the same line. Three angles are left with only two independent directions to turn in, which is gimbal lock.

The program tests the formula and measures what a 10° turn of yaw does as the camera tips towards the vertical:

Gimbal lock on a YXZ camera JavaScript · gimbal/gimbal.mjs
// Gimbal lock on a first-person camera stored as yaw, pitch and roll in the order YXZ: the matrix
// Ry(yaw) Rx(pitch) Rz(roll), the order three.js's PointerLockControls uses. The camera looks along
// its own -Z, so a positive pitch tips the view up. 3 x 3 matrices as lists of rows.

const rad = (degrees) => (degrees * Math.PI) / 180;
const deg = (radians) => (radians * 180) / Math.PI;
const mul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const apply = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const Rz = (a) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]];
const camera = (yaw, pitch, roll) => mul(Ry(rad(yaw)), mul(Rx(rad(pitch)), Rz(rad(roll))));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = ([ax, ay, az], [bx, by, bz]) => [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx];
const angle = (a, b) => deg(Math.acos(Math.max(-1, Math.min(1, dot(a, b) / Math.sqrt(dot(a, a) * dot(b, b))))));
const same = (a, b) => a.every((row, r) => row.every((v, c) => Math.abs(v - b[r][c]) < 1e-12));
const num = (v) => {
  const r = Number(v.toFixed(3));
  return Object.is(r, -0) ? '0' : String(r);
};
const cell = (text, width) => text.padStart(width);

console.log('pitch 90°:');
console.log(`  yaw 30°, roll 10° and yaw 50°, roll 30° give the same matrix: ${same(camera(30, 90, 10), camera(50, 90, 30))}`);
console.log(`  yaw 30°, roll 10° and yaw 40°, roll 10° give the same matrix: ${same(camera(30, 90, 10), camera(40, 90, 10))}`);
console.log('  only yaw minus roll matters: three angles, two degrees of freedom');

// The axes the three angles turn about, in world space: yaw about Y, pitch about the camera's x
// after the yaw, roll about its z after the yaw and the pitch. Volume: |det| of the three unit axes.
console.log('');
console.log('pitch  axes apart  volume  a 10° yaw: view moves  picture spins');
const yaw = 0;
for (const pitch of [0, 45, 80, 89, 90]) {
  const yawAxis = [0, 1, 0];
  const pitchAxis = apply(Ry(rad(yaw)), [1, 0, 0]);
  const rollAxis = apply(mul(Ry(rad(yaw)), Rx(rad(pitch))), [0, 0, 1]);
  const volume = Math.abs(dot(yawAxis, cross(pitchAxis, rollAxis)));
  const view = apply(camera(yaw, pitch, 0), [0, 0, -1]);
  const moved = angle(view, apply(camera(yaw + 10, pitch, 0), [0, 0, -1]));
  // The yaw is a turn about the world's Y; the part of it about the view direction spins the picture.
  const half = rad(10) / 2;
  const spin = deg(2 * Math.atan2(Math.sin(half) * dot(yawAxis, view), Math.cos(half)));
  console.log(`${cell(`${pitch}°`, 5)}${cell(`${num(angle(yawAxis, rollAxis))}°`, 12)}${cell(num(volume), 8)}${cell(`${num(moved)}°`, 23)}${cell(`${num(spin)}°`, 15)}`);
}

Output

pitch 90°:
  yaw 30°, roll 10° and yaw 50°, roll 30° give the same matrix: true
  yaw 30°, roll 10° and yaw 40°, roll 10° give the same matrix: false
  only yaw minus roll matters: three angles, two degrees of freedom

pitch  axes apart  volume  a 10° yaw: view moves  picture spins
   0°         90°       1                    10°             0°
  45°        135°   0.707                 7.067°          7.08°
  80°        170°   0.174                 1.734°         9.849°
  89°        179°   0.017                 0.174°         9.998°
  90°        180°       0                     0°            10°

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node gimbal.mjs

At pitch 90°, yaw 30° with roll 10° and yaw 50° with roll 30° give the same matrix: three angles hold only two degrees of freedom. The volume spanned by the three axes, 1 when they are perpendicular, falls to 0.707 at 45°, 0.017 at 89° and 0 at 90°. A 10° yaw moves the view by 10° when the camera looks level, by 0.174° at 89°, and not at all at 90°; at 90° it spins the picture 10° about the view direction instead. The rotation itself is fine: what fails is the three-number description near one pitch. PointerLockControls keeps the pitch between −90° and 90° by default, and interpolating the three angles one by one bends the path an orientation takes, which the next lesson measures.

Key idea

Euler angles are three turns in a stated order; when the middle turn reaches ±90°, the first and last turn about the same line, one degree of freedom is gone, and only a combination of the two outer angles is left.

Checkpoint

Check: Gimbal lock

  1. Question 1 of 2 A YXZ camera has pitch 90°. Which change of yaw and roll leaves its matrix the same as yaw 30°, roll 10°?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Yaw 50°, roll 30°

    Rx(90°) turns the camera's own z axis onto the world's y axis, so Ry(a) Rx(90°) Rz(c) = Ry(a − c) Rx(90°): only the difference of yaw and roll is left.

  2. Question 2 of 2 The camera looks straight up, pitch 90°, and the player turns the yaw by 10°. By how many degrees does the view direction move?

    Type a number.

    A hint

    Look at the last row of the program's table.

    Show the answer: Question 2 of 2

    Answer: 0 degrees (anything from -0.01 to 0.01 counts)

    The view direction lies on the yaw axis, so a turn about that axis leaves it where it is. The 10° go into spinning the picture about the view direction instead, which the table printed as 0° and 10°.

Practise: turn about any axis, then convert Euler angles

Two exercises build the rotation tools of this lesson, and their sample tests run in the page. The first turns about any axis and about any line with Rodrigues’ formula; the second multiplies Euler angles into a matrix in the orders XYZ and YXZ and reads them back, gimbal lock included:

Code lab · 2 steps

Build a rotation toolkit

Turn about any axis and any line with Rodrigues' formula, then convert Euler angles to matrices and back in two orders, gimbal lock included.

  1. Rotate about any axis and about any line To do
  2. Euler angles to a matrix and back, in two orders To do

Exercise · Medium · JavaScript

Rotate about any axis and about any line

A matrix here is an array of 16 numbers in column-major order, as WGSL and glTF store it: the entry in row r and column c is at index c * 4 + r, and matrices act on column vectors. Write, in axis.mjs:

  • rotationAxis(axis, angle): the 4 × 4 rotation by angle radians about the line through the origin along axis, counter-clockwise when the axis points at you. The axis may have any length except 0, so normalise it first.
  • rotationAboutLine(point, axis, angle): the same turn about the line through point, which stays where it is.

The starter builds Rodrigues' matrix from an axis it never normalises, and it puts the sine terms on the wrong side of the diagonal, which turns every point the wrong way. The sample tests use the lesson's case, 120° about (1, 1, 1), which sends x to y, an axis of length 5, the three basic rotations, a line through (1, 0, 0), and 300 seeded axes whose matrices must be rotations that keep their axis fixed.

Starter code · axis.mjs

/**
 * rotationAxis(axis, angle): the 4 × 4 rotation by `angle` radians about the line through the origin along `axis`,
 * column-major (entry (r, c) at c * 4 + r). rotationAboutLine(point, axis, angle): the same turn about the line
 * through `point`. This first version never normalises the axis, and its sine terms sit on the wrong side of the
 * diagonal, so it turns the wrong way.
 */
export function rotationAxis(axis, angle) {
  const [x, y, z] = axis;
  const c = Math.cos(angle);
  const s = Math.sin(angle);
  const t = 1 - c;
  const rows = [
    [t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0],
    [t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0],
    [t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0],
    [0, 0, 0, 1],
  ];
  const m = new Array(16);
  for (let r = 0; r < 4; r++) for (let col = 0; col < 4; col++) m[col * 4 + r] = rows[r][col];
  return m;
}

export function rotationAboutLine(point, axis, angle) {
  return rotationAxis(axis, angle);
}
The sample tests · axis.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { rotationAboutLine, rotationAxis } from './axis.mjs';

const at = (m, r, c) => m[c * 4 + r];
const point = (m, [x, y, z]) => [0, 1, 2].map((r) => at(m, r, 0) * x + at(m, r, 1) * y + at(m, r, 2) * z + at(m, r, 3));
function near(got, want, label, eps = 1e-9) {
  const ok = got.length === want.length && got.every((v, i) => Math.abs(v - want[i]) < eps);
  assert.ok(ok, `${label}: got (${got.map((v) => v.toFixed(4)).join(', ')}), expected (${want.map((v) => Number(v.toFixed(4)) + 0).join(', ')})`);
}
const rad = (d) => (d * Math.PI) / 180;

test("120° about (1, 1, 1) sends x to y, y to z and z to x", () => {
  const R = rotationAxis([1, 1, 1], rad(120));
  near(point(R, [1, 0, 0]), [0, 1, 0], 'x');
  near(point(R, [0, 1, 0]), [0, 0, 1], 'y');
  near(point(R, [0, 0, 1]), [1, 0, 0], 'z');
  near(point(R, [2, 2, 2]), [2, 2, 2], 'a point on the axis stays');
});

test('the length of the axis does not matter', () => {
  near(point(rotationAxis([0, 0, 5], rad(90)), [1, 0, 0]), [0, 1, 0], '90° about (0, 0, 5) turns x into y');
  near(point(rotationAxis([0, -2, 0], rad(90)), [1, 0, 0]), [0, 0, 1], '90° about (0, -2, 0) turns x into z');
});

test('about the coordinate axes it is Rx, Ry and Rz', () => {
  const a = 0.3;
  const [c, s] = [Math.cos(a), Math.sin(a)];
  near(point(rotationAxis([1, 0, 0], a), [0, 1, 0]), [0, c, s], 'Rx turns y towards z');
  near(point(rotationAxis([0, 1, 0], a), [0, 0, 1]), [s, 0, c], 'Ry turns z towards x');
  near(point(rotationAxis([0, 0, 1], a), [1, 0, 0]), [c, s, 0], 'Rz turns x towards y');
});

test('a turn about the line through (1, 0, 0) along z', () => {
  const R = rotationAboutLine([1, 0, 0], [0, 0, 1], rad(90));
  near(point(R, [2, 0, 0]), [1, 1, 0], '(2, 0, 0) turns a quarter circle round (1, 0, 0)');
  near(point(R, [1, 0, 7]), [1, 0, 7], 'points of the line stay');
  near(point(R, [0, 0, 0]), [1, -1, 0], 'the origin moves');
  near([at(R, 3, 0), at(R, 3, 1), at(R, 3, 2), at(R, 3, 3)], [0, 0, 0, 1], 'the bottom row');
});

test('300 seeded axes give rotations that keep their axis', () => {
  let seed = 5; // mulberry32, so every run tests the same axes
  const random = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  for (let n = 0; n < 300; n++) {
    const axis = [random() * 4 - 2, random() * 4 - 2, random() * 4 - 2];
    const angle = (random() * 2 - 1) * Math.PI;
    const R = rotationAxis(axis, angle);
    near(point(R, axis), axis, `case ${n + 1}: the axis stays`);
    const cols = [0, 1, 2].map((c) => [0, 1, 2].map((r) => at(R, r, c)));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) assert.ok(Math.abs(dot(cols[i], cols[j]) - (i === j ? 1 : 0)) < 1e-9, `case ${n + 1}: the columns are not orthonormal`);
    const cross = [cols[0][1] * cols[1][2] - cols[0][2] * cols[1][1], cols[0][2] * cols[1][0] - cols[0][0] * cols[1][2], cols[0][0] * cols[1][1] - cols[0][1] * cols[1][0]];
    near(cross, cols[2], `case ${n + 1}: x × y must be z (a rotation, not a mirror)`);
    // Turning by the angle and back again gives the identity.
    const back = rotationAxis(axis, -angle);
    near(point(back, point(R, [1, 2, 3])), [1, 2, 3], `case ${n + 1}: turning back`);
    // The turn is counter-clockwise seen from the tip of the axis: (k × v) · R v has the sign of sin(angle).
    const k = axis.map((v) => v / Math.hypot(...axis));
    const v = Math.abs(k[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
    const Rv = point(R, v);
    const kv = [k[1] * v[2] - k[2] * v[1], k[2] * v[0] - k[0] * v[2], k[0] * v[1] - k[1] * v[0]];
    if (Math.abs(Math.sin(angle)) > 0.05) assert.ok(Math.sign(dot(kv, Rv)) === Math.sign(Math.sin(angle)), `case ${n + 1}: the turn goes the wrong way`);
  }
});
A hint

Divide the axis by its length before anything else. In Rodrigues' matrix the entry in row 0 and column 1 is t x y − s z and the one in row 1 and column 0 is t x y + s z, with c = cos θ, s = sin θ and t = 1 − c: the minus sign sits above the diagonal. For a line through p, move p to the origin, turn, and move it back: T(p) R T(−p).

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

Euler angles to a matrix and back, in two orders

Angles are in radians and matrices are 16 numbers in column-major order (entry (r, c) at c * 4 + r). An order is named as three.js names it: 'XYZ' is the matrix Rx Ry Rz and 'YXZ' is Ry Rx Rz. Write, in euler.mjs:

  • eulerToMatrix(order, x, y, z): the 4 × 4 rotation of the three angles in that order.
  • matrixToEuler(order, m): the angles [x, y, z] of a rotation matrix. The middle angle of the order comes from one entry with asin, the other two from atan2 of a pair of entries. When the absolute value of that one entry is 0.9999999 or more, the middle angle is ±90°, only one combination of the other two is fixed, and the last angle of the order is set to 0.

The starter multiplies 'XYZ' the wrong way round, reads its matrix as if it were row-major, and has no answer for gimbal lock. The sample tests check matrices against values three.js produces, round trips in both orders, the lesson's (30°, 120°, 45°), which reads back as (−150°, 60°, −135°), and the locked cases of both orders.

Starter code · euler.mjs

/**
 * eulerToMatrix(order, x, y, z) builds the rotation of three Euler angles (radians) as a column-major 4 × 4 matrix:
 * 'XYZ' is Rx Ry Rz and 'YXZ' is Ry Rx Rz. matrixToEuler(order, m) reads the angles back. This first version
 * multiplies 'XYZ' as Rz Ry Rx, reads m as if it were row-major, and has no case for a middle angle of ±90°.
 */
function fromRows(rows) {
  const m = new Array(16).fill(0);
  m[15] = 1;
  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) m[c * 4 + r] = rows[r][c];
  return m;
}
const mul = (a, b) => a.map((row) => [0, 1, 2].map((c) => row[0] * b[0][c] + row[1] * b[1][c] + row[2] * b[2][c]));
const Rx = (a) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]];
const Ry = (a) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]];
const Rz = (a) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]];

export function eulerToMatrix(order, x, y, z) {
  if (order === 'XYZ') return fromRows(mul(Rz(z), mul(Ry(y), Rx(x))));
  if (order === 'YXZ') return fromRows(mul(Ry(y), mul(Rx(x), Rz(z))));
  throw new Error(`unknown order ${order}`);
}

export function matrixToEuler(order, m) {
  const at = (r, c) => m[r * 4 + c];
  if (order === 'XYZ') return [Math.atan2(-at(1, 2), at(2, 2)), Math.asin(at(0, 2)), Math.atan2(-at(0, 1), at(0, 0))];
  if (order === 'YXZ') return [Math.asin(-at(1, 2)), Math.atan2(at(0, 2), at(2, 2)), Math.atan2(at(1, 0), at(1, 1))];
  throw new Error(`unknown order ${order}`);
}
The sample tests · euler.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { eulerToMatrix, matrixToEuler } from './euler.mjs';

const rad = (d) => (d * Math.PI) / 180;
const deg = (r) => (r * 180) / Math.PI;
function near(got, want, label, eps = 1e-9) {
  const ok = got.length === want.length && got.every((v, i) => Math.abs(v - want[i]) < eps);
  assert.ok(ok, `${label}: got [${got.map((v) => Number(v.toFixed(4)) + 0).join(', ')}], expected [${want.map((v) => Number(v.toFixed(4)) + 0).join(', ')}]`);
}
const inDegrees = (angles) => angles.map(deg);

test('matrices match three.js for (0.5, -0.3, 1.2) in both orders', () => {
  // new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(0.5, -0.3, 1.2, order)).elements, rounded to 12 places
  near(eulerToMatrix('XYZ', 0.5, -0.3, 1.2), [0.346173584969, 0.766602426017, 0.540818425624, 0, -0.890410948116, 0.45005008291, -0.067994605288, 0, -0.295520206661, -0.458012710847, 0.838386643594, 0, 0, 0, 0, 1], 'XYZ');
  near(eulerToMatrix('YXZ', 0.5, -0.3, 1.2), [0.214122348554, 0.817941248845, 0.533969786868, 0, -0.941749770944, 0.317998846494, -0.109471925877, 0, -0.259343380052, -0.479425538604, 0.838386643594, 0, 0, 0, 0, 1], 'YXZ');
});

test('the angles read back from both orders', () => {
  near(matrixToEuler('XYZ', eulerToMatrix('XYZ', 0.5, -0.3, 1.2)), [0.5, -0.3, 1.2], 'XYZ');
  near(matrixToEuler('YXZ', eulerToMatrix('YXZ', 0.5, -0.3, 1.2)), [0.5, -0.3, 1.2], 'YXZ');
  near(inDegrees(matrixToEuler('XYZ', eulerToMatrix('YXZ', rad(30), rad(45), rad(60)))), [39.23152, 37.761244, 33.434949], 'YXZ (30°, 45°, 60°) read as XYZ', 1e-4);
});

test("(30°, 120°, 45°) in XYZ reads back as (-150°, 60°, -135°), the same rotation", () => {
  const m = eulerToMatrix('XYZ', rad(30), rad(120), rad(45));
  const back = matrixToEuler('XYZ', m);
  near(inDegrees(back), [-150, 60, -135], 'the angles');
  near(eulerToMatrix('XYZ', ...back), m, 'the matrix of the angles read back');
});

test('gimbal lock: the last angle is 0 and the first takes the rest', () => {
  near(inDegrees(matrixToEuler('XYZ', eulerToMatrix('XYZ', rad(20), rad(90), rad(35)))), [55, 90, 0], 'XYZ with y = 90°');
  near(inDegrees(matrixToEuler('XYZ', eulerToMatrix('XYZ', rad(20), rad(-90), rad(35)))), [-15, -90, 0], 'XYZ with y = -90°');
  near(inDegrees(matrixToEuler('YXZ', eulerToMatrix('YXZ', rad(90), rad(30), rad(10)))), [90, 20, 0], 'YXZ with x = 90°');
  const locked = eulerToMatrix('YXZ', rad(90), rad(30), rad(10));
  near(eulerToMatrix('YXZ', ...matrixToEuler('YXZ', locked)), locked, 'YXZ: the same rotation');
});

test('round trips on 500 seeded triples in both orders', () => {
  let seed = 9; // mulberry32
  const random = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  for (let n = 0; n < 500; n++) {
    const outer = () => rad(random() * 358 - 179);
    const middle = () => rad(random() * 178 - 89);
    const a = [outer(), middle(), outer()];
    near(matrixToEuler('XYZ', eulerToMatrix('XYZ', ...a)), a, `XYZ case ${n + 1}`);
    const b = [middle(), outer(), outer()];
    near(matrixToEuler('YXZ', eulerToMatrix('YXZ', ...b)), b, `YXZ case ${n + 1}`);
  }
});
A hint

Write at(m, r, c) for m[c * 4 + r] and read every entry through it. For 'XYZ' the entry (0, 2) is sin y, so y = asin(at(m, 0, 2)), x = atan2(−at(m, 1, 2), at(m, 2, 2)) and z = atan2(−at(m, 0, 1), at(m, 0, 0)). For 'YXZ' the entry (1, 2) is −sin x. Clamp the entry to [−1, 1] before asin: rounding can push it to 1.0000000000000002.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Matrix Calculator Multiply Rx(30°), Ry(45°) and Rz(60°) in both orders and compare the two products' third columns with the noses the program printed. Trigonometry Calculator Check the sines and cosines behind a rotation matrix, such as cos 120° = −0.5 and sin 120° = 0.866.

The self-check that follows covers all five ideas, from the basic matrices to gimbal lock. Each round begins with the idea you have answered least well so far, and a question you miss comes round once more before the end:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Where does Rz(90°) send the left wing tip (1.5, 0, −1)?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: (0, 1.5, −1)

    Rz(90°) has the rows (0, −1, 0), (1, 0, 0) and (0, 0, 1), so (1.5, 0, −1) goes to (0, 1.5, −1).

  2. Question 2 of 15 T(0, 1, 5) is applied to the point (0, 0, 2) and to the direction (0, 0, 1). What comes out?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: (0, 1, 7) and (0, 0, 1)

    The program printed (0, 1, 7) for the nose and left the direction as (0, 0, 1).

  3. Question 3 of 15 Which of these matrices flip the winding order of a model's triangles?

    Choose every answer that is right.

    Show the answer: Question 3 of 15

    Answer:

    • S(2, −1, 3)
    • S(−1, 1, 1)

    A matrix flips the winding when the determinant of its 3 × 3 part is negative: an odd number of negative scale factors, never a product of rotations.

  4. Question 4 of 15 Turning v = (1, 0, 0) by 120° about the unit axis along (1, 1, 1), what is the term k (k · v)(1 − cos θ)?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: (0.5, 0.5, 0.5)

    The program printed the three terms (−0.5, 0, 0), (0, 0.5, −0.5) and (0.5, 0.5, 0.5), which add up to (0, 1, 0).

  5. Question 5 of 15 What must you do to the axis before using Rodrigues' formula?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Divide it by its length, so that k is a unit vector

    Normalise the axis first. The exercise's starter forgets this and turns (0, 0, 5) into a matrix that stretches points five-fold.

  6. Question 6 of 15 How do you turn by θ about the line through p = (1, 0, 0) along z?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: T(p) R T(−p), with R the turn about z through the origin

    The pivot sandwich of the 2D lesson works in 3D: T(p) R T(−p). The exercise checks that (2, 0, 0) turns a quarter circle to (1, 1, 0) about the line through (1, 0, 0).

  7. Question 7 of 15 How many sequences of three coordinate axes are there in which no axis follows itself?

    Type a number.

    Show the answer: Question 7 of 15

    Answer: 12

    Three choices for the first axis, two for the second (any but the first), and two for the third (any but the second): 3 × 2 × 2 = 12, six like XYZ and six like ZXZ.

  8. Question 8 of 15 The angles x = 30°, y = 45°, z = 60° are applied in the orders XYZ and ZYX. How many degrees apart are the two noses?

    Type a number.

    Show the answer: Question 8 of 15

    Answer: 37.005 degrees (anything from 36.995 to 37.015 counts)

    The program put the nose at (1.414, −0.707, 1.225) and at (1.478, 0.561, 1.225), 37.005° apart.

  9. Question 9 of 15 An animator turns a character about its own axes, X first, then its new Y, then its new Z. Which world-axis sequence gives the same pose?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Z, then Y, then X about the world's axes

    The two columns of the step-through ended at the same wing tip, (−0.18, 1.74, −0.42).

  10. Question 10 of 15 In the order XYZ, which entry of the matrix gives the middle angle y?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: m02, row 0 and column 2, which is sin y

    y = asin(m02); then x = atan2(−m12, m22) and z = atan2(−m01, m00), unless |m02| is 0.9999999 or more.

  11. Question 11 of 15 Which angles does three.js read back, in the order XYZ, from the matrix of (20°, 90°, 35°)?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: (55°, 90°, 0°)

    The program read back (55°, 90°, 0°), and multiplied back it is the same rotation.

  12. Question 12 of 15 A user switches an object's Euler order from YXZ to XYZ in an editor, and its three angle fields change. What should the editor have done?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Exactly that; the rotation stays and is read again in the new order

    An orientation is the matrix; the angles are one description of it in one order. three.js's Euler.reorder does the same, through a quaternion.

  13. Question 13 of 15 What is gimbal lock?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: At a middle angle of ±90°, the first and last axes line up and one degree of freedom of the three angles is lost

    With the order YXZ at pitch 90°, Ry(a) Rx(90°) Rz(c) = Ry(a − c) Rx(90°): three angles, two degrees of freedom.

  14. Question 14 of 15 The volume spanned by the three axes of a YXZ camera is cos(pitch). What is it at pitch 89°, to three decimals?

    Type a number.

    Show the answer: Question 14 of 15

    Answer: 0.017 (anything from 0.016 to 0.018 counts)

    cos 89° = 0.01745, and the program printed 0.017. The three axes are nearly flat: two of them are only 1° from lining up, which is why small changes of view need large changes of angles there.

  15. Question 15 of 15 A flight game's camera stores yaw, pitch and roll and misbehaves when the player loops vertically. Which changes remove the problem?

    Choose every answer that is right.

    Show the answer: Question 15 of 15

    Answer:

    • Store the orientation as a rotation matrix and multiply turns into it
    • Store the orientation as a quaternion and apply turns to it

    Clamping the pitch works for a walking camera, but a loop must pass through the vertical, so the game needs a description without a locked pose: a quaternion or a matrix.

Interview questions

Warm-up (fresher to 2-5 years): What is gimbal lock, and why does it happen?

Gimbal lock is the loss of one degree of freedom in Euler angles. An orientation stored as three turns in a fixed order, such as yaw, pitch and roll in the order YXZ, is the product Ry Rx Rz. When the middle turn reaches ±90°, it carries the last axis onto the first, so the first and last angles turn about the same line and only their difference, or their sum, matters: at pitch 90°, yaw 30° with roll 10° and yaw 50° with roll 30° are the same matrix. The rotation itself is fine; the three-number description fails. Near that pitch, small changes of orientation need large changes of angles, and a camera’s yaw spins the picture instead of turning it. Fixes are to keep the middle angle away from ±90°, or to store the orientation as a quaternion or a matrix.

Two questions of this module go further, into a camera that misbehaves at the top of its range and angles that come out wrong between two tools. Their rows link to the module’s question page, which gives the first move of a good answer and the follow-ups to expect:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A first-person camera stores yaw, pitch and roll and builds its matrix in the order YXZ. When the player looks straight up, moving the mouse sideways spins the picture instead of turning the view, and a replay that interpolates the saved angles swings oddly near the top. Explain both and fix the camera.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    Models exported from a modelling tool with rotations as Euler angles in degrees, such as x 30°, y 45°, z 60°, come into your engine tilted. Both programs use the same axes and degrees, and models with a single non-zero angle import correctly. Find the likely cause, prove it, and make the import robust.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • In 3D, translation and scale are 4 × 4 matrices, and each coordinate axis has a rotation matrix that turns counter-clockwise when the axis points at you; the determinant of the 3 × 3 part is 1 for every rotation and −1 for a mirror, which flips the winding of every triangle.
  • Rodrigues’ formula turns any vector about any unit axis: the part along the axis stays, the part across it turns by cos θ and sin θ, and on 1,000 axes it matched the turn-onto-z method within 10⁻¹².
  • Euler angles mean nothing without their order and their reading: XYZ and ZYX put the same three angles’ nose 37° apart, and turning about the object’s own axes X, Y, Z equals turning about the world’s axes Z, Y, X.
  • Reading angles back takes the middle angle from one entry with asin and the outer two from pairs with atan2, so (30°, 120°, 45°) returns as (−150°, 60°, −135°), the same rotation.
  • At a middle angle of ±90° the first and last axes line up: three angles keep two degrees of freedom, and a camera at pitch 90° spins its picture when the yaw changes.

Terms in this lesson

Euler angles
Three angles of turns about coordinate axes, applied in a stated order such as XYZ, that together describe an orientation. Without the order and its reading they are ambiguous.
extrinsic rotation
A sequence of turns, each about a fixed axis of the world. Extrinsic Z, then Y, then X gives the matrix Rx Ry Rz, the same as intrinsic X, Y, Z.
gimbal lock
The loss of one degree of freedom in Euler angles when the middle turn reaches ±90°: the first and last axes line up, and only a combination of the two outer angles is left.
intrinsic rotation
A sequence of turns, each about an axis of the object as the earlier turns left it. Intrinsic X, then Y, then Z gives the matrix Rx Ry Rz.
Rodrigues' formula
The rotation of a vector v by an angle θ about a unit axis k: R v = v cos θ + (k × v) sin θ + k (k · v)(1 − cos θ). The part of v along k stays; the part across it turns.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.