Transformations

We can now put shapes on the screen, colour them per vertex and wrap textures around them. They just sit there, though. To make something move we could rewrite its vertices every frame and upload the buffer again, but that is slow, and the buffers in our lessons are immutable anyway: the GPU got them once, at creation, and the CPU never touches them again. The usual answer, and the one every chapter from here on relies on, is to leave the vertices alone and hand the vertex shader a matrix that says how to move them.

Matrices are one of those subjects that look forbidding until you have used them a few times, after which they become an everyday tool. This chapter is the maths groundwork for the rest of the tutorial: vectors first, then matrices, then how the two combine into transformations. If some of it does not stick on the first read, that is normal; skim it, carry on, and come back when a later chapter leans on it. At the end we put it to work with math.rin, the small library that does for our lessons what GLM does for LearnOpenGL.

Vectors

A vector is a quantity with a direction and a magnitude (its length). Directions given to a hiker are vectors: "walk 300 metres north, then 100 metres east" is two of them, each a direction plus a distance. In graphics we mostly deal with vectors of two, three or four components. A 2D vector describes a direction in a plane, a 3D vector a direction in space.

Because a vector is only a direction and a length, it has no fixed starting point. In LearnOpenGL's picture below, \(\color{red}{\bar{v}}\) and \(\color{blue}{\bar{w}}\) start in different places but are the same vector, (3, 2): three to the right, two up.

Vectors v and w with different origins but the same direction and length, and a third vector n (LearnOpenGL's diagram)

On paper a vector is written as a letter with a bar, \(\bar{v}\), and its components are stacked in a column:

\[\bar{v} = \begin{pmatrix} \color{red}x \\ \color{green}y \\ \color{blue}z \end{pmatrix}\]

We also use vectors for positions. The trick is to fix the start of the vector at the origin, (0, 0, 0): the vector then points at exactly one spot, and that spot is the position. The vertex positions we have been writing into buffers are vectors read this way, and so are the colours and texture coordinates, which are just "a few numbers that belong together". In math.rin they are plain structs:

vec3: struct = {
    x: f32;
    y: f32;
    z: f32;
}

with vec2 and vec4 alongside, and a constructor for each, so v3(1.0, 0.0, 0.0) is the unit x direction. In HLSL the same things are called float2, float3 and float4, which we have been using since the first triangle.

Scalar vector operations

A scalar is a single number. Adding a scalar to a vector, or subtracting, multiplying or dividing by one, applies the operation to every component:

\[\begin{pmatrix} \color{red}2 \\ \color{green}4 \\ \color{blue}6 \end{pmatrix} \div 2 = \begin{pmatrix} \color{red}2 \div 2 \\ \color{green}4 \div 2 \\ \color{blue}6 \div 2 \end{pmatrix} = \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix}\]

Multiplication is the one we use most: it keeps the direction and changes the length. math.rin calls it v3_scale.

Vector negation

Negating a vector flips it around: same length, opposite direction. Every component gets a minus sign, which is the same as scaling by -1:

\[-\bar{v} = -\begin{pmatrix} \color{red}{v_x} \\ \color{green}{v_y} \\ \color{blue}{v_z} \end{pmatrix} = \begin{pmatrix} -\color{red}{v_x} \\ -\color{green}{v_y} \\ -\color{blue}{v_z} \end{pmatrix}\]

Addition and subtraction

Two vectors are added component by component:

\[\bar{v} = \begin{pmatrix} \color{red}4 \\ \color{green}2 \\ \color{blue}0 \end{pmatrix}, \bar{k} = \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}0 \end{pmatrix} \rightarrow \bar{v} + \bar{k} = \begin{pmatrix} \color{red}{4 + 1} \\ \color{green}{2 + 2} \\ \color{blue}{0 + 0} \end{pmatrix} = \begin{pmatrix} \color{red}5 \\ \color{green}4 \\ \color{blue}0 \end{pmatrix}\]

Drawn as arrows, addition means walking the first vector and then the second from where the first ended. The sum is the arrow from the very start to the very end:

