All tutorials Mighty Professional
Build a Game Engine ยท Math & numerics

3D Math for Games

Your first triangle renders upside down, a model imported from another tool comes in mirrored with its faces culled, and a camera blend spins the long way round. The vectors, matrices and quaternions behind all three are the easy part. The bugs live in the conventions: row- or column-major, left- or right-handed, where the projection sends Y and how deep the clip space runs. This page builds the math in C++ and Rust, shows what each operation is for in an engine, and pins down the conventions Vulkan actually wants, so you can tell which convention a bug comes from and your first triangle comes out right-side up.

Time~75 min LevelBeginner to mid PrereqsYou can read C++ or Rust. High-school vectors and trig. No graphics API needed; the conventions are built here. StackC++ (glm) ยท Rust (glam)
โ—‚ Build a Game Engine Phase 2 ยท Math & numerics Next ยท IEEE-754 Floating Point โ–ธ
What you'll be able to do by the end

01The conventions are the hard part

A formula copied from a forum post renders your scene upside down; a model exported from another tool comes in mirrored. Neither is an algebra mistake. The vector and matrix algebra in this tutorial is the same algebra you saw in school. What makes 3D math for games its own skill is that a single rotation can be written several equally correct ways, and a formula copied from a source with different conventions renders upside down, mirrored, or inside out. The algebra doesn't change between engines and APIs; the conventions do, and most of the debugging time goes into them[1][2].

Four independent choices trip people up, and they are genuinely independent, so you can mix them in any combination:

This tutorial picks one of each and holds it throughout: column-major storage, column vectors (vโ€ฒ = Mยทv), right-handed world space, and Vulkan clip space. The first three are the defaults of the two libraries the code uses, glm for C++[3] and glam for Rust[4]. Vulkan clip space takes a little configuration in each (ยง5): glm defaults to OpenGL's depth range, and neither library flips Y for you. Stated once; every sample below obeys it.

Five widgets let you break each convention on purpose and watch what happens: the vector products, transform order, clip space and handedness, gimbal lock, and rotation blending.

02Vectors

A guard needs to know whether the player is in front of it and inside its 90ยฐ view cone; a shader needs to know how squarely light hits a surface; a mesh tool needs a triangle's normal. A 3D vector is three numbers, but the two products you do with it carry most of that geometry: the dot product measures alignment, the cross product builds a perpendicular.

New to the notation? Start here

A handful of symbols recur through this whole page. Named once, none of them are hard:

  • a, b, v: a vector, a little arrow with a direction and a length, written as a list of numbers like (3, 0, โˆ’1).
  • ax, ay, az: the components of a vector, its x, y, and z numbers. The small letter says which axis.
  • |a|: the length (or magnitude) of a vector. The bars are the same idea as absolute value: how long the arrow is, always positive.
  • ฮธ: theta, a Greek letter. Math labels angles with Greek letters out of habit; ฮธ is just the angle between two arrows. Later sections also use ฮฉ (omega) for an angle.
  • ยท and ร—: the two ways to multiply vectors, the dot product (gives a number) and the cross product (gives a new vector). They carry most of this section.
  • รข: a letter wearing a hat means a unit vector, same direction, length exactly 1.

Every symbol in the equations below is also hoverable: rest the pointer on any letter to see what it stands for.

The collapses two vectors to a single number that encodes the angle between them, which is what every "is it in front of me" test needs:

eq. 1 ยท dot product a ยท b = axbx + ayby + azbz = |a| |b| cos ฮธ

In words: two routes to the same number. Left: multiply the matching components and add, pure arithmetic. Right: multiply the two lengths by the cosine of the angle between them. The right-hand form is why the dot product measures alignment: cos ฮธ is 1 when the arrows point the same way, 0 when they are perpendicular, and negative once they point more than 90ยฐ apart. Hover any symbol to see what it stands for.

Need a refresher on sine, cosine, and ฮธ?

ฮธ (theta) is just a name for an angle, the way x is a name for a number. Greek letters for angles is an old habit, nothing deeper.

Cosine (cos) and sine (sin) each take an angle and return a number between โˆ’1 and 1. The one fact that makes the dot product click:

  • cos 0ยฐ = 1: arrows aligned, dot product at its largest.
  • cos 90ยฐ = 0: perpendicular, dot product exactly zero.
  • cos 180ยฐ = โˆ’1: opposite, dot product most negative.

Sine is the same wave shifted over: sin 0ยฐ = 0, sin 90ยฐ = 1. Sine tracks the perpendicular part, which is why it runs the cross product (area) while cosine runs the dot product (alignment). The widget at the end of this section spins the angle so you can watch both move.

Its sign tells you which half-space b is in relative to a: positive in front, negative behind, zero perpendicular. That sign test powers CPU-side back-face checks, the NยทL term in diffuse lighting (the surface normal dotted with the light direction), and "is the enemy in front of me" checks (a field-of-view test compares the dot product against the cosine of half the view angle).

Worked example

A guard faces f = (1, 0, 0) and has a 90ยฐ view cone, so half the angle is 45ยฐ and cos 45ยฐ = 0.707. The direction to the player, already normalized, is p = (0.6, 0.8, 0).

