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

3D Math for Games

Vectors, matrices, and quaternions 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 tutorial builds the math from scratch in C++ and Rust, then pins down the conventions Vulkan actually wants, so your first triangle comes out right-side up.

Time~60 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 โ–ธ

01The conventions are the hard part

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.

What you'll have by the end

Working Vec3, Mat4, and Quat in both languages, the modelโ†’viewโ†’projection chain assembled correctly for Vulkan, a precise account of why your first triangle was upside down, and an honest read on quaternions versus Euler angles, including when NLERP beats SLERP and when it doesn't.

02Vectors

A 3D vector is three numbers, but the two products you do with it carry most of the geometry in a game: 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:

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

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, 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).

The (3D only) returns a vector perpendicular to both inputs, with length equal to the area of the parallelogram they span:

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

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.

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:

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

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:

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

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.

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

a is fixed; drag the tip of b or use the sliders. 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 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.

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:

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 vertex passes through a chain of spaces: object space โ†’ world space โ†’ view (camera) space โ†’ clip space โ†’ NDC โ†’ 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.

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

The three matrices that 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 the divide 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.

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 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.

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:

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

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

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, and qโปยน = the conjugate for unit q).

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

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.

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:

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.

07Interpolating rotations

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 between two unit quaternions at constant angular velocity:

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

ฮฉ (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].

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:

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

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:

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

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.

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 to transform normals under non-uniform scale, not the model matrix itself.

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 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].

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.
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

This is Phase 2 of Build a Game Engine. The next module is IEEE-754 Floating Point, the other half of the numerics phase and the reason a fixed timestep alone doesn't guarantee identical results across machines. After it 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