Adding v = (4, 2) and k = (1, 2) head to tail gives (5, 4) (LearnOpenGL's diagram)

Subtraction is adding the negated vector. Its useful property is geometric: \(\bar{w} - \bar{v}\) is the vector that leads from the point \(\bar{v}\) to the point \(\bar{w}\). Whenever we need "the direction from here to there", from a surface to a light or from the camera to its target, it is a subtraction of two positions.

The difference w - v points from the tip of v to the tip of w (LearnOpenGL's diagram)

Length

The components of a 2D vector are the two short sides of a right-angled triangle, and the vector itself is the long side:

Vector (4, 2) as the hypotenuse of a triangle with sides x = 4 and y = 2 (LearnOpenGL's diagram)

So Pythagoras gives its length, written \(||\bar{v}||\), and in 3D we simply add the third square:

\[||\bar{v}|| = \sqrt{{\color{red}x}^2 + {\color{green}y}^2 + {\color{blue}z}^2}, \qquad ||(4, 2, 0)|| = \sqrt{16 + 4 + 0} = \sqrt{20} \approx 4.47\]

A vector of length exactly 1 is a unit vector. Any vector except zero can be turned into one by dividing it by its own length, which is called normalizing it, and the result is written with a hat:

\[\hat{n} = \frac{\bar{v}}{||\bar{v}||}\]

Unit vectors are what we use whenever only the direction matters, as it will for surface normals and light directions. In math.rin:

v3_length: proc(a: vec3) -> f32 = {
    return sqrtf(v3_dot(a, a));
}

v3_normalize: proc(a: vec3) -> vec3 = {
    len: f32 = v3_length(a);
    if (len == 0.0) { return a; }
    return v3_scale(a, 1.0 / len);
}

The length is computed through the dot product, which is coming next, and a zero vector, which has no direction to keep, is handed back unchanged rather than divided by zero.

Vector-vector multiplication

There is no single obvious way to multiply two vectors. Multiplying them component by component exists (HLSL's * on two float3s does that, and v3_mul is the rin version), and we will use it for colours, but geometrically the two products that matter are the dot product, written \(\bar{v} \cdot \bar{k}\), and the cross product, written \(\bar{v} \times \bar{k}\).

Dot product

The dot product of two vectors is a single number: their two lengths multiplied together, times the cosine of the angle \(\theta\) between them.

\[\bar{v} \cdot \bar{k} = ||\bar{v}|| \cdot ||\bar{k}|| \cdot \cos \theta\]

For unit vectors both lengths are 1 and the formula collapses to \(\hat{v} \cdot \hat{k} = \cos \theta\). That is what makes it so handy. The cosine is 1 when the vectors point the same way, 0 when they are at right angles (orthogonal), and -1 when they point in opposite directions, so one dot product tells us how much two directions agree. Diffuse lighting, in a few chapters, is exactly this number.

Computing it is cheap: multiply matching components and add the results. Two unit vectors in the xy plane, for example:

\[\begin{pmatrix} \color{red}{0.6} \\ \color{green}{0.8} \\ \color{blue}0 \end{pmatrix} \cdot \begin{pmatrix} \color{red}{0.8} \\ \color{green}{0.6} \\ \color{blue}0 \end{pmatrix} = ({\color{red}{0.6 \cdot 0.8}}) + ({\color{green}{0.8 \cdot 0.6}}) + ({\color{blue}{0 \cdot 0}}) = 0.96\]

and the inverse cosine, \(\cos^{-1}(0.96)\), says they are about 16.3 degrees apart. For vectors that are not unit length we would first divide by both lengths. The rin version is one line:

v3_dot: proc(a: vec3, b: vec3) -> f32 = {
    return a.x * b.x + a.y * b.y + a.z * b.z;
}

HLSL has it built in as dot.

Cross product

The cross product only exists in three dimensions. It takes two vectors that are not parallel and returns a third that is perpendicular to both. If the two inputs are already perpendicular to each other, the three together form a set of axes, which is exactly how we will build the camera's frame in a later chapter.

Two orthogonal vectors and their cross product, perpendicular to both (LearnOpenGL's diagram)

The formula has no obvious picture behind it without some linear algebra, so it is fine to treat it as a recipe:

\[\begin{pmatrix} \color{red}{A_x} \\ \color{green}{A_y} \\ \color{blue}{A_z} \end{pmatrix} \times \begin{pmatrix} \color{red}{B_x} \\ \color{green}{B_y} \\ \color{blue}{B_z} \end{pmatrix} = \begin{pmatrix} \color{green}{A_y} \color{blue}{B_z} - \color{blue}{A_z} \color{green}{B_y} \\ \color{blue}{A_z} \color{red}{B_x} - \color{red}{A_x} \color{blue}{B_z} \\ \color{red}{A_x} \color{green}{B_y} - \color{green}{A_y} \color{red}{B_x} \end{pmatrix}\]
v3_cross: proc(a: vec3, b: vec3) -> vec3 = {
    return v3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
}

Try it on the x and y axes: \((1, 0, 0) \times (0, 1, 0) = (0, 0, 1)\), the z axis. The order matters: \(\bar{B} \times \bar{A}\) points the opposite way. Which way "x cross y" points is what makes a coordinate system right-handed, and ours is: +x to the right, +y up, +z out of the screen towards us. We come back to that in Coordinate Systems.

Matrices

A matrix is a rectangular grid of numbers. Each number is an element, found by its row and its column, in that order. This one has two rows and three columns, so it is a 2x3 matrix:

\[\begin{bmatrix} 7 & 0 & 2 \\ 1 & 3 & 5 \end{bmatrix}\]

Element (2, 3), second row and third column, is 5. Note the order: row first, the opposite of the (x, y) habit from graphs. Matrices themselves are nothing more than this grid. What makes them interesting is a handful of operations defined on them.

Addition and subtraction

Two matrices of the same size are added or subtracted element by element, each with its counterpart in the same row and column. Matrices of different sizes cannot be added at all.

\[\begin{bmatrix} \color{red}3 & \color{red}1 \\ \color{green}0 & \color{green}5 \end{bmatrix} + \begin{bmatrix} \color{red}2 & \color{red}2 \\ \color{green}4 & \color{green}1 \end{bmatrix} = \begin{bmatrix} \color{red}5 & \color{red}3 \\ \color{green}4 & \color{green}6 \end{bmatrix}, \qquad \begin{bmatrix} \color{red}3 & \color{red}1 \\ \color{green}0 & \color{green}5 \end{bmatrix} - \begin{bmatrix} \color{red}2 & \color{red}2 \\ \color{green}4 & \color{green}1 \end{bmatrix} = \begin{bmatrix} \color{red}1 & -\color{red}1 \\ -\color{green}4 & \color{green}4 \end{bmatrix}\]

Matrix-scalar products

Multiplying a matrix by a scalar multiplies every element by it, which is where the word "scalar" comes from: it scales everything by the same amount.

\[{\color{green}3} \cdot \begin{bmatrix} 1 & 0 \\ 2 & 4 \end{bmatrix} = \begin{bmatrix} 3 & 0 \\ 6 & 12 \end{bmatrix}\]

Matrix-matrix multiplication

Multiplying two matrices is the operation everything else depends on, and the one that takes a little practice. Two rules come first:

Each element of the result pairs one row of the left matrix with one column of the right matrix. Walk along the row and down the column together, multiply the pairs of numbers you meet, and add them up. The row you used is the row of the result element, and the column you used is its column. LearnOpenGL's diagram colours the pairings:

A 2x2 matrix product with the row and column used for two result elements highlighted (LearnOpenGL's diagram)

Written out:

\[\begin{bmatrix} \color{red}1 & \color{red}2 \\ \color{green}3 & \color{green}4 \end{bmatrix} \cdot \begin{bmatrix} \color{blue}5 & \color{purple}6 \\ \color{blue}7 & \color{purple}8 \end{bmatrix} = \begin{bmatrix} \color{red}1 \cdot \color{blue}5 + \color{red}2 \cdot \color{blue}7 & \color{red}1 \cdot \color{purple}6 + \color{red}2 \cdot \color{purple}8 \\ \color{green}3 \cdot \color{blue}5 + \color{green}4 \cdot \color{blue}7 & \color{green}3 \cdot \color{purple}6 + \color{green}4 \cdot \color{purple}8 \end{bmatrix} = \begin{bmatrix} 19 & 22 \\ 43 & 50 \end{bmatrix}\]

The result has as many rows as the left matrix and as many columns as the right one. Every element is a dot product, of a row with a column, which is one more reason the dot product keeps turning up. Here is the same thing for 4x4 matrices, the only size our lessons use:

m4_mul: proc(a: mat4, b: mat4) -> mat4 = {
    r: mat4 = {};
    for (row: i32 = 0; row < 4; row += 1) {
        for (col: i32 = 0; col < 4; col += 1) {
            sum: f32 = 0.0;
            for (k: i32 = 0; k < 4; k += 1) {
                sum += a.m[row * 4 + k] * b.m[k * 4 + col];
            }
            r.m[row * 4 + col] = sum;
        }
    }
    return r;
}

The innermost loop walks along row row of a and down column col of b. Doing a couple of these by hand is the fastest way to make the pattern stick; after that, let the computer do it.

Matrix-vector multiplication

A vector with N components is really an Nx1 matrix: N rows, one column. So a matrix with N columns can multiply it, and the result is another vector. That is the whole point of this chapter. A great many useful operations, moving, turning, stretching, and later projecting, can be packed into a matrix, and multiplying a vector by that matrix applies the operation to it.

Identity matrix

The plainest 4x4 matrix is the identity matrix: ones on the diagonal, zeros everywhere else. Multiplying by it changes nothing:

\[\begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{bmatrix} 5 \\ 6 \\ 7 \\ 1 \end{bmatrix} = \begin{bmatrix} \color{red}1 \cdot 5 \\ \color{green}1 \cdot 6 \\ \color{blue}1 \cdot 7 \\ \color{purple}1 \cdot 1 \end{bmatrix} = \begin{bmatrix} 5 \\ 6 \\ 7 \\ 1 \end{bmatrix}\]

Each result element is a row of the matrix dotted with the vector, and each row has a single 1 that picks out one component. A transformation that does nothing sounds pointless, but it is where every other transformation starts: take the identity and change a few elements.

m4_identity: proc() -> mat4 = {
    r: mat4 = {
        .m = {
            [0] = 1.0,
            [5] = 1.0,
            [10] = 1.0,
            [15] = 1.0,
        },
    };
    return r;
}

Elements 0, 5, 10 and 15 are the diagonal because a mat4 keeps its 16 floats in m, row after row: element (row, column) is m[row * 4 + col]. We will see why that matters once the matrix travels to a shader.

Scaling

Scaling a vector changes its length while keeping its direction. We can scale each axis by its own factor: scaling (3, 2) by 0.5 along x and by 2 along y gives (1.5, 4), narrower and taller. When the factors differ, as here, it is a non-uniform scale; when they are all the same it is a uniform scale.

Vector (3, 2) scaled by (0.5, 2) becomes (1.5, 4) (LearnOpenGL's diagram)

The identity matrix already multiplies each component by the 1 on its row. Replace those ones with other factors and each component is multiplied by those instead:

\[\begin{bmatrix} \color{red}{S_x} & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{S_y} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}{S_z} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{S_x} \cdot x \\ \color{green}{S_y} \cdot y \\ \color{blue}{S_z} \cdot z \\ 1 \end{pmatrix}\]
m4_scale: proc(s: vec3) -> mat4 = {
    r: mat4 = m4_identity();
    r.m[0] = s.x;
    r.m[5] = s.y;
    r.m[10] = s.z;
    return r;
}

The fourth diagonal element stays 1. The fourth component of the vector, w, has a job of its own, which the next transformation explains.

Translation

Translation means moving: adding an offset vector to a position. A 3x3 matrix cannot do that, because each of its result components is only a weighted sum of x, y and z; there is no room for a constant. This is the reason our vectors have a fourth component. Give every position a w of 1, put the offset in the last column of the matrix, and the last column gets multiplied by that 1 and added on:

\[\begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}{T_x} \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}{T_y} \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}{T_z} \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} x + \color{red}{T_x} \\ y + \color{green}{T_y} \\ z + \color{blue}{T_z} \\ 1 \end{pmatrix}\]
m4_translate: proc(t: vec3) -> mat4 = {
    r: mat4 = m4_identity();
    r.m[3] = t.x;
    r.m[7] = t.y;
    r.m[11] = t.z;
    return r;
}

Elements 3, 7 and 11 are the last column of rows 0, 1 and 2, exactly where the formula puts \(T_x\), \(T_y\) and \(T_z\).

Homogeneous coordinates. The w component makes our vectors homogeneous coordinates. To turn one back into an ordinary 3D point we divide x, y and z by w. While w is 1 that changes nothing, which is why we rarely notice it. It earns its keep twice: it lets a matrix translate, and in the next chapter it carries the depth that perspective divides by. A vector with w = 0 is unaffected by the last column, so it can be rotated and scaled but never moved: that is exactly right for a direction vector, which has no position to move.

Rotation

Rotations take a little trigonometry. An angle can be measured in degrees, 360 to a full turn, or in radians, \(2\pi\) to a full turn. People think in degrees, but sinf, cosf and every rotation function in math.rin work in radians, so we convert with radians:

radians: proc(degrees: f32) -> f32 = {
    return degrees * pi / 180.0;
}

A quarter turn is 90 degrees, or \(\pi/2\) radians; a fifth of a turn is 72 degrees, as between \(\bar{k}\) and \(\bar{v}\) here:

Vector v rotated 72 degrees from k about the axis (0, 0, 1) (LearnOpenGL's diagram)

In 3D a rotation is an angle plus an axis to turn around. A flat shape in the xy plane, like our textured square, spins on the screen when rotated about the z axis. Following the right-hand rule, a positive angle turns counter-clockwise when the axis points at you. The rotations about the three main axes, with \(\theta\) the angle, are:

\[R_x = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos \theta & -\sin \theta & 0 \\ 0 & \sin \theta & \cos \theta & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \quad R_y = \begin{bmatrix} \cos \theta & 0 & \sin \theta & 0 \\ 0 & 1 & 0 & 0 \\ -\sin \theta & 0 & \cos \theta & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \quad R_z = \begin{bmatrix} \cos \theta & -\sin \theta & 0 & 0 \\ \sin \theta & \cos \theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

To rotate about some other axis you could chain these, x then y then z, but chained axis rotations suffer from gimbal lock: at certain angles two of the axes line up and one degree of freedom is lost. It is better to rotate about the axis we actually want in one step. For a unit axis \((\color{red}{R_x}, \color{green}{R_y}, \color{blue}{R_z})\) that matrix is:

\[\begin{bmatrix} \cos \theta + \color{red}{R_x}^2(1 - \cos \theta) & \color{red}{R_x}\color{green}{R_y}(1 - \cos \theta) - \color{blue}{R_z} \sin \theta & \color{red}{R_x}\color{blue}{R_z}(1 - \cos \theta) + \color{green}{R_y} \sin \theta & 0 \\ \color{green}{R_y}\color{red}{R_x} (1 - \cos \theta) + \color{blue}{R_z} \sin \theta & \cos \theta + \color{green}{R_y}^2(1 - \cos \theta) & \color{green}{R_y}\color{blue}{R_z}(1 - \cos \theta) - \color{red}{R_x} \sin \theta & 0 \\ \color{blue}{R_z}\color{red}{R_x}(1 - \cos \theta) - \color{green}{R_y} \sin \theta & \color{blue}{R_z}\color{green}{R_y}(1 - \cos \theta) + \color{red}{R_x} \sin \theta & \cos \theta + \color{blue}{R_z}^2(1 - \cos \theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

Set the axis to (0, 0, 1) and it reduces to \(R_z\) above. m4_rotate writes this matrix element by element, with c the cosine, s the sine and t for \(1 - \cos \theta\):

// A rotation of `angle` radians about `axis`, counter-clockwise when looking
// down the axis towards the origin.
m4_rotate: proc(angle: f32, axis: vec3) -> mat4 = {
    a: vec3 = v3_normalize(axis);
    c: f32 = cosf(angle);
    s: f32 = sinf(angle);
    t: f32 = 1.0 - c;
    r: mat4 = m4_identity();
    r.m[0] = c + a.x * a.x * t;
    r.m[1] = a.x * a.y * t - a.z * s;
    r.m[2] = a.x * a.z * t + a.y * s;
    r.m[4] = a.y * a.x * t + a.z * s;
    r.m[5] = c + a.y * a.y * t;
    r.m[6] = a.y * a.z * t - a.x * s;
    r.m[8] = a.z * a.x * t - a.y * s;
    r.m[9] = a.z * a.y * t + a.x * s;
    r.m[10] = c + a.z * a.z * t;
    return r;
}

The formula needs a unit axis, so the function normalizes whatever it is given. Even this form can be driven into gimbal lock if the angles come from three separate Euler angles; the cure for that is quaternions, a different representation of rotations that we will not need in these lessons.

Combining matrices

Here is the real reason to use matrices: several transformations multiply together into one matrix, and that single matrix then does all of them at once, for every vertex, for the price of one. Suppose we want to halve a shape's size and then move it by (2, 1, 0). The translation matrix times the scale matrix is:

\[T \cdot S = \begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}2 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}1 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{bmatrix} \color{red}{0.5} & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{0.5} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}{0.5} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} = \begin{bmatrix} \color{red}{0.5} & \color{red}0 & \color{red}0 & \color{red}2 \\ \color{green}0 & \color{green}{0.5} & \color{green}0 & \color{green}1 \\ \color{blue}0 & \color{blue}0 & \color{blue}{0.5} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix}\]

and applied to a position:

\[\begin{bmatrix} \color{red}{0.5} & \color{red}0 & \color{red}0 & \color{red}2 \\ \color{green}0 & \color{green}{0.5} & \color{green}0 & \color{green}1 \\ \color{blue}0 & \color{blue}0 & \color{blue}{0.5} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{0.5x + 2} \\ \color{green}{0.5y + 1} \\ \color{blue}{0.5z} \\ 1 \end{pmatrix}\]

Halved, then moved, as we wanted. Notice which way round that is. In \(T \cdot S \cdot \bar{v}\) the matrix nearest the vector, \(S\), touches it first, so a product of transformations is read right to left. Multiply them the other way, \(S \cdot T\), and the translation happens first and then gets halved along with everything else: the shape lands at (1, 0.5, 0) instead. Because of this, the usual order is scale first, then rotate, then translate, which in a product reads \(T \cdot R \cdot S\): scaling and rotating then happen around the shape's own centre, before it is moved away from the origin.

In practice

Neither Direct3D nor HLSL gives the CPU side any vector or matrix types, so the program needs its own. LearnOpenGL uses GLM. The usual choice for Direct3D in C++ is Microsoft's DirectXMath, but it is a C++ library built around SIMD types, and it treats vectors as rows, multiplying \(\bar{v} \cdot M\), so every product in LearnOpenGL would have to be written the other way round. Our lessons instead use includes/math.rin, about two hundred lines of plain rin that keep LearnOpenGL's conventions:

//   * right-handed world: +x right, +y up, the camera looks down -z
//   * column vectors: a point is transformed as `M * p`, so `T * R * S`
//     scales first and translates last
//   * clip-space depth runs 0..1, which is D3D's range (GL's is -1..1)

The first and last points belong to the next two chapters. The second is what we just worked through: a matrix times a column vector, read right to left. Everything in this chapter is a plain function in math.rin. rin has no operator overloading, so where GLM writes a + b we write v3_add(a, b), and A * B is m4_mul(A, B).

One difference from GLM is worth knowing before reading LearnOpenGL's code side by side with ours. GLM's glm::translate(trans, v) takes an existing matrix and returns it multiplied by a translation, so LearnOpenGL builds a transformation in steps:

glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f, 0.0f, 1.0f));

