Every object on screen has a position. Every camera looks in some direction. Every character rotates, scales, and moves every frame. All of that is built from a small set of math tools: vectors, dot products, cross products, and matrices. This chapter builds those tools from scratch in C++, using a tiny Vec2/Vec3 struct, so you can see exactly what an engine like Unity or Unreal is doing with your position and rotation on every single frame.
Each idea below follows the same shape as always: a small runnable program, its real printed output, then a plain explanation of what happened. Type them in and run them if you want to check your own compiler agrees.
In earlier chapters, a pair of numbers like x, y usually described a location — where a struct lives, or where a tile sits on a grid. In this chapter the same two numbers sometimes mean a location, and sometimes mean something different: a direction and a distance. Both are stored as the exact same floats, but they mean different things, and mixing them up is a common source of bugs.
std::vector!) answers "which way, and how far/strong". Example: the wind blows toward (1, 0), meaning one unit east and zero north.A vector has two properties: a direction (which way it points) and a magnitude (how long it is — also called its length). A point has neither; it is just a location. Here is the small struct we will use for the rest of this chapter, and a program storing both kinds of data in it:
#include <iostream>
struct Vec2 {
float x, y;
};
int main() {
Vec2 playerPos = {3.0f, 4.0f}; // a POINT: a location in space
Vec2 windDir = {1.0f, 0.0f}; // a VECTOR: a direction, no fixed location
std::cout << "player at (" << playerPos.x << ", " << playerPos.y << ")\n";
std::cout << "wind blows toward (" << windDir.x << ", " << windDir.y << ")\n";
}
Output:
player at (3, 4)
wind blows toward (1, 0)
The Vec2 struct itself does not know which one you mean — playerPos and windDir are both just two floats sitting next to each other in memory. The meaning comes from how you use them. Keep this distinction in mind for the rest of the chapter — some operations (like the ones in the next section) only make sense for one or the other.
Vectors support a small set of operations, and each one has a clear meaning in a game:
Every operation just works component by component — no coupling between x and y:
#include <iostream>
struct Vec2 {
float x, y;
};
Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s){ return { a.x * s, a.y * s }; }
int main() {
Vec2 pos = {2, 3}; // a point
Vec2 vel = {1, -1}; // a vector: move 1 right, 1 down per step
Vec2 nextPos = add(pos, vel); // point + vector = a new point
Vec2 diff = sub(nextPos, pos); // point - point = the vector between them
Vec2 doubled = scale(vel, 2.0f); // same direction, twice as long
Vec2 reversed = scale(vel, -1.0f); // same length, opposite direction
std::cout << "nextPos = (" << nextPos.x << ", " << nextPos.y << ")\n";
std::cout << "diff = (" << diff.x << ", " << diff.y << ")\n";
std::cout << "doubled = (" << doubled.x << ", " << doubled.y << ")\n";
std::cout << "reversed = (" << reversed.x << ", " << reversed.y << ")\n";
}
Output:
nextPos = (3, 2)
diff = (1, -1)
doubled = (2, -2)
reversed = (-1, 1)
add(pos, vel) moved the player's position by its velocity — one simulation step. sub(nextPos, pos) recovers exactly the vector we started with, because subtracting one point from another always answers "how far, and which way, between them". scale(vel, 2.0f) doubled the vector's magnitude but kept its direction; scale(vel, -1.0f) kept the same magnitude but flipped the direction 180 degrees — a common trick for a bounce-back or recoil effect.
enemyPos + playerPos. That produces a meaningless location — points do not add. If you want the midpoint between two points, use scale(add(a, b), 0.5f), and think of it as an "average", not a "sum".A vector's length (also called its magnitude) is how far it reaches. For a 2D vector this is the Pythagorean theorem: length = sqrt(x*x + y*y). In 3D you add a z*z term inside the square root.
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float length(Vec2 v) {
return std::sqrt(v.x * v.x + v.y * v.y);
}
Vec2 normalize(Vec2 v) {
float len = length(v);
return { v.x / len, v.y / len };
}
int main() {
Vec2 v = {3, 4};
std::cout << "length = " << length(v) << "\n";
Vec2 u = normalize(v);
std::cout << "unit vector = (" << u.x << ", " << u.y << ")\n";
std::cout << "length of unit vector = " << length(u) << "\n";
}
Output:
length = 5
unit vector = (0.6, 0.8)
length of unit vector = 1
That is the classic 3-4-5 right triangle. normalize divides every component by the length, which shrinks (or grows) the vector until its length is exactly 1 while keeping its direction unchanged. A vector with length 1 is called a unit vector, and turning any vector into one is called normalizing it.
Why bother? Because a lot of the time you only care about direction, not distance: which way is the player facing, which way does this surface point, which way should this bullet travel. Normalizing throws away the magnitude and keeps only the direction, which is exactly what those questions need.
inf or NaN ("not a number") — a floating-point value that silently poisons every later calculation it touches. Real code checks if (length > 0.00001f) (some tiny "epsilon" threshold) before dividing, and decides on a fallback direction if the vector is too short to normalize safely.The dot product takes two vectors and returns a single plain number (a scalar), not another vector. In 2D the formula is dot(a, b) = a.x*b.x + a.y*b.y; in 3D you add a.z*b.z.
#include <iostream>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) {
return a.x * b.x + a.y * b.y;
}
int main() {
Vec2 right = {1, 0};
Vec2 up = {0, 1};
Vec2 diag = {1, 1};
Vec2 left = {-1, 0};
std::cout << "right . up = " << dot(right, up) << "\n";
std::cout << "right . diag = " << dot(right, diag) << "\n";
std::cout << "right . left = " << dot(right, left) << "\n";
}
Output:
right . up = 0
right . diag = 1
right . left = -1
Look at the pattern in the sign of the result: perpendicular gives 0, a 45-degree vector gives a positive number, and a vector pointing the opposite way gives a negative number. That is the whole idea of the dot product: it measures alignment — how much two vectors point in the same direction.
There is a second formula for the same number that explains why: a . b = |a| * |b| * cos(theta), where |a| and |b| are the lengths and theta is the angle between the two vectors. Since cos is positive below 90 degrees, zero at 90 degrees, and negative above 90 degrees, the sign of the dot product tells you exactly whether the angle between two vectors is acute, right, or obtuse — without ever computing the angle itself. This single number shows up constantly in games: lighting (how much a surface faces a light), AI vision cones, and steering all lean on it.
Rearranging that second formula gives you the actual angle: theta = acos( dot(a, b) / (length(a) * length(b)) ). acos ("arc cosine") is the inverse of cosine — it turns a cosine value back into an angle.
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }
float angleDegrees(Vec2 a, Vec2 b) {
float cosTheta = dot(a, b) / (length(a) * length(b));
float radians = std::acos(cosTheta);
return radians * 180.0f / 3.14159265f;
}
int main() {
Vec2 right = {1, 0};
Vec2 diag = {1, 1};
Vec2 steep = {3, 4};
std::cout << "angle(right, diag) = " << angleDegrees(right, diag) << " degrees\n";
std::cout << "angle(right, steep) = " << angleDegrees(right, steep) << " degrees\n";
}
Output:
angle(right, diag) = 45 degrees
angle(right, steep) = 53.1301 degrees
(1, 1) really does sit exactly halfway between "right" and "up", so 45 degrees is correct. (3, 4) is the same vector from section 3's triangle — a bit steeper than 45 degrees, and the math agrees: about 53 degrees.
Game AI uses this constantly. If a guard is facing some direction, and you know the vector from the guard to a target, the sign of the dot product tells you instantly whether the target is roughly in front of the guard or behind it — no trigonometry needed.
#include <iostream>
struct Vec2 {
float x, y;
};
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
bool isInFront(Vec2 selfPos, Vec2 facing, Vec2 targetPos) {
Vec2 toTarget = sub(targetPos, selfPos); // direction from self to target
return dot(facing, toTarget) > 0.0f; // positive = same side as facing
}
int main() {
Vec2 selfPos = {0, 0};
Vec2 facing = {0, 1}; // guard is looking "north"
Vec2 targetA = {3, 4}; // ahead of the guard
Vec2 targetB = {1, -2}; // behind the guard
std::cout << "targetA in front? " << isInFront(selfPos, facing, targetA) << "\n";
std::cout << "targetB in front? " << isInFront(selfPos, facing, targetB) << "\n";
}
Output:
targetA in front? 1
targetB in front? 0
std::cout prints a bool as 1 for true and 0 for false unless you ask it to spell the words out (with std::boolalpha). Target A is up and to the right of the guard, which is on the same general side as "north", so the dot product is positive. Target B is behind, so it is negative. A full vision-cone check would also compare the angle against a maximum field of view, but the front/behind test alone is often enough for simple AI.
Projection answers "how much of vector a points along the direction of vector b?" It is what makes a character slide smoothly along a wall instead of stopping dead when it bumps into one — you project the velocity onto the wall's direction and keep only that part.
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
int main() {
Vec2 v = {5, 3}; // a character's velocity
Vec2 wallDir = {1, 1}; // the direction a diagonal wall runs (not unit length)
float scalarProj = dot(v, wallDir) / length(wallDir); // how far along the wall
Vec2 vectorProj = scale(wallDir, dot(v, wallDir) / dot(wallDir, wallDir)); // the sliding velocity
std::cout << "scalar projection = " << scalarProj << "\n";
std::cout << "vector projection = (" << vectorProj.x << ", " << vectorProj.y << ")\n";
}
Output:
scalar projection = 5.65685
vector projection = (4, 4)
The scalar projection is a plain number: how far a reaches along b's direction. The vector projection turns that back into an actual vector pointing along b. The formula (dot(a,b) / dot(b,b)) * b works for a b of any length, because dividing by dot(b,b) (which is length(b) squared) cancels out b's own length before scaling.
Projection kept the part of a vector that lies along a direction. Two close cousins keep or flip the other part — the piece perpendicular to it — and both are everyday tools in a game. Both are written most cleanly against a surface's unit normal n (a unit vector pointing straight out of the surface):
reject(a, n) = a - (a . n) n. It drops the part of a heading into the surface and keeps the part that slides along it.reflect(a, n) = a - 2 (a . n) n. It removes the into-surface part twice, so the vector bounces back out.#include <iostream>
struct Vec2 { float x, y; };
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
// the part of a perpendicular to UNIT vector n (a with its n-component removed)
Vec2 reject(Vec2 a, Vec2 n) { return sub(a, scale(n, dot(a, n))); }
// mirror a across the surface whose UNIT normal is n
Vec2 reflect(Vec2 a, Vec2 n) { return sub(a, scale(n, 2.0f * dot(a, n))); }
int main() {
Vec2 vel = {5, 3}; // a character's velocity
Vec2 wallN = {1, 0}; // wall runs vertically; its normal points along +x
Vec2 slide = reject(vel, wallN); // slide along the wall (drop the into-wall part)
Vec2 bounce = reflect(vel, wallN); // bounce off the wall
std::cout << "slide = (" << slide.x << ", " << slide.y << ")\n";
std::cout << "bounce = (" << bounce.x << ", " << bounce.y << ")\n";
}
Output:
slide = (0, 3)
bounce = (-5, 3)
The slide is exactly the "throw away the into-wall part" idea from the projection diagram above, now packaged as one function: keep only the motion along the wall so the character grazes past it instead of stopping dead. The bounce goes one step further and removes that inward part a second time, sending the vector back out at an equal and opposite angle — the basis of a bouncing projectile, a ricochet, or a pool ball. Both formulas assume n has length 1; if it does not, divide the (a . n) term by dot(n, n) first, exactly as the projection formula did.
n is twice as long as it should be, the (a . n) n term grows by a factor of four, and your "bounce" comes out far too strong and pointing the wrong way. Normalize surface normals once, up front, and these formulas stay simple.The cross product is different from the dot product in an important way: it takes two 3D vectors and returns a third vector, not a number. That new vector is always perpendicular (at a 90-degree angle) to both of the original two.
#include <iostream>
struct Vec3 {
float x, y, z;
};
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float dot(Vec3 a, Vec3 b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
int main() {
Vec3 right = {1, 0, 0};
Vec3 up = {0, 1, 0};
Vec3 n = cross(right, up);
std::cout << "right x up = (" << n.x << ", " << n.y << ", " << n.z << ")\n";
// proof that n is perpendicular to BOTH inputs: dot product should be 0
std::cout << "n . right = " << dot(n, right) << "\n";
std::cout << "n . up = " << dot(n, up) << "\n";
}
Output:
right x up = (0, 0, 1)
n . right = 0
n . up = 0
The two dot products confirm it: the result is perpendicular to both right and up, exactly as promised. Notice also that cross(a, b) and cross(b, a) point in opposite directions — the cross product is anti-commutative. Order matters here too, a theme that comes back hard when we get to matrices.
A surface normal is a unit vector that points straight out of a flat surface — lighting, reflections, and physics all need it. For a triangle with corners A, B, C, build two edge vectors from one corner, then cross them:
#include <iostream>
#include <cmath>
struct Vec3 {
float x, y, z;
};
Vec3 sub(Vec3 a, Vec3 b) {
return { a.x - b.x, a.y - b.y, a.z - b.z };
}
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float length(Vec3 v) {
return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
Vec3 normalize(Vec3 v) {
float len = length(v);
return { v.x / len, v.y / len, v.z / len };
}
int main() {
Vec3 A = {0, 0, 0};
Vec3 B = {4, 0, 0};
Vec3 C = {0, 3, 0};
Vec3 edge1 = sub(B, A);
Vec3 edge2 = sub(C, A);
Vec3 rawNormal = cross(edge1, edge2);
Vec3 normal = normalize(rawNormal);
std::cout << "raw normal = (" << rawNormal.x << ", " << rawNormal.y << ", " << rawNormal.z << ")\n";
std::cout << "unit normal = (" << normal.x << ", " << normal.y << ", " << normal.z << ")\n";
}
Output:
raw normal = (0, 0, 12)
unit normal = (0, 0, 1)
The triangle sits flat in the x-y plane, so its normal has to point straight along z — and it does: (0, 0, 1). This is exactly how a 3D model's lighting normals are computed from its raw triangle data before anything gets rendered.
The length of the raw (non-normalized) cross product equals the area of the parallelogram that the two edge vectors would sweep out. Since a triangle is exactly half of that parallelogram, dividing by 2 gives the triangle's area — a nice bonus fact.
#include <iostream>
#include <cmath>
struct Vec3 {
float x, y, z;
};
Vec3 sub(Vec3 a, Vec3 b) {
return { a.x - b.x, a.y - b.y, a.z - b.z };
}
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float length(Vec3 v) {
return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
int main() {
Vec3 A = {0, 0, 0};
Vec3 B = {4, 0, 0};
Vec3 C = {0, 3, 0};
Vec3 edge1 = sub(B, A);
Vec3 edge2 = sub(C, A);
float parallelogramArea = length(cross(edge1, edge2));
float triangleArea = parallelogramArea * 0.5f;
std::cout << "parallelogram area = " << parallelogramArea << "\n";
std::cout << "triangle area = " << triangleArea << "\n";
}
Output:
parallelogram area = 12
triangle area = 6
Check it by hand: this is a right triangle with legs 4 and 3, so its area is 0.5 * 4 * 3 = 6. The cross product agrees, and unlike a hand formula, this one works for a triangle floating anywhere in 3D space, tilted any way at all.
A matrix is a grid of numbers. On its own it does nothing — but multiply it by a vector, and it produces a new vector. That is the whole idea: a matrix is a reusable "recipe" for turning any vector into another vector, following the same rule every time. Rotate 30 degrees, scale by 2, flip upside down — each of those recipes can be written down as a matrix once, then applied to as many points as you like.
Each entry of the result is a row of the matrix, multiplied component-by-component against the vector, then summed. The simplest possible matrix is the identity matrix: it has 1s down the diagonal and 0s everywhere else, and it changes nothing at all — the "do nothing" transform, useful as a starting point and for testing.
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2]; // m[row][col]
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y, // new x
M.m[1][0] * v.x + M.m[1][1] * v.y // new y
};
}
int main() {
Mat2 identity = { { {1, 0},
{0, 1} } };
Vec2 p = {3, 4};
Vec2 r = mul(identity, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(3, 4)
(3, 4) went in, (3, 4) came out — unchanged, exactly as promised. Now let us build matrices that actually do something.
A scale matrix multiplies x by one number and y by another, independently. Put those two numbers on the diagonal and zero everywhere else:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
Mat2 scaleMatrix(float sx, float sy) {
return { { {sx, 0},
{0, sy} } };
}
int main() {
Mat2 S = scaleMatrix(2.0f, 0.5f); // twice as wide, half as tall
Vec2 p = {10, 10};
Vec2 r = mul(S, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(20, 5)
A rotation matrix uses sine and cosine of the rotation angle to spin a vector around the origin: [cos(theta), -sin(theta); sin(theta), cos(theta)]. Rotating (1, 0) by 90 degrees counter-clockwise should land it on (0, 1):
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
Mat2 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s},
{s, c} } };
}
int main() {
Mat2 R = rotationMatrix(90.0f); // rotate 90 degrees counter-clockwise
Vec2 p = {1, 0};
Vec2 r = mul(R, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(-4.37114e-08, 1)
Almost (0, 1) — but not quite. That tiny -4.37114e-08 (a number close to 0.0000000437) is floating-point rounding error, not a bug. 90 degrees converted to radians cannot be stored exactly as a float, so cos of "almost exactly 90 degrees" comes back as "almost exactly 0" instead of a perfect 0. In a real game this is completely invisible on screen — but it is worth recognizing the pattern the first time you see it, instead of panicking that your rotation math is broken.
You might expect a translation (move) matrix to be just as easy. It is not — and the reason matters. A 2x2 matrix multiply is what mathematicians call a linear function, and one property of every linear function is that it always sends the origin (0, 0) to (0, 0). No combination of scaling and rotating can ever move the origin, because both operations only stretch and spin things around it.
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
int main() {
Mat2 anyMatrix = { { {2, 0},
{0, 3} } }; // could be any scale or rotation at all
Vec2 origin = {0, 0};
Vec2 r = mul(anyMatrix, origin);
std::cout << "(" << r.x << ", " << r.y << ")\n"; // still the origin!
}
Output:
(0, 0)
So how does a game move anything? The trick is to add a third, fake coordinate that is always 1, and use a 3x3 matrix instead of a 2x2 one. The extra column lets a constant offset sneak into the result — that offset is the translation. We will explain exactly why this works, and what that "extra 1" really means, in section 10. For now, just see that it works:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
// treat the point as (x, y, 1) -- that extra 1 is explained later in this chapter
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
int main() {
Mat3 T = translateMatrix(5, 3);
Vec2 origin = {0, 0};
Vec2 point = {2, 2};
Vec2 r1 = transformPoint(T, origin);
Vec2 r2 = transformPoint(T, point);
std::cout << "origin moved to (" << r1.x << ", " << r1.y << ")\n";
std::cout << "point moved to (" << r2.x << ", " << r2.y << ")\n";
}
Output:
origin moved to (5, 3)
point moved to (7, 5)
The origin really did move to (5, 3), exactly the translation amount, and (2, 2) landed on (7, 5) — 2+5 and 2+3. That third row and column made a plain addition ride along inside a matrix multiply.
Real objects need more than one transform at once — scale, then rotate, then move to a world position. You could apply each matrix to a point one at a time, but it is far more useful to combine the matrices first into a single matrix, then apply that one matrix to every vertex of a mesh. Combining two matrices is done with matrix multiplication: combined = A * B produces one matrix that has the exact same effect as applying B first and then A.
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
Mat3 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s, 0},
{s, c, 0},
{0, 0, 1} } };
}
// standard 3x3 matrix multiply: result = A * B
Mat3 mul(const Mat3& A, const Mat3& B) {
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
int main() {
Mat3 R = rotationMatrix(90.0f);
Mat3 T = translateMatrix(5.0f, 0.0f);
Mat3 rotateThenTranslate = mul(T, R); // T * R: R happens first, then T
Mat3 translateThenRotate = mul(R, T); // R * T: T happens first, then R
Vec2 p = {1, 0};
Vec2 a = transformPoint(rotateThenTranslate, p);
Vec2 b = transformPoint(translateThenRotate, p);
std::cout << "rotate then translate = (" << a.x << ", " << a.y << ")\n";
std::cout << "translate then rotate = (" << b.x << ", " << b.y << ")\n";
}
Output:
rotate then translate = (5, 1)
translate then rotate = (-2.62268e-07, 6)
(The -2.62268e-07 is the same kind of floating-point rounding from section 7 — treat it as 0.)
This is the single most important habit to build around matrices: matrix multiplication does not commute — A * B is not the same as B * A. Rotating first and then moving 5 units right lands you at (5, 1). Moving 5 units right first and then rotating around the (still-at-the-origin) pivot swings that whole offset around too, landing you at (0, 6). Both used the exact same rotation and the exact same translation — only the order changed, and the results are nowhere near each other. In code, mul(A, B) applies B first (it is closest to the point when you read the multiplication right to left) and A second. Get this backwards in an engine and your objects will fly off to bizarre positions the moment you add rotation to a moving object.
model = T * R * S, so that S (closest to the point) runs first. Scale and rotate around the object's own local origin first, then move the whole already-shaped object out to its position in the world last.Now we can explain how a 3D point on a character's mesh ends up as a 2D pixel on your monitor. It travels through three matrices, applied in sequence:
w discussion in section 10.Here is a simplified 2D version of the whole trip, using the matrices we already built, plus a plain orthographic (no-perspective) projection at the end, so every number is one you can check by hand:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
Mat3 scaleMatrix(float sx, float sy) {
return { { {sx, 0, 0},
{0, sy, 0},
{0, 0, 1} } };
}
Mat3 mul(const Mat3& A, const Mat3& B) {
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
int main() {
// -- MODEL: place a local mesh vertex into the game world --
Vec2 localVertex = {1, 1}; // corner of a unit square, in the mesh's own space
Mat3 model = mul(translateMatrix(10, 5), scaleMatrix(2, 2)); // scale x2, then move to world pos (10,5)
Vec2 worldVertex = transformPoint(model, localVertex);
// -- VIEW: re-measure the world relative to the camera --
Vec2 cameraPos = {10, 0};
Mat3 view = translateMatrix(-cameraPos.x, -cameraPos.y);
Vec2 viewVertex = transformPoint(view, worldVertex);
// -- PROJECTION: squash the visible [-10,10] range down to [-1,1] (NDC) --
float ndcX = viewVertex.x / 10.0f;
float ndcY = viewVertex.y / 10.0f;
// -- VIEWPORT: map NDC [-1,1] onto an 800x600 pixel screen (Y flipped: screen Y grows downward) --
float screenX = (ndcX * 0.5f + 0.5f) * 800.0f;
float screenY = (1.0f - (ndcY * 0.5f + 0.5f)) * 600.0f;
std::cout << "world = (" << worldVertex.x << ", " << worldVertex.y << ")\n";
std::cout << "view = (" << viewVertex.x << ", " << viewVertex.y << ")\n";
std::cout << "ndc = (" << ndcX << ", " << ndcY << ")\n";
std::cout << "screen = (" << screenX << ", " << screenY << ")\n";
}
Output:
world = (12, 7)
view = (2, 7)
ndc = (0.2, 0.7)
screen = (480, 90)
Trace it through: the local corner (1, 1) gets scaled to (2, 2), then moved to world position (12, 7). The camera sits at (10, 0), so relative to the camera the point is at (2, 7) — 2 units to the right, 7 units up. The projection squashes the visible -10..10 range down to -1..1, giving (0.2, 0.7). Finally the viewport step stretches that -1..1 square out to an 800x600 pixel window (flipping Y, since screen rows usually count downward), landing the vertex at pixel (480, 90) — a bit right of center, and near the top of the screen, which matches a point that was "up and to the right" of the camera. A real 3D pipeline adds a z axis and true perspective (which needs the w value from the next section), but the shape of the pipeline — model, then view, then projection, then viewport — is identical.
Back in section 7, translation needed an "extra 1" tacked onto every point. That extra number has a name: homogeneous coordinates add a fourth value, usually called w, alongside x, y, z. A full 3D engine uses 4x4 matrices and 4-value vectors (x, y, z, w) everywhere, for exactly the reason you are about to see.
w is not just a fixed 1 — it carries real meaning. A point (a location) uses w = 1. A pure direction (like a surface normal or a ray direction) uses w = 0. Watch what that does to a translation matrix:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
// this time w is a real parameter, not always 1
Vec2 transform(const Mat3& M, Vec2 v, float w) {
float x = M.m[0][0] * v.x + M.m[0][1] * v.y + M.m[0][2] * w;
float y = M.m[1][0] * v.x + M.m[1][1] * v.y + M.m[1][2] * w;
return { x, y };
}
int main() {
Mat3 T = translateMatrix(5, 3);
Vec2 point = transform(T, {1, 1}, 1.0f); // a POINT: w = 1
Vec2 direction = transform(T, {1, 1}, 0.0f); // a DIRECTION: w = 0
std::cout << "point moved to (" << point.x << ", " << point.y << ")\n";
std::cout << "direction stayed (" << direction.x << ", " << direction.y << ")\n";
}
Output:
point moved to (6, 4)
direction stayed (1, 1)
With w = 1, the translation column (tx, ty) gets added in fully, moving the point. With w = 0, that same column gets multiplied by zero and vanishes, leaving the direction untouched. This is exactly why the difference between a point and a vector from section 1 is not just a naming convention — the engine encodes it numerically, and it changes how the same matrix treats the same pair of numbers.
w has one more trick. After a perspective projection matrix (not the flat, orthographic one we used in section 9), w stops being just 0 or 1 — it becomes a number related to depth (distance from the camera). The GPU then divides x, y, and z by w, a step called the perspective divide. Dividing by a bigger number shrinks the result more, and that shrinking toward the center of the screen is exactly what makes far-away objects look smaller.
#include <iostream>
int main() {
// x and y BEFORE the perspective divide; w carries the depth (distance from camera)
float x = 4.0f, y = 4.0f;
float wNear = 2.0f; // a close object
float wFar = 8.0f; // a far-away object, same x and y before dividing
std::cout << "near: (" << x / wNear << ", " << y / wNear << ")\n";
std::cout << "far: (" << x / wFar << ", " << y / wFar << ")\n";
}
Output:
near: (2, 2)
far: (0.5, 0.5)
Same x and y going in, but the far object's larger w divides them down to a point four times closer to the screen center — smaller and nearer the middle, which is exactly how perspective looks to your eyes. This single divide, riding along inside the fourth coordinate, is the entire trick behind 3D perspective rendering.
Section 6 called a matrix a "machine that transforms vectors" and left it at that. There is a simpler picture that makes every matrix readable at a glance: the columns of a matrix are where the basis vectors land. The basis vectors are just the unit axes — (1, 0) for x and (0, 1) for y. Whatever a matrix does to those two arrows, written side by side, is the matrix.
#include <iostream>
struct Vec2 { float x, y; };
struct Mat2 { float m[2][2]; };
Vec2 mul(Mat2 M, Vec2 v) {
return { M.m[0][0]*v.x + M.m[0][1]*v.y,
M.m[1][0]*v.x + M.m[1][1]*v.y };
}
int main() {
// a matrix whose COLUMNS are the vectors we want the axes to land on:
// x-axis (1,0) -> (2, 1) y-axis (0,1) -> (-1, 3)
Mat2 M = { { {2, -1},
{1, 3} } };
Vec2 xAxis = {1, 0};
Vec2 yAxis = {0, 1};
Vec2 ix = mul(M, xAxis);
Vec2 iy = mul(M, yAxis);
std::cout << "x-axis lands on (" << ix.x << ", " << ix.y << ")\n";
std::cout << "y-axis lands on (" << iy.x << ", " << iy.y << ")\n";
// any vector is just a blend of the columns: (3,2) means 3*xAxis + 2*yAxis
Vec2 v = {3, 2};
Vec2 r = mul(M, v);
std::cout << "(3, 2) lands on (" << r.x << ", " << r.y << ")\n";
// by hand: 3*(2,1) + 2*(-1,3) = (6,3) + (-2,6) = (4,9)
}
Output:
x-axis lands on (2, 1)
y-axis lands on (-1, 3)
(3, 2) lands on (4, 9)
Read the matrix column by column: the first column (2, 1) is exactly where the x-axis ended up, and the second column (-1, 3) is where the y-axis ended up. Multiplying by any vector just takes that many steps along each landed axis and adds them — (3, 2) means "3 of the first column plus 2 of the second", which lands on (4, 9). This is why a rotation matrix is built from sines and cosines: its columns are simply the x-axis and y-axis after they have been rotated. And it is why the first columns of an object's 3D transform are, quite literally, that object's own right, up, and forward axes expressed in world coordinates — a fact the next two sections lean on directly.
If the columns of an object's transform are its own right, up, and forward axes in world space, then a matrix does more than shove points around: it translates between two ways of describing the same location — the shared world space everyone agrees on, and the object's private local space (where the object sits at the origin, facing down its own axes). Converting from one to the other is a change of basis, and it answers questions that are painful any other way, such as "is that enemy on my left or my right?"
When the axes are an orthonormal basis (all unit length and mutually perpendicular — every pure rotation is one), converting a world vector into local space is just a dot product with each axis. Here a ship has turned so its own right and forward point along different world directions, and we ask where a target lies from the ship's point of view:
#include <iostream>
struct Vec2 { float x, y; };
float dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; }
int main() {
// the ship's local axes, written in WORLD coordinates (an orthonormal basis)
Vec2 shipRight = {0, 1}; // ship's +X (right) points toward world north
Vec2 shipForward = {-1, 0}; // ship's +Y (forward) points toward world west
// a world-space vector from the ship to a target
Vec2 toTarget = {3, 4};
// change of basis WORLD -> LOCAL: dot with each local axis
float localX = dot(toTarget, shipRight); // how far to the ship's right
float localY = dot(toTarget, shipForward); // how far ahead of the ship
std::cout << "target in ship space = (" << localX << ", " << localY << ")\n";
std::cout << (localX > 0 ? "target is to my RIGHT\n" : "target is to my LEFT\n");
std::cout << (localY > 0 ? "target is AHEAD\n" : "target is BEHIND\n");
}
Output:
target in ship space = (4, -3)
target is to my RIGHT
target is BEHIND
The target has not moved — (3, 4) in world space and (4, -3) in the ship's space are the same spot, just measured against different axes. Because the ship's axes are orthonormal, stacking those two dot products is multiplying by the matrix whose rows are the ship's axes, which is the transpose of the ship's own transform. And for an orthonormal (rotation) matrix, the transpose equals the inverse — so world-to-local is the inverse of local-to-world, and you get it for free by transposing. This is exactly what the view matrix from section 9 is: it expresses the whole world in the camera's local basis, which is why we described it there as the inverse of the camera's own transform.
Here is a bug that has shipped in real games: a model gets stretched taller than it is wide, and suddenly its lighting looks wrong — surfaces seem lit from the wrong angle. The cause is that a surface normal cannot be transformed with the same matrix as the surface itself when that matrix scales unevenly.
A normal is defined by staying perpendicular to the surface. If you stretch the geometry but push the normal through the identical stretch, it stops being perpendicular. Watch it happen with a 45-degree surface stretched twice as wide in x:
#include <iostream>
struct Vec2 { float x, y; };
struct Mat2 { float m[2][2]; };
Vec2 mul(Mat2 M, Vec2 v){ return { M.m[0][0]*v.x + M.m[0][1]*v.y,
M.m[1][0]*v.x + M.m[1][1]*v.y }; }
float dot(Vec2 a, Vec2 b){ return a.x*b.x + a.y*b.y; }
int main() {
// a flat surface at 45 degrees:
Vec2 tangent = {1, 1}; // runs ALONG the surface
Vec2 normal = {1, -1}; // sticks straight OUT (perpendicular: tangent . normal = 0)
// non-uniform scale: stretch X by 2, leave Y alone
Mat2 S = { { {2, 0}, {0, 1} } };
Mat2 Sinv = { { {0.5f, 0}, {0, 1} } }; // inverse of S (its transpose is itself here)
Vec2 newTangent = mul(S, tangent); // geometry is transformed by S
Vec2 wrongNormal = mul(S, normal); // WRONG: reuse the geometry matrix
Vec2 rightNormal = mul(Sinv, normal); // RIGHT: inverse-transpose of S
std::cout << "new tangent = (" << newTangent.x << ", " << newTangent.y << ")\n";
std::cout << "wrong normal = (" << wrongNormal.x << ", " << wrongNormal.y
<< ") dot with tangent = " << dot(wrongNormal, newTangent) << "\n";
std::cout << "right normal = (" << rightNormal.x << ", " << rightNormal.y
<< ") dot with tangent = " << dot(rightNormal, newTangent) << "\n";
}
Output:
new tangent = (2, 1)
wrong normal = (2, -1) dot with tangent = 3
right normal = (0.5, -1) dot with tangent = 0
The fix is a rule worth memorizing: to transform a normal, use the inverse-transpose of the matrix you used on the geometry — written (M^-1)^T. The dot product proves it: the "wrong" normal gives 3 (no longer perpendicular, so lighting comes out wrong), while the inverse-transpose normal gives 0 (still perpendicular). Afterwards you re-normalize, because the transform changes the normal's length too.
Two reasons you may never have noticed this before:
Section 11 said the columns of a 3D rotation are an object's right, up, and forward axes. That runs in reverse too: if you know which way an object should face, you can build its rotation by manufacturing three clean axes from that one direction. This is what Quaternion.LookRotation in Unity and a camera "look-at" do under the hood, and the tool for it is the cross product.
Give it a forward direction and a rough "up" hint (usually world up, (0, 1, 0)). Cross them to get a right vector perpendicular to both, then cross those to get a clean up that is exactly perpendicular to the other two:
#include <iostream>
#include <cmath>
struct Vec3 { float x, y, z; };
float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
Vec3 cross(Vec3 a, Vec3 b) { return { a.y*b.z - a.z*b.y,
a.z*b.x - a.x*b.z,
a.x*b.y - a.y*b.x }; }
float length(Vec3 v) { return std::sqrt(dot(v, v)); }
Vec3 normalize(Vec3 v) { float l = length(v); return { v.x/l, v.y/l, v.z/l }; }
int main() {
Vec3 forward = normalize({1, 0, 1}); // the direction the object should face
Vec3 worldUp = {0, 1, 0}; // a rough "up" hint (need not be exact)
Vec3 right = normalize(cross(worldUp, forward)); // perpendicular to both
Vec3 up = cross(forward, right); // exact up, already unit length
std::cout << "right = (" << right.x << ", " << right.y << ", " << right.z << ")\n";
std::cout << "up = (" << up.x << ", " << up.y << ", " << up.z << ")\n";
std::cout << "forward = (" << forward.x << ", " << forward.y << ", " << forward.z << ")\n";
std::cout << "right.up = " << dot(right, up) << "\n";
std::cout << "right.forward = " << dot(right, forward) << "\n";
std::cout << "up.forward = " << dot(up, forward) << "\n";
}
Output:
right = (0.707107, 0, -0.707107)
up = (-0, 1, 0)
forward = (0.707107, 0, 0.707107)
right.up = 0
right.forward = -2.50326e-08
up.forward = 0
The three dot products are all essentially zero, so the three axes came out mutually perpendicular — and each is unit length. Stack them as columns and you have a valid rotation matrix that faces forward. (The -2.50326e-08 is the same floating-point dust from section 7, not a genuine non-zero; and -0 is negative zero, which equals zero.) The move of "cross to get a perpendicular, then cross again to clean up the third axis" is a two-step Gram-Schmidt orthonormalization: it takes vectors that are roughly right and forces them to be exactly perpendicular and unit length.
This also closes a loose end. Section 11, and the quaternion note below, warn that a rotation matrix can drift — repeated multiplications let floating-point error creep in until the columns are no longer quite perpendicular or quite unit length, and the object starts to shear or shrink. Re-orthonormalizing is the fix: take the drifted forward and up, run them back through these same cross products, and you get a clean rotation again. Engines that keep a matrix around for many frames periodically do exactly this. Storing rotations as quaternions (section 17) mostly sidesteps the problem, because a quaternion is far cheaper to renormalize — you just scale it back to length 1.
Copy a transform matrix from an OpenGL tutorial straight into DirectX code and your objects will scatter to nonsense positions. The reason is a pair of conventions that engines disagree on, and that beginners constantly conflate. They are two separate choices:
M * v (a column vector), or on the left as v * M (a row vector)? The two matrices for the same transform are transposes of each other.The consequence that bites: the same numbers, read under the wrong convention, are the transpose of what you meant. Here the exact same "translate by (5, 0)" matrix is applied both ways:
#include <iostream>
struct Vec3 { float x, y, z; };
struct Mat3 { float m[3][3]; };
// column-vector convention: result = M * v (Unity / OpenGL / textbook math)
Vec3 mulColumn(const Mat3& M, Vec3 v) {
return { M.m[0][0]*v.x + M.m[0][1]*v.y + M.m[0][2]*v.z,
M.m[1][0]*v.x + M.m[1][1]*v.y + M.m[1][2]*v.z,
M.m[2][0]*v.x + M.m[2][1]*v.y + M.m[2][2]*v.z };
}
// row-vector convention: result = v * M (DirectX / Unreal traditional)
Vec3 mulRow(Vec3 v, const Mat3& M) {
return { v.x*M.m[0][0] + v.y*M.m[1][0] + v.z*M.m[2][0],
v.x*M.m[0][1] + v.y*M.m[1][1] + v.z*M.m[2][1],
v.x*M.m[0][2] + v.y*M.m[1][2] + v.z*M.m[2][2] };
}
int main() {
// a "translate by (5,0)" matrix for the COLUMN-vector convention
// (translation sits in the last COLUMN):
Mat3 M = { { {1, 0, 5},
{0, 1, 0},
{0, 0, 1} } };
Vec3 p = {2, 3, 1}; // a point, w = 1
Vec3 a = mulColumn(M, p); // M * p -- correct for this matrix
Vec3 b = mulRow(p, M); // p * M -- SAME numbers, wrong convention
// the row-vector convention needs the TRANSPOSE (translation in the last ROW):
Mat3 Mt = { { {1, 0, 0},
{0, 1, 0},
{5, 0, 1} } };
Vec3 c = mulRow(p, Mt); // correct again
std::cout << "M * p (column convention) = (" << a.x << ", " << a.y << ", " << a.z << ")\n";
std::cout << "p * M (row conv, NOT transposed) = (" << b.x << ", " << b.y << ", " << b.z << ")\n";
std::cout << "p * Mt (row conv, transposed) = (" << c.x << ", " << c.y << ", " << c.z << ")\n";
}
Output:
M * p (column convention) = (7, 3, 1)
p * M (row conv, NOT transposed) = (2, 3, 11)
p * Mt (row conv, transposed) = (7, 3, 1)
The column convention moves the point to (7, 3, 1) — a clean +5 in x. Feeding the same numbers to the row convention gives (2, 3, 11): the translation leaked into the wrong coordinate and x never moved at all. Transposing the matrix first (translation now in the last row) fixes it back to (7, 3, 1). Same transform, mirror-image storage.
There is one more visible consequence. Because (A B)^T = B^T A^T, switching conventions also reverses the order you multiply transforms. That is why the same scale-rotate-translate build-up is written two opposite ways depending on the engine:
The takeaway for reading engine code: check two things before trusting a matrix — which side the vector goes on (M * v or v * M), and whether the API hands you row-major or column-major storage. Section 8's rule "T * R * S, scale runs first" is written for the column-vector world of this chapter (and of Unity and OpenGL). In the row-vector world of classic DirectX and Unreal, the very same recipe is spelled S * R * T — the operations happen in the same real-world order, only the notation reverses.
Section 7 already met one rounding artifact — a rotation that produced -4.37114e-08 instead of a clean zero. That one was harmless. A few are not, and they cause real crashes and glitches. Two are worth knowing before they cost you an afternoon.
The angle-between formula from section 4 fed dot(a,b) / (length(a) * length(b)) straight into acos. Mathematically that ratio is always between -1 and 1. In floating point it can land at 1.0000001 — a hair outside — and acos of anything past 1 is undefined, so it returns NaN ("not a number"), which then poisons every calculation downstream. This happens most often for the angle between two nearly-parallel vectors, which is exactly when you least expect trouble.
#include <iostream>
#include <cmath>
#include <algorithm>
int main() {
// two unit vectors pointing almost the same way: the true angle is ~0.
// floating-point rounding can push their dot product just OVER 1.0
float cosTheta = 1.0000001f;
float bad = std::acos(cosTheta); // no clamp
float clamped = std::acos(std::min(1.0f, std::max(-1.0f, cosTheta))); // clamp first
std::cout << "acos(1.0000001) unclamped = " << bad << "\n";
std::cout << "acos, clamped to [-1,1] = " << clamped << "\n";
}
Output:
acos(1.0000001) unclamped = nan
acos, clamped to [-1,1] = 0
The fix is one line: clamp the cosine into the range [-1, 1] before calling acos. The angle-between code back in section 4 left this out to stay short; production code never does. Unity's Vector3.Angle and every serious math library clamp internally for exactly this reason.
Rounding also means two values that should be equal usually are not, bit for bit. Adding 0.1f ten times is the classic case — it even prints as 1, yet fails an exact equality test:
#include <iostream>
#include <cmath>
int main() {
float sum = 0.0f;
for (int i = 0; i < 10; i++) sum += 0.1f;
std::cout << "sum of ten 0.1f = " << sum << "\n";
std::cout << "sum == 1.0f ? " << (sum == 1.0f) << "\n";
std::cout << "close enough (eps) ? " << (std::fabs(sum - 1.0f) < 0.00001f) << "\n";
}
Output:
sum of ten 0.1f = 1
sum == 1.0f ? 0
close enough (eps) ? 1
The sum displays as 1 because printing rounds it, but sum == 1.0f is 0 (false): the stored value is a whisker off. The rule is to compare with a small tolerance — an epsilon — instead of ==: ask whether fabs(a - b) is below some tiny threshold. This is the same epsilon idea the normalize warning in section 3 used to avoid dividing by a near-zero length, and it is why engine code is full of comparisons like if (fabs(x) < 1e-5f) rather than if (x == 0).
We rotated things in this chapter with a 2D rotation matrix (section 7) and mentioned 3D rotation matrices in the pipeline (section 9). Both work, but in 3D they have real problems: a rotation matrix uses 9 numbers to store something that only truly needs 3 (an axis and an angle), and repeated multiplication can let tiny floating-point errors accumulate until the matrix is no longer a "valid" rotation at all. Describing a 3D rotation as three separate angles (pitch, yaw, roll — called Euler angles) is easier to read, but can hit gimbal lock: a situation where two of the three rotation axes line up and the object permanently loses its ability to rotate freely around one direction.
A quaternion stores a 3D rotation with just 4 numbers, has no gimbal lock, and blends smoothly between two rotations (an operation called slerp) in a way Euler angles cannot. Unity and Unreal both store rotations internally as quaternions, converting to a rotation matrix only at the last moment, when a vertex actually needs to be transformed. That is the whole subject of the next chapter, 2.3 — for now, just know that when you see "rotation" stored in an engine, it is very likely four numbers, not three or nine.
sqrt(x*x + y*y [+ z*z]).a.x*b.x + a.y*b.y [+ a.z*b.z]; a single number measuring how aligned two vectors are.n: a - 2 (a . n) n; the basis of a bounce or ricochet.w) and right after the perspective divide (NDC, roughly -1..1).(x, y, z, w) so that translation, rotation, and scale can all be done with one matrix multiply.1 for a point, 0 for a direction; after a perspective projection, related to depth.x, y, z by w after projection; makes distant objects appear smaller.(1,0), (0,1) (and a third in 3D); the columns of a matrix are where they land.(M^-1)^T; the matrix you must use to transform surface normals so they stay perpendicular under non-uniform scale.v * M, row) or right (M * v, column) of the matrix; the two matrices are transposes, and the convention flips the multiply order.M * v.acos of a value past 1; clamp inputs to avoid it.fabs(a - b) < eps) instead of ==, and to guard against dividing by a near-zero value.(2, 2) facing direction (1, 0) (facing east). Two noises come from P = (5, 2) and Q = (0, 5). For each noise, compute the vector from the guard to the noise, then the dot product of the guard's facing direction with that vector, and say whether the guard is facing toward the noise (in front) or away from it (behind) — without running any code.Vector to P: P - guard = (5-2, 2-2) = (3, 0). Dot with facing (1,0): 1*3 + 0*0 = 3. That is positive, so P is in front of the guard.
Vector to Q: Q - guard = (0-2, 5-2) = (-2, 3). Dot with facing (1,0): 1*(-2) + 0*3 = -2. That is negative, so Q is behind the guard (relative to which way it is facing) — even though Q is above the guard, "behind" here just means "on the opposite side of the facing direction", not "physically below".
(3, 1), then move it with a translation of (2, 0). Using mul(A, B) to mean "apply B first, then A" (as in section 8), compute where the local point p = (1, 1) ends up under both mul(S, T) and mul(T, S), where S = scale(3, 1) and T = translate(2, 0). Which order is the "correct" one for scaling an object and then placing it in the world, and what goes wrong with the other order?mul(S, T) applies T first, then S: (1,1) translates to (3,1), then that gets scaled to (9,1).
mul(T, S) applies S first, then T: (1,1) scales to (3,1), then that gets translated to (5,1).
mul(T, S) is the correct order — it gives (5,1), close to the intended world position around (2,0). mul(S, T) gives the surprising (9,1), because translating before scaling means the scale also stretches the translation offset itself, dragging the object much further than (2,0) away. This is exactly the "my object flew off to a weird position after I added scaling" bug mentioned in section 8 — the fix is always scale, then rotate, then translate.
A = (1, 0, 0), B = (1, 4, 0), and C = (1, 0, 5). Compute edge1 = B - A, edge2 = C - A, the raw cross product cross(edge1, edge2), its length, the unit surface normal, and the triangle's area.edge1 = B - A = (0, 4, 0). edge2 = C - A = (0, 0, 5).
cross(edge1, edge2) = (4*5 - 0*0, 0*0 - 0*5, 0*0 - 4*0) = (20, 0, 0).
Length: sqrt(20*20 + 0*0 + 0*0) = 20.
Unit normal: (20/20, 0/20, 0/20) = (1, 0, 0) — the triangle lies flat in the plane x = 1, so it makes sense that its normal points straight along x.
Triangle area: 20 / 2 = 10.
d = (3, -4) and hits a flat floor whose unit normal is n = (0, 1). Compute d . n, then the reflected velocity reflect(d, n) = d - 2 (d . n) n. Which component flipped, and why does that match a ball bouncing off the ground?d . n = 3*0 + (-4)*1 = -4.
reflect(d, n) = (3,-4) - 2*(-4)*(0,1) = (3,-4) - (0,-8) = (3,-4) + (0,8) = (3, 4).
The y component flipped from -4 (moving down) to +4 (moving up), while x was untouched. That is exactly a bounce: the floor reverses the into-floor (vertical) motion and leaves the along-floor (horizontal) motion alone.
right = (0, -1) and forward = (1, 0) (it has turned so "forward" points world-east). A shell lands at world offset toTarget = (2, 3) from the tank. Using the change-of-basis trick from section 12, compute localX = toTarget . right and localY = toTarget . forward, and say whether the impact was to the tank's left or right, and ahead or behind.localX = (2,3) . (0,-1) = 2*0 + 3*(-1) = -3. Negative, so the impact was to the tank's left.
localY = (2,3) . (1,0) = 2*1 + 3*0 = 2. Positive, so the impact was ahead of the tank.
In the tank's own frame the shell landed at (-3, 2): two units ahead and three to the left — even though in world coordinates it was up and to the right. Same point, different axes.
t = (1, 1) and normal n = (1, -1) (check: t . n = 0). The geometry is scaled by S = "x times 1, y times 3" (a non-uniform stretch). Compute (a) the new tangent S * t; (b) the WRONG normal S * n and its dot product with the new tangent; (c) the RIGHT normal using the inverse-transpose (for this diagonal S, that is S^-1 = "x times 1, y times 1/3") and its dot product with the new tangent. Which one stayed perpendicular?(a) New tangent: S * (1,1) = (1*1, 3*1) = (1, 3).
(b) Wrong normal: S * (1,-1) = (1*1, 3*(-1)) = (1, -3). Dot with the new tangent: (1,-3) . (1,3) = 1 - 9 = -8 — not zero, so it is no longer perpendicular, and lighting would be wrong.
(c) Right normal: S^-1 * (1,-1) = (1*1, (1/3)*(-1)) = (1, -0.333...). Dot with the new tangent: (1,-1/3) . (1,3) = 1 - 1 = 0 — still perpendicular. The inverse-transpose normal is the correct one.
That is the math that puts every object on screen. A vector is a direction and a length; add, subtract, and scale them to move things around. The dot product measures alignment and gives you angles, front/behind checks, and projections. The cross product builds a new perpendicular vector — a surface normal — and its length gives you area for free. Matrices package up scale, rotate, and translate into single reusable objects; multiplying them combines transforms, but never forget that the order changes the answer. The model-view-projection chain is exactly that combination, applied three times, to walk a vertex from a mesh's own tiny space all the way to a pixel on your screen — with the humble fourth coordinate, w, quietly making both translation and perspective possible. Rotations, done properly in 3D, deserve their own tool — quaternions — which is exactly where chapter 2.3 picks up.