f · p = 1·0.6 + 0·0.8 + 0·0 = 0.6: positive, so the player is in front, but 0.6 < 0.707, so they're outside the cone (the angle is acos 0.6 ≈ 53°, more than 45°). Three multiplies, two adds and a compare; no trig at run time.

The (3D only) returns a vector perpendicular to both inputs, with length equal to the area of the parallelogram they span. Cross two edges of a triangle and you get its normal:

eq. 2 ยท cross product a ร— b = (aybz โˆ’ azby, azbx โˆ’ axbz, axby โˆ’ aybx)
|a ร— b| = |a| |b| sin ฮธ

In words: the result is a new vector pointing perpendicular to both a and b, straight out of the plane they share. Each output component criss-crosses the other two axes; that pattern is the whole formula. Its length equals the area of the parallelogram a and b span, and because that area carries sin ฮธ, the cross product is largest when the arrows are perpendicular and shrinks to zero when they are parallel.

Worked example

a = (2, 0, 0) and b = (1, 1, 0). The formula gives (0·0 − 0·1, 0·1 − 2·0, 2·1 − 0·1) = (0, 0, 2): straight along z, perpendicular to both.

Its length, 2, is the parallelogram's area: base |a| = 2 times height 1 (b rises 1 unit above a's line).

Trap: the cross product's direction depends on handedness, its formula does not

The component formula above is fixed. Whether the result points "up" or "down" relative to your screen depends on whether your basis is left- or right-handed. The right-hand rule only describes a right-handed basis; Unreal's coordinate system follows the left-hand rule and its docs say so explicitly[8]. Don't write "the cross product gives the right-hand-rule normal" without scoping it to the basis you're in.

Projection of a onto b drops a perpendicular from the tip of a onto the line through b. It answers "how much of this velocity points along that wall or that axis", a question character controllers and physics ask constantly:

eq. 3 ยท projection of a onto b projb(a) = a ยท bb ยท b b โ†’ (a ยท b) b when |b| = 1

In words: how much of a points along b? Drop a straight line from the tip of a onto b's direction; the shadow it casts is the projection. The fraction (aยทb)/(bยทb) works out to how many copies of b fit into that shadow, and multiplying by b aims the answer back along b. When b is already unit length the bottom is 1 and the whole thing collapses to (aยทb) b.

Normalization scales a vector to unit length while keeping its direction. The view-cone test above only works on unit vectors, so this runs before most of them:

eq. 4 ยท normalize รข = a|a|

In words: divide a vector by its own length and the direction survives but the length becomes exactly 1, a unit vector, written with a hat: รข. Directions you compare or feed into lighting should almost always be normalized first. The trap: a zero-length vector has |a| = 0, so this divides by zero and produces a NaN that then spreads through every transform it touches, so guard against it.

Worked example

a = (3, 4, 0) has length √(9 + 16) = 5, so â = (0.6, 0.8, 0).

Project a onto b = (2, 0, 0): a·b = 6 and b·b = 4, so the shadow is 6/4 × (2, 0, 0) = (3, 0, 0). With the unit b̂ = (1, 0, 0) the short form gives the same: (a·b̂) b̂ = 3 × (1, 0, 0).

And when you actually need the angle between two vectors, acos(aยทb) (for unit vectors) is numerically poor near 0 and ฯ€; atan2(|aร—b|, aยทb), using the two-argument arctangent, is the robust form.

Drag the blue arrow, or use the sliders. The dot product flips sign as b swings past 90ยฐ, and the parallelogram (the cross product's magnitude) swells to its largest when the arrows are perpendicular:

Red a is fixed and blue b follows the sliders or your drag; the panel beside them reads out the angle, a·b, the projection and the cross product. At the starting 55° it shows a·b = 0.69 and an area of 0.98; drag b to 90° and a·b drops to 0.00 while the area peaks at 1.20 (a is 1.2 long). The dot product stays positive while b leans the same way as a, hits zero at 90ยฐ, and goes negative past it, exactly tracking the projection of b onto a (the dashed shadow flips to the far side). The shaded parallelogram is the cross product's magnitude: zero when the arrows line up, largest when perpendicular. The โŠ™ / โŠ— glyph marks whether aร—b points out of the screen or into it, the sign that handedness pins down.
A 3D vector, by hand
struct Vec3 { float x, y, z; };

inline float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }

inline Vec3 cross(Vec3 a, Vec3 b) {
    return { a.y*b.z - a.z*b.y,      // x
             a.z*b.x - a.x*b.z,      // y
             a.x*b.y - a.y*b.x };    // z
}

inline float length(Vec3 v) { return std::sqrt(dot(v, v)); }

inline Vec3 normalize(Vec3 v) {
    float len = length(v);
    return len > 1e-8f ? Vec3{ v.x/len, v.y/len, v.z/len } : v;  // guard the zero vector
}
// In practice: glm::dot, glm::cross, glm::length, glm::normalize.
#[derive(Clone, Copy)]
struct Vec3 { x: f32, y: f32, z: f32 }

fn dot(a: Vec3, b: Vec3) -> f32 { a.x*b.x + a.y*b.y + a.z*b.z }

