Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

Building a Perspective Projection Matrix

Recommended resources

Introduction

Hello! In our last lesson, we put all the pieces of the graphics pipeline together to render a 2D rectangle using indexed drawing. This gave us an efficient way to draw shapes by reusing vertex data.

Now, we're ready to make the leap from 2D to 3D. Your goal is to build a 3D game engine, and the most fundamental aspect of 3D graphics is creating the illusion of depth. This lesson will focus on exactly that, as we work to achieve the following outcome: Construct a perspective projection matrix to create 3D depth illusion.

We'll start by understanding the concept of a "viewing frustum"—the region of 3D space your virtual camera can see. Then, we'll derive the mathematics needed to project the 3D world onto your 2D screen, making distant objects appear smaller. Finally, you'll write a C function to construct this powerful matrix, which we will soon pass to our shaders using the uniforms you learned about previously. This lesson directly builds on the matrix math from Module 2 and the rendering pipeline from Module 4.


The Goal of Perspective Projection

In the real world, your eyes see a 3D scene, but the image formed on your retina is 2D. Your brain then interprets this 2D image to perceive depth. In computer graphics, we simulate this process. The "camera" in our game world exists in a 3D space, but our monitor is a 2D surface. Projection is the process of transforming 3D coordinates into 2D screen coordinates.

The specific type we're interested in is perspective projection, which mimics how human vision works. The key principle is that objects farther away from the camera should appear smaller.

This projection is defined by a volume in space shaped like a pyramid with its top cut off. This shape is called the viewing frustum. Anything inside this frustum will be visible on the screen; anything outside will be clipped (cut away).

Camera Setup with Field of View and Viewing Frustum
A camera's viewing frustum, defined by a Field of View (FOV), a near plane, and a far plane. Everything inside this volume is projected onto the screen.

The job of the projection matrix is to take every 3D point inside this frustum and transform it into a perfectly regular cube, with coordinates ranging from -1 to 1 on all axes. This standardized cube is called Normalized Device Coordinates (NDC). OpenGL then takes this NDC cube and maps it to your 2D screen to create the final image.

The perspective projection matrix accomplishes three main tasks to achieve this. Let's watch a video that introduces them.

Perspective Projection Matrix (Math for Game Developers)

This video from the pikuma channel provides an excellent overview of the three main goals of a perspective projection matrix.

Watch from 00:28 to 05:45. As you watch, take note of the three distinct responsibilities of the matrix: Accounting for the screen's aspect ratio. Accounting for the camera's field of view (FOV). Normalizing the depth (Z) values.


Deriving the Projection Matrix

Now that you have a conceptual overview, let's dive into the mathematics. A complete projection matrix looks intimidating, but it's built from smaller, understandable parts that correspond to the three tasks you just learned about. We'll use the principles of similar triangles and some algebra to derive the components.

The core idea relies on similar triangles, as shown in this diagram.

Perspective Projection Geometry
Using similar triangles, the projected y-coordinate (\(P_s\)_y) can be found relative to the original point's y and z coordinates and the distance to the near plane, \(n\).

The goal is to derive a matrix that:

  1. Projects the x and y coordinates using the principle of similar triangles, resulting in division by the point's depth ().
  2. Remaps the projected x and y coordinates from the frustum's dimensions ([left, right], [bottom, top]) to the NDC range [-1, 1].
  3. Remaps the original z coordinate from the frustum's depth range [near, far] to the NDC range [-1, 1].

The following reading from Scratchapixel provides a detailed, step-by-step derivation of the standard OpenGL projection matrix. Your background in engineering means you're used to applying mathematical models, and this is a prime example.

The OpenGL Perspective Projection Matrix

This article provides a rigorous mathematical derivation of the OpenGL perspective projection matrix. We'll walk through it section by section.

Read the sections titled 'Principle' and 'Derivation'. Don't get bogged down in memorizing every algebraic step. Instead, focus on understanding the process: For X and Y: First, they use similar triangles to get the basic projection formula (e.g., P_s_y = rac{n \cdot P_y}{-P_z}). Then, they use a series of algebraic steps (scaling and offsetting) to remap a value from a range like [l, r] to [-1, 1]. For Z: They set up a system of two equations with two unknowns (A and B) to find the coefficients that will map the original z-coordinate from the [n, f] range to [-1, 1]. This derivation directly leads to the matrix we will implement.