Our m4_translate and m4_rotate just return the matrix, and the products are written out, so the same transformation is m4_mul(m4_translate(...), m4_rotate(...)). Both mean \(T \cdot R\); ours just puts the multiplication where you can see it. As a quick check, translating the point (1, 0, 0) by (1, 1, 0),

p: vec4 = m4_mul_v4(m4_translate(v3(1.0, 1.0, 0.0)), v4(1.0, 0.0, 0.0, 1.0));
// p is (2, 1, 0, 1)

gives (2, 1, 0), with w still 1. m4_mul_v4 is matrix-times-vector: each component of the result is one row of the matrix dotted with the vector.

Getting the matrix to the shader

The vertex shader needs the matrix, and per-draw values reach shaders through a constant buffer, as the tint did in Shaders. HLSL's matrix type is float4x4, and mul multiplies a matrix by a vector:

// Read matrices row by row, as rin stores them; mul(M, v) is then M * v.
#pragma pack_matrix(row_major)

cbuffer object : register(b0)
{
    float4x4 transform;
};
...
vs_out vs_main(float3 position : POSITION, float2 uv : TEXCOORD)
{
    vs_out o;
    o.position = mul(transform, float4(position, 1.0));
    o.uv = uv;
    return o;
}

The position arrives as a float3 and we give it a w of 1 before multiplying, since it is a point that should be translated. The result is what the shader returns as SV_Position, so the rasterizer sees the moved square. The pixel shader is unchanged from Textures: the crate with 20% of the face mixed in.

The first line, the #pragma, is the one Direct3D detail in this chapter, and forgetting it is a classic. A mat4 is 16 floats, and the CPU and the shader have to agree on what order they are in. We store them row by row, as we saw with m4_identity. HLSL, by default, expects a matrix in a constant buffer to be stored column by column (column-major), and would read our first row as its first column: it would see the transpose of our matrix, every element mirrored across the diagonal. The translation in the last column would land in the bottom row, where it feeds into w instead of moving anything, and the square would come out warped and turning the wrong way. #pragma pack_matrix(row_major) tells the compiler our matrices are row-major, after which mul(transform, v) means exactly \(M \cdot \bar{v}\), as in our maths and as transform * v does in GLSL.

OpenGL has the same question in another form. GLM stores matrices column by column and GLSL reads them that way, so LearnOpenGL passes GL_FALSE for the transpose argument of glUniformMatrix4fv. Either convention works as long as both sides agree; every shader in our lessons that reads a matrix starts with this pragma. You may also see mul(v, M) in Direct3D code elsewhere: that treats the vector as a row and expects matrices built the transposed way, the DirectXMath convention. We never use that form.

On the CPU side, a struct mirrors the constant buffer:

object_constants: struct = {
    transform: mat4;
}

A mat4 is 64 bytes, four of HLSL's 16-byte registers, so the buffer's size is already a multiple of 16 as constant buffers require. The buffer is made once with create_constant_buffer (D3D11_USAGE_DYNAMIC, CPU-writable) and bound to register b0 every frame with bind_constants, which binds it for the vertex, geometry and pixel stages alike.