fn cross(a: Vec3, b: Vec3) -> Vec3 {
    Vec3 { x: a.y*b.z - a.z*b.y,      // x
           y: a.z*b.x - a.x*b.z,      // y
           z: a.x*b.y - a.y*b.x }     // z
}

fn length(v: Vec3) -> f32 { dot(v, v).sqrt() }

fn normalize(v: Vec3) -> Vec3 {
    let len = length(v);
    if len > 1e-8 { Vec3 { x: v.x/len, y: v.y/len, z: v.z/len } } else { v }  // guard zero
}
// In practice: glam's Vec3::dot, ::cross, ::length, ::normalize_or_zero.

03Matrices

A sword follows a hand, the hand follows an arm, and the arm follows a character walking through a level. Each link is a transform, and chaining them is matrix multiplication. A 4ร—4 matrix packs a 3ร—3 linear part (rotation, scale, shear) and a translation into one object that the GPU can apply to every vertex with a single multiply. Composing transforms is matrix multiplication, and the order matters: TยทR โ‰  RยทT.

Worked example

R turns 90° about z, so it sends (1, 0, 0) to (0, 1, 0). T moves points 5 units along x. Apply both to the point p = (1, 0, 0), reading each product right to left.

TยทRยทp: rotate to (0, 1, 0), then move to (5, 1, 0). RยทTยทp: move to (6, 0, 0), then rotate to (0, 6, 0). Same two matrices, two different places.

The thing that actually causes bugs is that two independent conventions get conflated. Storage order (row- vs column-major) is how the floats sit in memory. Vector convention (column vs row vectors) is which side the matrix goes on. They are orthogonal: any of the four combinations is valid[2].

The number-one misconception

"Row-major means I pre-multiply" is false. Storage order does not dictate multiply order. glm and glam both store column-major and use column vectors (vโ€ฒ = Mยทv)[3][4]. They are not contradicting each other; the two choices are simply independent.

Three facts follow, and each maps to a real bug:

One more scoping note: don't say "every engine stores column-major." The OpenGL and glTF lineage does, and so do glm and glam; DirectX's XMMATRIX is row-major with row vectors. Both are correct; they are different lineages[2].

Poke at it. The widget composes a transform from translation, rotation, and a scale that starts non-uniform (1.6 by 1), and prints the resulting column-major matrix. Flip the application order and the square shears; set both scales equal and the two orders agree again:

The dashed outline is the original square, the filled shape the transformed one, and the two arrows its transformed x and y axes; the matrix beside them is the result. At the starting 30° and scale 1.6 by 1, TยทRยทS gives axis columns (1.39, 0.80) and (−0.50, 0.87), perpendicular. Switch to TยทSยทR and they become (1.39, 0.50) and (−0.80, 0.87), about 113° apart: a shear. With order TยทRยทS the square scales along its own axes, then rotates: a rectangle, no shear. With TยทSยทR the same non-uniform scale is applied after the rotation, so it stretches along world axes and the square shears into a parallelogram. Non-uniform scale and rotation don't commute, so the order changes the result; with equal X and Y scales it doesn't, and the toggle has no effect.
Compose a transform (column-major, column vectors)
// glm: column-major storage, column vectors. Read right-to-left:
// scale first, then rotate, then translate.
glm::mat4 model = glm::mat4(1.0f);              // identity
model = glm::translate(model, position);          // T
model = model * glm::mat4_cast(rotation);        // R (quaternion โ†’ matrix)
model = glm::scale(model, scale);                 // S
// Equivalent: model = T * R * S; applied to a point as model * vec4(p, 1).
// glam: column-major storage, column vectors. Same order.
let model = Mat4::from_scale_rotation_translation(
    scale,        // S
    rotation,     // R (a Quat)
    position,     // T
);
// Builds T * R * S directly; applied to a point as model * p.extend(1.0).

Wrong answers, and why: column-major storage does not force row vectors (the conventions are independent); the transpose-on-upload symptom is a memory-layout mismatch, not a quaternion or near/far issue.

04Coordinate spaces & handedness

A rock modeled in one tool shows up in your engine mirrored, with its faces culled. To see why, follow where a vertex goes. It passes through a chain of spaces: object space โ†’ world space โ†’ view (camera) space โ†’ clip space โ†’ NDC (normalized device coordinates, the cube the GPU clips against) โ†’ screen. Each arrow is one matrix, except the last two, which are the perspective divide and the viewport transform the GPU does for you.

Handedness is fixed by the basis orientation. With +X right and +Y up: if +Z points toward you, the system is right-handed; if +Z points into the screen, it's left-handed. Formally, it is the sign of the triple product (X ร— Y) ยท Z with the three axes written in a reference right-handed frame.

Worked example

Write Unity's axes in a right-handed reference frame where +z points toward the viewer. Its +X is right, (1, 0, 0); +Y is up, (0, 1, 0); its +Z is forward, into the screen, so (0, 0, −1).

X × Y = (0, 0, 1), and (0, 0, 1) · (0, 0, −1) = −1: left-handed. glTF's axes give +1. That one sign is the difference a glTF importer for Unity has to convert by flipping an axis.

Trap: "right-handed" alone doesn't pin down a coordinate system

You also have to say which axis is up and which is forward, and the units. Three shipping conventions, all different:

Import a model across a handedness boundary without converting and one axis flips, which mirrors the model and inverts triangle winding, and therefore which faces get culled. Unreal's coordinate-system page tells anyone importing from Maya, Houdini, ZBrush, Blender, or 3ds Max to account for exactly this[8].

05Model โ†’ view โ†’ projection

Your first Vulkan triangle renders upside down, or vanishes when the camera gets close. Both come from the last step of the chain. Three matrices get a model onto the screen. Model takes object space to world space. View takes world to eye space, and is the inverse of the camera's world transform. Projection takes eye space to clip space, a homogeneous space, where each point carries a fourth number w and the divide by it hasn't happened yet.

After the vertex shader writes clip-space (x, y, z, w), the GPU does the perspective divide (xyz / w) to get NDC, then the viewport transform to get framebuffer pixels. The projection matrix is where the API's clip-space conventions live, and this is where OpenGL and Vulkan differ in ways that produce upside-down or depth-broken renders.

Worked example

The vertex shader writes clip (2, 1, 0.5, 4). The divide gives NDC (2/4, 1/4, 0.5/4) = (0.5, 0.25, 0.125): right of center, a little above the middle in OpenGL's Y-up NDC, and near the front of Vulkan's 0…1 depth range.

With a 60° vertical field of view, the projection's y scale is 1 / tan 30° = 1.73. A point 1 unit up and 5 units in front of the camera lands at NDC y = 1.73 / 5 = 0.35. In OpenGL's Y-up NDC that's above center, where it belongs; in Vulkan's Y-down NDC it's below center, so the matrix negates that 1.73, which is what the listing below does.

OpenGLVulkan
NDC Y directionupdown
Clip depth rangeโˆ’w โ‰ค z โ‰ค w  (NDC โˆ’1โ€ฆ1)0 โ‰ค z โ‰ค w  (NDC 0โ€ฆ1)
NDC handednessleft-handedright-handed

Sources: the Vulkan specification's vertex post-processing chapter and Wellings' coordinate-system writeup[5][6].

Two consequences for the projection matrix:

Trap: "Vulkan is left-handed" is wrong, and it hides three separate things

Vulkan's NDC is right-handed. The visible flip people hit is the Y-down framebuffer mapping, which is separate from the depth range, which is separate again from your world-space handedness[5]. Conflating the three is a common source of OpenGLโ†’Vulkan porting bugs. And reversed-Z, which maps the near plane to depth 1 and the far plane to 0, is yet another, orthogonal, precision technique; it is not the same as the [0, 1] range, though it pays off only with a [0, 1] range and a floating-point depth buffer.

Worked example

A Vulkan projection with near 0.1 and far 1,000 maps a point at distance d to depth 1000 (d − 0.1) / (999.9 d). At d = 0.2 that's 0.50; at d = 1, 0.90; at d = 10, 0.99. Ninety-nine percent of the depth range covers the first 10 units of a 1,000-unit view, which is why distant surfaces z-fight and why reversed-Z helps.

Feed Vulkan an OpenGL projection with the same planes instead, and depth reaches 0 only at d = 2 × 1000 × 0.1 / 1000.1 ≈ 0.2. Anything closer comes out below 0, outside Vulkan's 0…1 range, and is clipped.

The widget renders the same scene for a chosen target API. Untick the box to feed it a projection written for the other API, or mismatch the import handedness; the readout names each thing that breaks:

The F and the two triangles are the scene as the chosen API draws it, and the panel lists the target's conventions and everything that broke. Start with both boxes as they load, then untick the projection box, then tick the handedness box. Feed Vulkan a projection written for OpenGL and the scene renders upside down; OpenGL's โˆ’1โ€ฆ1 depth also lands in Vulkan's 0โ€ฆ1 clip volume, so geometry closer than about twice the near distance (the red triangle) is clipped. Feed OpenGL a Vulkan projection and it is upside down too, with half the depth range unused. Mismatch import handedness and the scene mirrors along X while every triangle's winding reverses, so back-face culling throws away the faces that should be visible (dashed outlines). Y direction, depth range, and world handedness are independent; the readout lists each one that broke.
A Vulkan-ready perspective matrix
// Define this BEFORE including glm so depth maps to [0, 1] for Vulkan:
//   #define GLM_FORCE_DEPTH_ZERO_TO_ONE
glm::mat4 proj = glm::perspective(glm::radians(60.0f), aspect, 0.1f, 1000.0f);
proj[1][1] *= -1.0f;  // flip Y: glm assumes GL's Y-up NDC, Vulkan's is Y-down
// Alternative to the flip: set a negative VkViewport.height.
// glam's perspective_rh already targets [0, 1] depth (Vulkan/wgpu/D3D).
let mut proj = Mat4::perspective_rh(60.0_f32.to_radians(), aspect, 0.1, 1000.0);
proj.y_axis.y *= -1.0;  // flip Y for Vulkan's Y-down NDC (or use a negative viewport height)
// Use perspective_rh_gl instead if you ever target OpenGL's -1..1 depth.

06Quaternions

An animation system stores thousands of bone orientations and blends between them every frame, and a camera has to turn smoothly through any pose, including straight down. Both need an orientation format that composes cheaply and has no dead spots. A is four numbers that encode an orientation. A unit quaternion q = (w, x, y, z) represents a rotation of angle ฮธ about a unit axis n as:

eq. 5 ยท axis-angle to quaternion q = ( cos(ฮธ/2), n sin(ฮธ/2) )

In words: a quaternion's four numbers are not arbitrary: pack the cosine of half the angle into the first slot, and the axis scaled by the sine of half the angle into the other three. The orientation is stored directly, with no sequence of separate spins to go wrong, which is exactly what dodges gimbal lock (next subsection).

The half-angle is intrinsic, not a quirk: it falls out of the sandwich product used to rotate a vector, vโ€ฒ = q v qโปยน (with v written as a pure quaternion, one whose real part is 0, and qโปยน = the conjugate for unit q: keep w, negate x, y and z).

Worked example

A 90° turn about +y: θ/2 = 45°, so q = (cos 45°, 0, sin 45°, 0) = (0.707, 0, 0.707, 0), and its conjugate is (0.707, 0, −0.707, 0).

The sandwich q (0, 1, 0, 0) q⁻¹ rotates the x axis to (0, 0, −1), which is where a right-handed 90° turn about y sends it. Negate all four numbers, (−0.707, 0, −0.707, 0), and the sandwich gives the same (0, 0, −1): the double cover below.

Two properties matter constantly. First, double cover: q and โˆ’q are the same rotation. That's harmless when you apply a quaternion and decisive when you interpolate two (next section). Second, the budget: four floats versus nine for a 3ร—3 matrix, and composition is one quaternion product versus a matrix multiply.

Versus Euler angles, and gimbal lock

A camera rig built from yaw, pitch and roll works until the player looks straight down; then yaw and roll start doing the same thing. Euler angles store an orientation as three sequential rotations (yaw, pitch, roll). They read nicely in an inspector and fail in a specific way:

What gimbal lock actually is

Gimbal lock is the loss of one degree of freedom that happens when the middle rotation of a yaw-pitch-roll sequence reaches ยฑ90ยฐ (for sequences that reuse an axis, such as Z-X-Z, the lock is at 0ยฐ and 180ยฐ), which lines up the first and third rotation axes so they spin about the same direction[10]. It is a property of the three-angle representation, not of 3D rotation. Quaternions and rotation matrices don't gimbal-lock because they don't decompose orientation into three sequential axis rotations.

Gimbal lock is not "caused by quaternions being missing" or "by 90ยฐ rotations in general"; it is the middle-axis degeneracy of a three-angle sequence. Quaternions trade that singularity for an ambiguity: q and โˆ’q are the same rotation, which matters when interpolating (ยง7). And a quaternion built from Euler angles inherits their lock at that pose; the benefit comes from composing rotations as quaternions instead of rebuilding them from three angles.

Worked example

Build orientations as R = Ry(yaw) · Rx(pitch) · Rz(roll), the order the widget below uses. At pitch 90°, yaw 30° with roll 0° and yaw 0° with roll −30° give the same matrix: both sliders now turn the model about one line, and one direction of rotation is gone.

At pitch 80° the same two settings give different matrices. Nothing about 30° is special; the middle angle reaching 90° is.

The rig below is three nested gimbals driven by Euler angles. Drive the middle ring to 90ยฐ and the outer and inner rings snap parallel; yaw and roll then produce the same motion. Flip to the quaternion track and each slider instead turns the model about its own axis, composed as a quaternion product, so the three sliders move it in three independent directions at any pose:

The three rings are the yaw, pitch and roll gimbals around the model, and the text at the top says how many independent axes are left. Push pitch to 90°, then move yaw and roll; then switch to the quaternion track and do the same. At pitch = ยฑ90ยฐ the yaw and roll rings are coplanar: a readout flags "1 DOF lost," and dragging yaw or roll now rotates the model the same way. The cause is the middle axis hitting 90ยฐ, not the magnitude of any single rotation. The quaternion track starts from the current pose and applies each slider's change as a turn about the model's own axis (q โ† q ยท ฮ”q); those axes stay perpendicular, so pitch the model 90ยฐ there and yaw and roll still differ.

Versus rotation matrices, and drift

Both represent the same rotations, and the GPU ultimately wants a matrix for the vertex transform, so quaternions are usually converted to a matrix at the end of the chain. Pick by operation: quaternions are cheaper to store, compose, interpolate, and renormalize; matrices are cheaper to apply to many vectors, which is why linear-blend skinning shaders usually consume matrices.

Repeated quaternion multiplication accumulates floating-point error, so a quaternion that should stay unit-length slowly drifts off the unit sphere; left uncorrected, it introduces scale/shear distortion when converted to a matrix. The fix is to renormalize periodically, every few multiplications, not necessarily after every single one. Rotation matrices drift the same way (they lose orthonormality) and need re-orthonormalization rather than mere column-normalization.

Worked example

Compose a 0.5° turn about a fixed axis onto a float quaternion once per frame and never renormalize. In one float32 run its length was off by about 5 × 10−6 after 1,000 frames and 4 × 10−4 after 100,000, about 28 minutes at 60 fps.