From Parameters to a Matrix

You saw in the derivation that the matrix is defined by six parameters: left, right, bottom, top, near, and far. However, it's often more intuitive to control the camera using a vertical field of view (FOV) and the window's aspect ratio.

The Scratchapixel article you just read has a section explaining how to calculate l, r, b, t from these more intuitive parameters. The formulas are:

Where aspect_ratio = screen_width / screen_height.

With these values calculated, we can finally populate our projection matrix. The video from earlier does a great job of showing how all these derived components come together and, most importantly, explains how the "magic" of perspective division actually happens.

Perspective Projection Matrix (Math for Game Developers)

Let's return to the pikuma video to see the final matrix assembled and to understand the crucial role of the 'w' coordinate.

Watch from 17:45 to 28:48. Focus on these key points: How the formulas for aspect ratio, FOV, and Z-normalization map directly to specific cells in the 4x4 matrix. The purpose of the -1 (or 1 in the video's convention) in the third row, fourth column: it copies the vertex's z value into the w component of the output vector. The perspective divide is not done by the matrix itself. It's a separate, automatic hardware step where OpenGL divides the x, y, and z components of the output vector by its w component. This is how the division by z is achieved.


Implementing the Perspective Matrix in C

Now it's time to turn theory into practice. We'll create a function that constructs a perspective projection matrix. This function will take the intuitive parameters (FOV, aspect ratio, near/far planes) and produce a mat4 ready to be sent to our vertex shader.

The final OpenGL-style projection matrix we derived is:

Note that this matrix is in column-major order, which is what OpenGL expects. When you store this in a C array, the element at matrix[column][row] will correspond to the mathematical notation.

Here is a C function you can add to your math library that combines the calculation of the frustum planes with the construction of this matrix.

// Add to your matrix/math header file



#include <math.h> // For tan()

void mat4_perspective(mat4 out, float fovy, float aspect, float near, float far) {
    float f = 1.0f / tanf(fovy / 2.0f);
    
    out[0][0] = f / aspect;
    out[0][1] = 0.0f;
    out[0][2] = 0.0f;
    out[0][3] = 0.0f;

    out[1][0] = 0.0f;
    out[1][1] = f;
    out[1][2] = 0.0f;
    out[1][3] = 0.0f;

    out[2][0] = 0.0f;
    out[2][1] = 0.0f;
    out[2][2] = (far + near) / (near - far);
    out[2][3] = -1.0f;

    out[3][0] = 0.0f;
    out[3][1] = 0.0f;
    out[3][2] = (2.0f * far * near) / (near - far);
    out[3][3] = 0.0f;
}

Note: This implementation is slightly different from the raw Scratchapixel matrix but mathematically equivalent and common in many modern graphics libraries. It combines the tan() calculation directly into the matrix elements.

You can now generate the projection matrix in your main application code like this:

mat4 projection_matrix;
float fov = 45.0f; // degrees
float aspect_ratio = (float)window_width / (float)window_height;
float near_plane = 0.1f;
float far_plane = 100.0f;

// Convert fov to radians for tanf()
mat4_perspective(projection_matrix, fov * 3.14159f / 180.0f, aspect_ratio, near_plane, far_plane);

Conclusion

In this lesson, you took a major step toward rendering a full 3D scene. You have bridged the gap between 3D mathematics and the practicalities of the GPU rendering pipeline.

Key Takeaways:

  • The perspective projection matrix transforms 3D points from a viewing frustum into a standardized cube called Normalized Device Coordinates (NDC).
  • This matrix is constructed from intuitive parameters: Field of View (FOV), aspect ratio, and the near and far clipping planes.
  • The matrix encodes the scaling and remapping operations for the x, y, and z axes.
  • The actual perspective division (dividing by depth to make far objects smaller) is performed automatically by the GPU, which divides the x, y, z of each vertex by its w coordinate after the vertex shader runs.

Preview of the Next Lesson:
We now have the 'P' (Projection) matrix, a critical component of 3D rendering. But it's only one part of the transformation. In the next lesson, we will learn how to combine the model, view, and projection matrices into a single MVP matrix. This will allow us to take a model from its own local space, place it in the world, position the camera to look at it, and finally project it onto the screen, all in one efficient operation within the vertex shader.