Putting it together

The lesson draws the textured square twice, from the same vertex and index buffers, each time with its own matrix. Because the matrices change with time, they are rebuilt every frame inside the render loop:

c: object_constants = {
    .transform = m4_mul(aspect, m4_mul(m4_translate(v3(0.5, -0.5, 0.0)), m4_rotate(current_frame, v3(0.0, 0.0, 1.0)))),
};
update_buffer(context, constants, c&, sizeof(object_constants));
ID3D11DeviceContext_DrawIndexed(context, 6, 0, 0);

pulse: f32 = fabsf(sinf(current_frame));
c.transform = m4_mul(aspect, m4_mul(m4_translate(v3(-0.5, 0.5, 0.0)), m4_scale(v3(pulse, pulse, pulse))));
update_buffer(context, constants, c&, sizeof(object_constants));
ID3D11DeviceContext_DrawIndexed(context, 6, 0, 0);

Read each one from the right. The first square is rotated about z by current_frame radians, the seconds since start, so it turns about once every six seconds; only then is it translated to the bottom right. It spins in place, around its own centre, because it is rotated while that centre is still at the origin. The second square is scaled by \(|\sin t|\), shrinking to nothing and growing back, and then moved to the top left. LearnOpenGL leaves that second square as an exercise; our lesson draws both. The absolute value keeps the scale positive; a plain sine would also work but would flip the square through itself every other cycle.

