Skip to content
Fundamental · TransformsIntermediate

Matrices & MVP

4×4 matrices, homogeneous coordinates and the model, view and projection chain that puts a vertex on the screen.

How it works

A mesh stores its vertices relative to its own origin, the way the artist modeled it. To draw it, every vertex has to end up at a pixel: placed in the world, seen from the camera, projected onto the screen. Each of those steps is a matrix, and their product is the MVP matrix (model, view, projection) that almost every vertex shader applies. This is the 3D version of the 2D transforms: the same idea of moving coordinates between spaces, with one more dimension and a projection at the end.

Why 4×4

A mat3 can rotate and scale a 3D vector, but it can’t translate it: multiplying the zero vector by any matrix gives zero, so the origin can never move. The fix is homogeneous coordinates: write a point as vec4(x, y, z, 1.0) and put the translation in the matrix’s fourth column. Multiplying adds the translation times that 1:

mat4 translate(vec3 t) {
    return mat4(1.0, 0.0, 0.0, 0.0,   // column 0
                0.0, 1.0, 0.0, 0.0,   // column 1
                0.0, 0.0, 1.0, 0.0,   // column 2
                t.x, t.y, t.z, 1.0);  // column 3: the translation
}

As with mat2, GLSL fills matrices column by column, so the translation sits in the last group of four numbers, and m[3] reads that column back.

The fourth component also tells points from directions. A point has w = 1 and is moved by translation. A direction (a normal, a light direction, a velocity) has w = 0, so the translation column is multiplied by zero and it only rotates and scales. That is the right behavior: moving an object shouldn’t change which way its surface faces.

The chain

  • Model takes the vertex from object space to world space: the object’s scale, rotation and position in the scene. Each object has its own.
  • View takes world space to view space, where the camera sits at the origin looking down an axis (−z in OpenGL and WebGL). It is the inverse of the camera’s own model matrix: moving the camera right is the same as moving the whole world left, the same reversal as moving space instead of the shape in 2D.
  • Projection takes view space to clip space. A perspective projection copies the distance in front of the camera into w; an orthographic one leaves w at 1.

Matrices apply right to left, so the one nearest the vector acts first:

// vertex shader
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
in vec3 aPosition;

void main() {
    vec4 world = uModel * vec4(aPosition, 1.0);
    // model first, then view, then projection
    gl_Position = uProjection * uView * world;
}

Matrix products are associative but not commutative: P * V * M can be grouped as (P * V) * M and computed once, but the order of the three can’t change. Programs usually multiply them on the CPU, once per object, and send one matrix rather than doing two extra 4×4 multiplications in every vertex.

Clip space and the divide

gl_Position is in clip space, still four-dimensional. The GPU clips triangles to the visible volume, where x and y lie between −w and w, and z between −w and w in OpenGL and WebGL (0 and w in Direct3D, Vulkan and Metal), then divides by w. The result is normalized device coordinates (NDC): −1 to 1 on x and y across the viewport. Because a perspective matrix put the distance in w, that divide shrinks far things toward the center, which is what perspective is. The viewport transform then maps NDC to pixels, the gl_FragCoord a fragment shader reads.

Depth conventions differ. OpenGL and WebGL map NDC z to −1..1; Direct3D, Vulkan and Metal use 0..1, and many engines reverse it (1 near, 0 far) for better precision. Code that reads depth back has to know which one it is getting.

Inverses

Going backward is multiplying by the inverse. Unprojecting a pixel to a world-space point, for a ray from the camera or to rebuild a position from the depth buffer, uses the inverse of P * V and then divides by w:

vec4 h = inverse(uProjection * uView) * vec4(ndc, 1.0); // ndc: x, y and depth in −1..1
vec3 worldPos = h.xyz / h.w;

GLSL ES 3.00 has inverse(), but it is expensive; compute inverses on the CPU and pass them in when you can.

Normals need care. With a non-uniform scale (say 2 in x and 1 in y), transforming a normal by the model matrix tilts it so it is no longer perpendicular to the surface. The correct matrix for normals is the inverse transpose of the model matrix’s upper 3×3, often called the normal matrix:

mat3 normalMatrix = transpose(inverse(mat3(uModel))); // better computed once on the CPU
vec3 n = normalize(normalMatrix * aNormal);

With only rotation and uniform scale, mat3(uModel) works too, followed by normalize.

In engines and on this site

Engines build these matrices for you and expose them by name: Godot’s spatial shaders have MODEL_MATRIX, VIEW_MATRIX and PROJECTION_MATRIX, Unity has UNITY_MATRIX_MVP and friends, and three.js passes modelMatrix, viewMatrix and projectionMatrix. The cards on this site hand effect.glsl positions and normals already in world space, so a card’s own code rarely touches the chain. It matters when you move vertices, read depth, or port a shader between engines that differ in handedness and depth range.

Further reading