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.
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:
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:
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:
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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:
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.
a = (1, 1, 1), b = (1, 0, 0): a · b = 1, a × b = (0, 1, −1).
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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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).
- 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).
- 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).
- 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).
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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°.
- 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.
- 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.
- 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.
- 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.
- 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°.
- 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, 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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
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°:
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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.
- Rotate about any axis and about any line To do
- 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 byangleradians about the line through the origin alongaxis, 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 throughpoint, 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).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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 withasin, the other two fromatan2of 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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.
-
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.
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
- glTF 2.0 Specification: 3.4 Coordinate System and Units, and 3.7.4 Instantiation (Khronos Group)
- three.js documentation: Euler (three.js authors)
- three.js r186 source: Matrix4.makeRotationFromEuler (three.js authors)
- three.js r186 source: Euler.setFromRotationMatrix (three.js authors)
- three.js r186 source: PointerLockControls (three.js authors)
- Des lois géométriques qui régissent les déplacements d’un système solide dans l’espace (Journal de Mathématiques Pures et Appliquées, vol. 5) (Journal de Mathématiques Pures et Appliquées (Numdam))
- ECMAScript Language Specification: Math.asin and Math.atan2 (Ecma International (TC39))
- UGC NET syllabus: Computer Science and Applications (code 87) (University Grants Commission (UGC NET Bureau))
- Computer Graphics and Visualization (BE05000161) syllabus (Gujarat Technological University)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress