Euler Angles and Gimbal Lock
What You’ll Learn
Section titled “What You’ll Learn”That three angles do not describe an orientation until you also say what order to apply them in, and that there are six reasonable choices which disagree. Then gimbal lock, shown and measured rather than asserted - including the exact number that says how much trouble you are in. And finally the honest part: three angles are still the right answer in several places, and this Section says which.
Three Angles Feels Like Enough
Section titled “Three Angles Feels Like Enough”An orientation in 3D has three degrees of freedom, and three angles is three numbers, so the obvious thing to do is give each axis its own.
| Name | Axis, for something facing | Everyday meaning |
|---|---|---|
| Yaw | about | which way you are facing. Turning left |
| Pitch | about | nose up, nose down. Looking up |
| Roll | about | tilting sideways without changing heading |
These are called Euler angles, and their appeal is real: a human can read them. yaw 90, pitch 0, roll 0 is a fact you can picture. A 3×3 matrix with nine entries is not, and a
quaternion with four is worse.
That readability is why they survive in every tool’s inspector panel, and it is the reason Section 3.2 does not simply replace them.
Three Numbers Are Not Enough
Section titled “Three Numbers Are Not Enough”Here is the first problem. Three angles are meaningless on their own, because rotations do not commute - which Section 2.3 established with boxes and applies just as hard here.
Yaw 90 then pitch 90 does not leave you where pitch 90 then yaw 90 does. So a set of three angles needs a fourth piece of information: the order. There are six ways to sequence three distinct axes, and every one of them is in use somewhere.
Below is the same triple - pitch 30, yaw 60, roll 45 - read under all six, with the resulting forward directions compared against the first.
src/lib/gamedev/demos/eulerorders.ts /** The same three angles under all six orders, and how far apart the results end up. */
import {
ORDERS,
degreesBetween,
forwardOf,
fromEuler,
type Euler,
} from "../euler.ts";
import type { Demo } from "./runner.ts";
/** Pitch 30, yaw 60, roll 45. One set of numbers, read six different ways. */
const ANGLES: Euler = { x: 30, y: 60, z: 45 };
const demo: Demo = (log) => {
const reference = forwardOf(fromEuler(ANGLES, "XYZ"));
for (const order of ORDERS) {
const away = degreesBetween(reference, forwardOf(fromEuler(ANGLES, order)));
log(
`forward under "${order}"`,
`${away.toFixed(1)}\u00B0 away`,
order === "XYZ" ? "the one we are comparing against" : undefined,
);
}
};
export default demo; Forty degrees apart, from identical numbers. Which means Euler angles are not portable: a triple copied from one tool into another that assumes a different order does not land close, it lands somewhere else entirely.
For a -up world with things facing , the useful order is "YXZ" - yaw, then pitch,
then roll, reading the product outward. That puts yaw in world space where a player expects it,
and roll innermost where it belongs. It is what cameras and characters use, and it is what the
scene below uses.
Gimbal Lock
Section titled “Gimbal Lock”Now the real problem, and the one worth seeing rather than reading about.
The three rotations are nested. The outer one turns about a fixed world axis. The inner one turns about an axis that has been carried around by the two outside it. That is not an analogy - it is what the matrix product does, and it is what a physical gimbal does, which is where the name comes from.
Below, three rings. Blue is yaw about world and never moves. Green is pitch, carried by yaw. Purple is roll, carried by both. The dashed lines are the yaw axis and the roll axis.
src/lib/gamedev/demos/gimbal.scene.ts /**
* Three nested gimbal rings, and the two rotation axes that collapse onto each other at 90.
*/
import * as THREE from "three";
import {
IDENTITY4,
applyMat4,
multiplyMat4,
point,
rotationX4,
rotationY4,
type Mat4,
type Vec3,
} from "../matrices.ts";
import {
YAW_PITCH_ROLL,
axisInWorld,
axisSeparation,
forwardOf,
fromEuler,
type Axis,
type Euler,
} from "../euler.ts";
import {
makeCanvas,
addSlider,
addReadout,
addButtonRow,
addBoxWire,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
const YAW = 0x58a6ff;
const PITCH = 0x7ee787;
const ROLL = 0xd2a8ff;
/** A ring of the given radius, lying in the plane its own axis is perpendicular to. */
function addRing(
scene: THREE.Scene,
color: number,
axis: Axis,
radius: number,
): (m: Mat4) => void {
const geom = new THREE.BufferGeometry();
scene.add(new THREE.Line(geom, new THREE.LineBasicMaterial({ color })));
return (m: Mat4) => {
const pts: THREE.Vector3[] = [];
for (let d = 0; d <= 96; d += 1) {
const t = (d / 96) * Math.PI * 2;
const u = Math.cos(t) * radius;
const v = Math.sin(t) * radius;
const local =
axis === "Y"
? point(u, 0, v)
: axis === "X"
? point(0, u, v)
: point(u, v, 0);
const p = applyMat4(m, local);
pts.push(new THREE.Vector3(p.x, p.y, p.z));
}
geom.setFromPoints(pts);
};
}
/** A dashed line through the origin, so an axis can be seen rather than inferred. */
function addAxisLine(
scene: THREE.Scene,
color: number,
half: number,
): (d: Vec3) => void {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.LineSegments(
geom,
new THREE.LineDashedMaterial({ color, dashSize: 0.1, gapSize: 0.08 }),
);
scene.add(mesh);
return (d: Vec3) => {
geom.setFromPoints([
new THREE.Vector3(-d.x * half, -d.y * half, -d.z * half),
new THREE.Vector3(d.x * half, d.y * half, d.z * half),
]);
mesh.computeLineDistances();
};
}
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 320);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(3.4, 2.6, 4.6);
camera.lookAt(0, 0, 0);
const yawRing = addRing(scene, YAW, "Y", 1.75);
const pitchRing = addRing(scene, PITCH, "X", 1.42);
const rollRing = addRing(scene, ROLL, "Z", 1.1);
// The two axes that matter: the fixed outer one, and the inner one being carried around.
const outerAxis = addAxisLine(scene, YAW, 2.1);
const innerAxis = addAxisLine(scene, ROLL, 1.95);
const body = addBoxWire(scene, 0x7d8590);
const nose = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0x39d3c3 }),
);
scene.add(nose);
const show = addReadout(el);
const setActive = addButtonRow(el, [
{ label: "Level", apply: () => preset(25, 0, 15, 0) },
{ label: "Nose up 90\u00B0", apply: () => preset(25, 90, 15, 1) },
{ label: "Nose down 90\u00B0", apply: () => preset(25, -90, 15, 2) },
]);
const yaw = addSlider(el, "yaw, about world Y", -180, 180, 25, draw);
const pitch = addSlider(el, "pitch, about the carried X", -90, 90, 20, draw);
const roll = addSlider(el, "roll, about the carried Z", -180, 180, 15, draw);
function preset(y: number, p: number, r: number, index: number) {
yaw.set(y);
pitch.set(p);
roll.set(r);
setActive(index);
draw();
}
function draw() {
const e: Euler = { x: pitch(), y: yaw(), z: roll() };
// Each ring is oriented by the rings outside it, and by nothing inside it.
const middle = rotationY4(e.y);
const inner = multiplyMat4(middle, rotationX4(e.x));
const full = fromEuler(e, YAW_PITCH_ROLL);
yawRing(IDENTITY4);
pitchRing(middle);
rollRing(inner);
outerAxis(axisInWorld(e, YAW_PITCH_ROLL, 0));
innerAxis(axisInWorld(e, YAW_PITCH_ROLL, 2));
body((c) => {
const p = applyMat4(full, point(c[0] * 0.7, c[1] * 0.7, c[2] * 0.7));
return [p.x, p.y, p.z];
});
const f = forwardOf(full);
nose.geometry.setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(f.x * 1.5, f.y * 1.5, f.z * 1.5),
]);
const apart = axisSeparation(e, YAW_PITCH_ROLL);
const verdict =
apart < 0.5
? " \u00B7 gimbal lock"
: apart < 5
? " \u00B7 nearly locked"
: "";
show(`yaw and roll axes are ${apart.toFixed(1)}\u00B0 apart${verdict}`);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Start level and drag the pitch slider up. Watch the purple dashed line swing towards the blue one. At pitch 90 they lie on top of each other, and at that point yaw and roll turn the object about the same axis. Two controls, one effect. You have three numbers and only two degrees of freedom left.
That is gimbal lock. The orientation you cannot reach is not exotic - it is any orientation that needed those two axes to be different.
It has a number
Section titled “It has a number”Most explanations stop at “things go wrong near the poles.” They do not have to, because the severity is measurable: take the angle between the outer rotation axis and the inner one. Ninety degrees means fully independent. Zero means locked.
For yaw-pitch-roll that angle turns out to be exactly
independent of what the yaw and roll happen to be. Which is a much sharper statement than a warning:
| Pitch | Separation | What it means |
|---|---|---|
| 0° | 90° | yaw and roll fully independent |
| 30° | 60° | fine |
| 60° | 30° | roll is starting to duplicate yaw |
| 85° | 5° | nearly one control |
| 89° | 1° | effectively locked already |
| 90° | 0° | locked. One degree of freedom is simply gone |
Note the third-to-last row. The problem does not arrive suddenly at 90 - it degrades linearly the whole way up, and you are in trouble well before the singularity. The build check for this Section asserts that formula across a grid of pitch, yaw and roll values.
The degeneracy, stated exactly
Section titled “The degeneracy, stated exactly”At pitch the yaw and roll axes are not merely close, they are anti-parallel. So adding ten degrees to yaw and ten to roll produces the identical orientation you started with.
The check asserts that directly - two different triples, one matrix - and asserts that the same trick does nothing at all away from the pole. That is what “one degree of freedom is gone” means, and it is a claim about matrices rather than about how a picture looks.
What Goes Wrong in a Game
Section titled “What Goes Wrong in a Game”Three concrete failures, all downstream of the above.
- A camera that flips when you look straight up. A first-person camera with yaw and pitch hits separation zero at pitch ; go past it and yaw effectively reverses, so the view spins. The standard fix is to clamp pitch, and it is worth being precise about why: clamping does not restore independence - at pitch 89 you are already 1° from locked. It prevents the camera reaching the point where the mapping is degenerate and can flip. That is enough, because a first-person camera never rolls, so losing roll independence costs nothing.
- Interpolating between two orientations. Blend the angles and the object does not take the short path; it can swing wide, or spin on an axis you never asked about. Section 3.3.
- Converting a matrix back into angles. The reverse trip needs an
atan2whose arguments both go to zero at lock, so the answer is genuinely undefined - not imprecise, undefined. Any code doing that round trip needs a special case, and the special case has to pick an answer arbitrarily.
source The code the scene and the value list both run on
/**
* Three angles, one per axis - and the two things that go wrong with them.
*
* The first is that three numbers do not describe an orientation until you also say what
* order to apply them in, and there are six sensible choices. The second is gimbal lock,
* which `axisSeparation` below turns from an assertion into a measurement.
*/
import {
IDENTITY4,
multiplyMat4,
rotationX4,
rotationY4,
rotationZ4,
type Mat4,
type Vec3,
} from "./matrices.ts";
import { transformDirection } from "./spaces.ts";
export type Axis = "X" | "Y" | "Z";
/** Per-axis angles in degrees. For something facing -Z: x is pitch, y is yaw, z is roll. */
export type Euler = { x: number; y: number; z: number };
/**
* Which order the three rotations combine in.
*
* The string names the **product**, left to right: `"YXZ"` means `Ry * Rx * Rz`. By Section
* 2.3's rule that puts `Rz` nearest the vector, so **the last letter is applied first**. This
* is the convention Three.js, Godot and Unity all use, and it is worth saying out loud
* because it reads backwards.
*/
export type Order = "XYZ" | "XZY" | "YXZ" | "YZX" | "ZXY" | "ZYX";
export const ORDERS: readonly Order[] = [
"XYZ",
"XZY",
"YXZ",
"YZX",
"ZXY",
"ZYX",
];
/** Yaw-pitch-roll for a Y-up world, and what nearly every camera and character uses. */
export const YAW_PITCH_ROLL: Order = "YXZ";
export const axesOf = (order: Order): [Axis, Axis, Axis] =>
order.split("") as [Axis, Axis, Axis];
/** The rotation matrix for one axis on its own. */
export function axisMatrix(axis: Axis, degrees: number): Mat4 {
if (axis === "X") return rotationX4(degrees);
if (axis === "Y") return rotationY4(degrees);
return rotationZ4(degrees);
}
/** The angle this Euler triple assigns to one axis. */
export function angleOn(e: Euler, axis: Axis): number {
return axis === "X" ? e.x : axis === "Y" ? e.y : e.z;
}
/** The same triple with one axis moved by `delta`. */
export function bump(e: Euler, axis: Axis, delta: number): Euler {
if (axis === "X") return { ...e, x: e.x + delta };
if (axis === "Y") return { ...e, y: e.y + delta };
return { ...e, z: e.z + delta };
}
/** Build one rotation matrix from three angles and an order. */
export function fromEuler(e: Euler, order: Order): Mat4 {
const [outer, middle, inner] = axesOf(order);
return multiplyMat4(
axisMatrix(outer, angleOn(e, outer)),
multiplyMat4(
axisMatrix(middle, angleOn(e, middle)),
axisMatrix(inner, angleOn(e, inner)),
),
);
}
/** Where the object ends up facing. Forward is -Z, as everywhere in this Module. */
export function forwardOf(m: Mat4): Vec3 {
return transformDirection(m, { x: 0, y: 0, z: -1 });
}
/** Where its up ends up. Needed as well as forward, or a roll would be invisible. */
export function upOf(m: Mat4): Vec3 {
return transformDirection(m, { x: 0, y: 1, z: 0 });
}
const unit = (axis: Axis): Vec3 => ({
x: axis === "X" ? 1 : 0,
y: axis === "Y" ? 1 : 0,
z: axis === "Z" ? 1 : 0,
});
/**
* Where one of the three rotation axes actually points, in world space.
*
* The outer rotation happens last, so its axis is a fixed world axis and never moves. The
* inner one happens first and then gets carried around by the two outside it. That is
* precisely what the rings of a physical gimbal do, which is where the name comes from.
*/
export function axisInWorld(e: Euler, order: Order, which: 0 | 1 | 2): Vec3 {
const axes = axesOf(order);
let carrier = IDENTITY4;
for (let i = 0; i < which; i += 1) {
carrier = multiplyMat4(carrier, axisMatrix(axes[i], angleOn(e, axes[i])));
}
return transformDirection(carrier, unit(axes[which]));
}
/** The angle between two directions, in degrees. */
export function degreesBetween(a: Vec3, b: Vec3): number {
const la = Math.hypot(a.x, a.y, a.z);
const lb = Math.hypot(b.x, b.y, b.z);
if (la < 1e-12 || lb < 1e-12) return 0;
const c = (a.x * b.x + a.y * b.y + a.z * b.z) / (la * lb);
return (Math.acos(Math.min(1, Math.max(-1, c))) * 180) / Math.PI;
}
/**
* How much independent control is left, in degrees, from 90 down to 0.
*
* This is the angle between the outer rotation axis and the inner one. **90 means the two
* angles turn the object about genuinely different axes. 0 means they turn it about the same
* axis**, so one of your three numbers has stopped buying you anything - which is gimbal lock,
* as a measurement rather than a warning.
*
* Anti-parallel counts as locked, hence the absolute value: two controls that spin the object
* in exactly opposite directions are still only one control.
*/
export function axisSeparation(e: Euler, order: Order): number {
const a = axisInWorld(e, order, 0);
const b = axisInWorld(e, order, 2);
const d = Math.abs(a.x * b.x + a.y * b.y + a.z * b.z);
return (Math.acos(Math.min(1, d)) * 180) / Math.PI;
}
/**
* Read yaw, pitch and roll back out of a rotation matrix, for the `"YXZ"` order.
*
* Only one order, deliberately - the other five are the same shape with the indices moved, and
* one worked example makes the structure clearer than six near-identical branches would.
*
* The `if` is the whole point of Section 3.1. Away from the poles, pitch comes from a single
* entry and the other two angles come from `atan2` pairs. **At** the poles those pairs are both
* zero, so `atan2(0, 0)` is being asked which way to point when every direction is equally
* true. The answer has to be chosen rather than computed, and the usual choice is to hand the
* whole rotation to yaw and set roll to zero.
*/
export function toEulerYXZ(m: Mat4): Euler {
// Row-major entries, so the names match how the matrix is written down.
const m01 = m.j.x;
const m00 = m.i.x;
const m10 = m.i.y;
const m11 = m.j.y;
const m12 = m.k.y;
const m02 = m.k.x;
const m22 = m.k.z;
const sinPitch = Math.min(1, Math.max(-1, -m12));
const pitch = Math.asin(sinPitch);
const cosPitch = Math.cos(pitch);
// Below this, the yaw and roll terms have both collapsed to zero and cannot be separated.
if (Math.abs(cosPitch) < 1e-7) {
return {
x: (pitch * 180) / Math.PI,
y:
((sinPitch > 0 ? Math.atan2(m01, m00) : Math.atan2(-m01, m00)) * 180) /
Math.PI,
z: 0,
};
}
return {
x: (pitch * 180) / Math.PI,
y: (Math.atan2(m02, m22) * 180) / Math.PI,
z: (Math.atan2(m10, m11) * 180) / Math.PI,
};
} axisSeparation is the whole argument in six lines: get the outer axis, get the inner axis,
measure the angle between them. The absolute value is there because two controls that spin an
object in exactly opposite directions are still only one control.
Where Euler Angles Are Still Right
Section titled “Where Euler Angles Are Still Right”This Section is not an argument for never using them. It is an argument for knowing where the edge is.
- Anything a person types or reads. Inspector fields, config files, level data, animation curves an artist edits. Nobody hand-authors a quaternion.
- Turrets and turntables, which have genuinely fewer than three degrees of freedom. A turret with yaw and pitch and no roll cannot gimbal lock, because there is no third axis to collapse into the first. Two angles are exactly right and a quaternion would be overkill.
- First-person cameras, for the reason above: yaw and pitch, roll clamped to zero, pitch clamped short of the pole. This is the most common camera in games and it is Euler angles all the way down.
- Designer-facing parameters like “spawn this facing 45 degrees”, which want to stay readable in a text diff.
The rule that falls out: Euler angles are a good interface and a bad intermediate representation. Accept them at the edges, convert once, compute with something else, and convert back only if a human needs to read the result.
Where This Shows Up
Section titled “Where This Shows Up”- Every inspector panel in every 3D tool, which is why the format persists.
- Camera controllers, with a pitch clamp that exists because of this Section.
- Flight and space games, which cannot clamp pitch because the whole point is to fly through the pole, and which therefore cannot use Euler angles for the craft’s orientation.
- Animation import, where a curve authored as three angles in one order and read in another gives limbs that bend the wrong way.
- Anything reading angles back out of a matrix, which is where the undefined case lives.