3D Projection: Perspective Projection
We’re finally at the last stretch for 3D projection! In this post, we want to transform what is known as the view frustum1 into a cube. Recall from the previous post, the following is a perspective view frustum:
Let’s first consider how to map the screen coordinates. These would be the x- and y-coordinates. I have conveniently avoided discussing the near and far clipping planes in previous posts, but their presence is worth discussing. In reality, we don’t have clipping planes in our view. That’s why you can see Mars with your naked eye at night, but most games seem to have some sort of fog or terrain in the distance that can never be reached2. Some of the reasons for this limitation:
- Depth information is stored as a floating-point value. You could increase this, but you could never match reality (infinite viewing distance) without tanking performance or running into z-fighting issues3.
- The next limitation is likely your graphics card. You would not be able to render the number of objects that could fit in an infinitely long viewing frustum without running out of memory. Even if you could, it would definitely not be in real-time4.
Derivation
Starting with the -coordinate, let’s imagine the -axis is pointing out of the screen from the origin (or we are looking down the -axis).
This makes it clear that the screen y-coordinate can be determined purely from values on the y- and z-axes. Firstly, because we’re using a right-handed system but have to map to the canonical view volume, which is left-handed, we’re using the fact that the camera “looks down” its negative z-axis. So this is the frustum after the camera has placed all objects in the world relative to its own origin in a right-handed coordinate system. This took me a long time to digest, so feel free to draw it out or do the maths with me to help your understanding5.
If you want to, you can depart from my derivations and try and do a purely left-handed system (from world coordinates all the way to screen projection). You will run into fewer issues and probably tear out less hair. Since I apparently have a penchant for pain, we’ll keep moving forward with a mixed system.
So, from the above, we can determine the value of ( projected onto the screen) using similar triangles6. Noting that the coordinate of the point (vertex) in space that we are projecting is at .
The near and far clipping planes are simply specified as positive values, so I’ve explicitly negated them so that their signs match the -coordinate’s sign7. Similarly, we can imagine looking down the y-axis to determine the screen -coordinate, :
I’ve moved the sign next to the -coordinate because we are going to take advantage of homogeneous coordinates, again. We can construct the desired transformation matrix as follows:
The approach to determining the above is to draw an empty 4x4 transformation matrix (a partial perspective projection). I then filled in the coordinates for a vertex and the resultant answer. I knew that I wanted to divide the - and -coordinates by , so I reserved that in the final row of the resultant vector through the use of the .
Now, this forces us to divide the coordinate by when going from clip-space to NDC. We want to preserve the initial as-is, but remove the negative signs to pass to the left-handed orthographic projection derived in the previous post. Therefore we must have:
Finally, we have the third row of the transformation matrix. We can intuitively assume that and do not contribute to remapping the -coordinate back to its original scale (with the sign flipped into a positive). So we assume the last two elements, and , of the row are unknown.
We know that this equation must be satisfied at the near and far clipping planes.
Substituting (3) into (1) we have:
Finally, substituting (4) into (3) we have:
Substituting in, we get the perspective-to-orthographic transformation matrix :
Assuming the camera is centered on the z-axis, for the orthographic projection we have , . Therefore, we can infer the following:
Now, combining the orthographic and perspective-to-orthographic matrices, we get the perspective projection matrix, .
We’re nearly there, however we can make some additional reductions to this matrix. Let’s consider the view frustum again with some additional annotations:
Using the definition of , and acknowledging that ( is negative), we can construct the following:
We can specify the aspect ratio as the ratio between the screen width and screen height . So, we can derive the value for as:
Therefore, the final perspective projection matrix can be given as:
And that’s it! With all of these combined, you can create a mapping from 3D space to a perspective-projected space represented in normalized device coordinates. Unfortunately, we still need to do some work in the WebGPU series before we get to see the application, but it is very close!
Footnotes
-
Frustum is Latin for “morsel” or “piece cut off”. ↩
-
The 60+ FPS dream is dead. ↩
-
I had a sign flipped in the view matrix for the longest time when originally figuring these out for myself – it was maddening. ↩
-
A trick from geometry that I find keeps popping up all over the place: https://en.wikipedia.org/wiki/Similarity_(geometry). ↩
-
Shakes left fist at right-handed coordinate system. ↩