Between the two draws we write the constant buffer again. update_buffer maps it with D3D11_MAP_WRITE_DISCARD, which hands us fresh memory each time, so the first draw still sees the first matrix even though the GPU has not drawn it yet when we write the second.

Then there is aspect, applied last, after everything else. LearnOpenGL does not have it:

// The window is wider than it is tall and clip space is not: without
// this a square comes out stretched sideways.
aspect: mat4 = m4_scale(v3(cast(scr_height, f32) / cast(scr_width, f32), 1.0, 1.0));

Clip space runs from -1 to 1 in both x and y, but our window is 1280 by 720 pixels, so one unit of x covers more pixels than one unit of y. Scaling x by 720/1280 makes a unit the same size on screen in both directions, and the squares stay square while they spin. Because it is the leftmost matrix, it also squeezes the translations: the first square's centre ends up at x = 0.28 rather than 0.5. In the next chapter a projection matrix takes over this job.

One container spinning at the bottom right, another scaling at the top left

That is two moving objects without touching a single vertex. A matrix can hold as many transformations as we like, and the vertex shader applies them all with one multiplication; all the CPU uploads each frame is 64 bytes. The full program is here, its shaders here, and the maths library here.

Next we use matrices to set up several coordinate spaces, and take the step into 3D.

Additional resources

Exercises

  1. Swap the order of the first square's matrices so it is translated first and rotated second. Work out on paper what should happen before you run it, then check.
  2. Delete #pragma pack_matrix(row_major) from transformations.hlsl and run the lesson. Explain what you see in terms of the transposed matrix, then put the pragma back and get the same broken picture by passing m4_transpose(...) of the matrix instead.
  3. Add a third square in the top right that both spins and pulses. Mind the order of the scale and the rotation (does it matter here?) and of the translation.
Credit: this chapter follows LearnOpenGL's Transformations by Joey de Vries, rewritten for Direct3D 11 and rin. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.