The error grows steadily rather than averaging out. A renormalize every few frames (compute the length, divide all four numbers by it) keeps it at rounding level.

07Interpolating rotations

A character blends from a run into a jump over a fifth of a second, and every bone's rotation has to pass smoothly from one orientation to the other. Blending two orientations is the daily job of an animation system. There are three contenders, and the "obvious correct" one is not always the right pick.

SLERP (spherical linear interpolation, Shoemake 1985[11]) walks the great-circle arc (the shortest path across the sphere's surface) between two unit quaternions at constant angular velocity, a steady turn rate from start to end:

eq. 6 ยท SLERP slerp(qโ‚€, qโ‚, t) = sin((1โˆ’t)ฮฉ) qโ‚€ + sin(tฮฉ) qโ‚sin ฮฉ , cos ฮฉ = qโ‚€ ยท qโ‚

In words: ฮฉ (omega) is the angle between the two orientations, read straight off their dot product (cos ฮฉ = qโ‚€ ยท qโ‚). The two sine weights slide from all-qโ‚€ at t = 0 to all-qโ‚ at t = 1, and because they ride the curve of the sphere rather than a straight chord, the blend turns at a steady rate the whole way across.

LERP interpolates the components in a straight line: a chord through the sphere, not along it. The result is not unit length and its rotation speed is non-uniform. NLERP is LERP followed by a renormalize; it rides the same arc as SLERP but not at constant angular velocity, running faster in the middle and slower at the ends[12].

When the difference actually matters

For small angular steps (adjacent animation keyframes, per-frame blends), NLERP is indistinguishable from SLERP, cheaper, and commutative, which is why many animation runtimes use it[12][13]. SLERP's constant-velocity property only becomes visible across large single interpolations, like a slow camera orbit over a wide arc. The framing (Blow, after Shoemake) is a three-way tradeoff: you want commutativity, constant velocity, and minimal torque, but you can't have all three. SLERP gives the last two; NLERP gives commutativity and minimal torque[13].

Worked example

Blend from no rotation, q₀ = (1, 0, 0, 0), to a 90° turn about y, q₁ = (0.707, 0, 0.707, 0). Then cos Ω = q₀·q₁ = 0.707, so Ω = 45°, half the turn. At t = 0.5 both sine weights are sin 22.5° / sin 45° = 0.541, giving (0.924, 0, 0.383, 0): a 45° turn, exactly halfway. LERP's midpoint is 0.5(q₀ + q₁), which has length 0.924, not 1.

At t = 0.25 SLERP gives 22.5° and NLERP 21.6°, 0.9° behind. For a 150° turn the same t gives 37.5° against 33.0°, 4.5° behind. The gap grows with the angle, which is why NLERP is safe between adjacent keyframes and visible on a wide camera swing.

The widget animates t from 0 to 1 over real seconds and draws all three paths plus a live angular-velocity strip. The circle is a slice through quaternion space, where a rotation of ฮธ puts qโ‚ at ฮธ/2 from qโ‚€ (the half angle of eq. 5). Crank the angle to make the methods diverge, and toggle off the shortest-path fix to see the classic bug:

The circle is a slice of quaternion space: q₀ on the right, q₁ at half the rotation angle, and three dots racing along the three paths, with each one's turn rate plotted in the strip below. At the starting 150° the readout shows q₀·q₁ = 0.26; drag the angle to 240° and it reads −0.50, and with the shortest-path box ticked the rotation traveled is 120°, not 240°. LERP's tip cuts inside the circle (non-unit). The velocity strip compares SLERP (flat) against NLERP, whose curve bulges in the middle, and LERP shares that exact profile, since renormalizing changes the radius, not the angle. Only SLERP holds a constant angular velocity. At small angles the three are nearly identical; the gap only opens up as you widen the angle. Past a 180ยฐ rotation, qโ‚€ยทqโ‚ < 0: with the fix on, the blend heads for โˆ’qโ‚ (hollow marker) and turns 360ยฐ โˆ’ ฮธ the short way; turn the fix off and it turns the full ฮธ the long way around (the readout shows the rotation traveled).

Wrong answers, and why: normalizing the inputs doesn't address which arc you travel (the double cover does); dropping to LERP trades the long-way bug for a non-unit, variable-speed result. And NLERP is the one without constant angular velocity, so that can't be its advantage.

08Building & decomposing transforms

An editor shows position, rotation and scale fields, the renderer wants one matrix, and a physics engine hands back a matrix you need to turn into fields again. Many engines store a local transform as translation, rotation (a quaternion), and scale, and build the matrix on demand. glTF defines a node's transform as exactly TยทRยทS with the rotation as a quaternion[7].

Composing is the easy direction, and it's what turns an editor's three fields into the one matrix the vertex shader applies:

eq. 7 ยท compose a transform M = T ยท R ยท S

In words: with column vectors you read the chain right to left, the order it actually runs: scale the object, then rotate it, then translate it into the world. Swap two of these and the result changes, because rotation and non-uniform scale do not commute (the ยง3 widget shows the shear).

Decomposing back out is where the edges are: translation is the last column, scale is the length of each of the three basis columns, and the rotation is what's left after dividing the columns by their scales.

Worked example

Build M = T(1, 2, 3) · R(90° about z) · S(2, 1, 1). Its first three columns are (0, 2, 0), (−1, 0, 0) and (0, 0, 1), and its last column holds the translation (1, 2, 3).

Decompose: the column lengths give scale (2, 1, 1); dividing each column by its length leaves (0, 1, 0), (−1, 0, 0) and (0, 0, 1), which is the 90° turn about z; and translation is (1, 2, 3). Everything comes back, because this matrix has no shear.

Trap: decomposition is lossy

Under shear or negative (mirrored) scale, you cannot cleanly split a matrix into rotation plus positive scale. glam's to_scale_rotation_translation documents only that the input "is expected to be a 3D affine transformation matrix otherwise the output will be invalid"[4]; a sheared matrix is still affine, so nothing flags it, and the rotation and scale that come back don't rebuild it. Two more from the same family: non-uniform scale doesn't commute with rotation, so TยทRยทS โ‰  TยทSยทR; and lighting needs the inverse-transpose of the upper 3ร—3 (its inverse, flipped across the diagonal) to transform normals under non-uniform scale, not the model matrix itself.

Worked example

A 45° slope has tangent (1, −1, 0) and normal (0.707, 0.707, 0). Stretch it 2× along x with S = diag(2, 1, 1): the tangent becomes (2, −1, 0).

Transform the normal by S itself and you get (1.41, 0.71, 0), whose dot with the new tangent is 2.12, not 0: no longer perpendicular, so the lighting is wrong. The inverse-transpose is diag(0.5, 1, 1), giving (0.35, 0.71, 0), normalized (0.45, 0.89, 0), whose dot with (2, −1, 0) is 0.

09Random numbers

Procedural placement, loot rolls, particle jitter, AI variation: games lean on random numbers, and the C standard library's rand() is the wrong tool for all of them.

rand() is implementation-defined: there's no required algorithm and no quality guarantee. Common implementations are LCGs (linear congruential generators, which compute state = a·state + c, mod m) whose low-order bits are weak, and RAND_MAX can be as small as 32767[15]. On top of that, rand() % n introduces modulo bias unless n divides RAND_MAX + 1[15].

Worked example

With RAND_MAX = 32767, rand() has 32,768 possible values. For rand() % 10, 32,768 = 3,276 × 10 + 8, so 0 to 7 each come from 3,277 values and 8 and 9 from 3,276: a bias you'd never see.

For rand() % 20000, 32,768 = 1 × 20,000 + 12,768, so results 0 to 12,767 each come from two values and 12,768 to 19,999 from one. The lower 64% of the range is twice as likely as the rest: a loot table indexed this way drops its first entries twice as often.

Reach for a modern small-state generator. PCG applies a permutation to an LCG's output: small fast state, strong statistical quality, multiple independent streams[14]. The xoshiro/xoroshiro family is comparably fast with tiny state. Both are plenty for gameplay.

Three things to get right
PCG32, a usable generator in a dozen lines
// PCG32 (O'Neill). State is 64 bits; output is a permuted 32-bit word.
struct Pcg32 {
    uint64_t state = 0x853c49e6748fea9bULL;
    uint64_t inc   = 0xda3e39cb94b95bdbULL;   // stream selector, must be odd

    uint32_t next() {
        uint64_t oldState = state;
        state = oldState * 6364136223846793005ULL + inc;   // LCG step
        // Output uses the OLD state, so the permutation overlaps the next step.
        uint32_t xorshifted = (uint32_t)(((oldState >> 18) ^ oldState) >> 27);
        uint32_t rotation = (uint32_t)(oldState >> 59);   // top 5 bits pick the rotate
        // Rotate right by `rotation`; (32 - rotation) & 31 keeps the shift below 32.
        return (xorshifted >> rotation) | (xorshifted << ((32 - rotation) & 31));
    }
};
// PCG32 (O'Neill). Same multiplier and permutation; seed it with the same
// constants as the C++ tab: state 0x853c49e6748fea9b, inc 0xda3e39cb94b95bdb.
struct Pcg32 { state: u64, inc: u64 }   // inc (stream) must be odd

impl Pcg32 {
    fn next_u32(&mut self) -> u32 {
        let old_state = self.state;
        self.state = old_state.wrapping_mul(6364136223846793005).wrapping_add(self.inc);
        // Output uses the OLD state, so the permutation overlaps the next step.
        let xorshifted = (((old_state >> 18) ^ old_state) >> 27) as u32;
        let rotation = (old_state >> 59) as u32;   // top 5 bits pick the rotate
        xorshifted.rotate_right(rotation)   // the permutation, as one intrinsic
    }
}
// In practice: the `rand_pcg` / `rand` crates ship this and the bounded sampling.

Worked example

Seeded with the constants above, both listings return 0x152ca78d, 0x027c6003 and 0xcb07bbf3 on their first three calls. A port that prints anything else has a shift, the rotate or the output state wrong; the output must come from the old state, before the LCG step.

What's intentionally missing

These generators show the core step only. A shipping RNG adds: unbiased bounded sampling (rejection or Lemire), a documented seeding routine, float generation in [0, 1), and a seek/stream API for reproducible replays. Use the library versions (rand_pcg in Rust, the reference PCG headers in C++) rather than hand-rolling those parts.

10Pitfalls

Most bugs in this tutorial are convention mismatches. Symptom first, then the convention behind it:

Whole scene upside downVulkan's Y-down NDC, no Y-flip in the projection or viewport.
Model mirrored, faces inside outHandedness mismatch on import flips an axis and inverts winding.
Objects fly to infinityTranslation written into the wrong row/column for your vector convention.
Z-fighting in the distanceStandard depth mapping crowds precision near the camera; reversed-Z with a floating-point depth buffer spreads it out.
Geometry clipped close to the cameraAn OpenGL โˆ’1โ€ฆ1 projection fed to Vulkan's 0โ€ฆ1 clip volume.
Object shears when scaledNon-uniform scale composed in the wrong order, or decomposed from a sheared matrix.
Camera spins the long waySLERP without the qโ‚€ยทqโ‚ < 0 shortest-path check.
NaNs spreading through transformsNormalizing a zero-length vector.
Lighting wrong on scaled meshesTransforming normals with the model matrix instead of its inverse-transpose.

11What's next

Next in the series: IEEE-754 Floating Point. Every number on this page is a float, and float rounding is why a quaternion drifts off unit length and why a fixed timestep alone doesn't guarantee identical results across machines. The next page covers how floats are stored and where they round.

This is Phase 2 of Build a Game Engine. After floating point come The Game Loop & Time, the Platform layer, and your first triangle in Vulkan, where the clip-space conventions from ยง5 stop being abstract.

  1. Eric Lengyel. Foundations of Game Engine Development, Vol. 1: Mathematics. Terathon Software, 2016. The primary textbook reference for the vector, matrix, and quaternion fundamentals here.
  2. Tomas Akenine-Mรถller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 4 "Transforms." realtimerendering.com. Supports the row- vs column-vector conventions, transform composition, and quaternions.
  3. G-Truc. glm manual. github.com/g-truc/glm. Column-major storage, column vectors, right-handed and [โˆ’1,1] depth defaults; GLM_FORCE_DEPTH_ZERO_TO_ONE and the _ZO/_NO projection variants.
  4. Cameron Hart. glam documentation. docs.rs/glam. Column-major storage (public x_axisโ€ฆw_axis columns); perspective_rh ([0,1]) vs perspective_rh_gl ([โˆ’1,1]); to_scale_rotation_translation's requirement that the input be a 3D affine transform.
  5. Matthew Wellings. "The New Vulkan Coordinate System." matthewwellings.com. Vulkan's Y-down NDC, [0,1] depth, right-handed NDC (OpenGL's is left-handed), and a projection-matrix correction for porting OpenGL code.
  6. The Khronos Group. Vulkan Specification, vertex post-processing (clip volume and viewport transform). docs.vulkan.org. The clip-volume bound 0 โ‰ค zc โ‰ค wc.
  7. The Khronos Group. glTF 2.0 Specification. registry.khronos.org. Right-handed, +Y up, meters and radians, node transform as TยทRยทS with a quaternion rotation.
  8. Epic Games. "Coordinate System and Spaces in Unreal Engine." dev.epicgames.com. Left-handed (cross product and axes follow the left-hand rule), +Z up, +X forward, and the handedness warning for assets imported from other tools. With "Units of Measurement in Unreal Engine," dev.epicgames.com: centimeters as the default distance unit.
  9. Unity Technologies. "Quaternion and euler rotations in Unity," Unity 6.3 Manual. docs.unity3d.com. Left-handed with +X right, +Y up, +Z forward; rotations stored internally as quaternions and shown as Euler angles, applied Z, then X, then Y.
  10. "Gimbal lock." Wikipedia. en.wikipedia.org/wiki/Gimbal_lock. The loss of one DOF when the middle Euler axis reaches ยฑ90ยฐ, as a representation artifact.
  11. Ken Shoemake. "Animating Rotation with Quaternion Curves." SIGGRAPH 1985, Computer Graphics 19(3), 245โ€“254. dl.acm.org. The origin of SLERP (and SQUAD); constant-angular-velocity interpolation.
  12. Arseny Kapoulkine. "Approximating slerp." zeux.io. NLERP follows the SLERP arc but not at constant velocity; small-angle equivalence and the sin ฮฉ โ†’ 0 fallback.
  13. Jonathan Blow. "Understanding Slerp, Then Not Using It." number-none.com. The commutativity / constant-velocity / minimal-torque tradeoff and the case for NLERP in games.
  14. Melissa O'Neill. "PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation." 2014. pcg-random.org. The permutation-on-LCG construction and its statistical quality.
  15. Paul Hsieh. "Misconceptions about rand()." azillionmonkeys.com. rand() is implementation-defined with weak low bits and a possibly small RAND_MAX; % n introduces modulo bias.
  16. The Khronos Group. VK_KHR_maintenance1 reference page. docs.vulkan.org. Allows a negative VkViewport::height to invert Y in the clip-to-framebuffer transform; all its functionality is core in Vulkan 1.1.

See also