Every chapter so far treated the screen as something that just works: you set a material, called Instantiate(), and a 3D model showed up. This chapter opens up that black box. Chapter 2.1 already built the math you need — model, view, and projection matrices that turn a 3D point into a position on screen. This chapter shows exactly what a GPU (Graphics Processing Unit, the chip that draws your game) does with that math, one stage at a time, from a single triangle sitting in memory to colored pixels sitting in a framebuffer (the block of memory holding the finished image). By the end, you will have written a tiny software version of a GPU's core drawing loop yourself, in plain C++, so none of the stages feel like magic anymore.
Every 3D model in a game — a character, a rock, a sword — is really just a pile of triangles (a mesh). Each triangle is defined by three vertices (corner points, singular vertex), and each vertex carries a position and often extra data too, like a color, a texture coordinate, or the direction it faces (a normal). Turning that pile of triangles into the picture you see on your monitor is called rendering, and the fixed sequence of steps a GPU runs to do it is called the rendering pipeline.
This chapter walks through every stage between those two pictures: vertices go into a vertex shader, get grouped into triangles, get turned into candidate pixels by rasterization, get colored by a fragment shader, and finally land in the framebuffer that your monitor displays. Here is the whole trip in one line — every arrow gets its own section later in this chapter:
None of this is specific to Unity or Unreal. Both engines, and every other real-time 3D engine, sit on top of exactly this pipeline, provided by a graphics API (DirectX, Vulkan, Metal, or OpenGL) talking to your GPU's driver. When you write a shader later in this course, you are writing code that plugs directly into two of these stages. Understanding the whole pipeline first is what makes shader code make sense instead of feeling like a magic incantation copied from a tutorial.
Before looking at pipeline stages one by one, it helps to fix one idea firmly in your head: a GPU is built completely differently from a CPU, and the whole shape of the pipeline only makes sense once you know why.
A CPU (Central Processing Unit — the chip running your normal game logic, your C# scripts, your Update() calls) has a small number of powerful cores. Each core can do complicated, unpredictable work: chase pointers, branch all over the place, run completely different code from the core sitting next to it. A GPU takes the opposite approach: thousands of small, simple cores, all running the same tiny program at the same time, each one working on a different piece of data.
This is exactly why the pipeline works the way it does. A shader (a small program that runs on the GPU) is not written as "loop over every vertex" — it is written as "here is what to do for one vertex," and the GPU runs that same tiny program on thousands of vertices at once. The same idea repeats for pixels later in the pipeline. You never write the loop yourself. The hardware is the loop.
Here is the mental model as plain C++, simulating on one CPU core what a GPU does across thousands of cores simultaneously. This loop below is not something you would write for a real GPU — but it is exactly what conceptually happens, just one at a time instead of all at once:
#include <iostream>
#include <vector>
struct Vertex { float x, y, z; };
// This is the "shader": a tiny program written for ONE vertex.
Vertex simpleShader(Vertex in)
{
// A GPU runs this exact function, unmodified, once per vertex --
// never a loop written by you.
return { in.x * 2.0f, in.y * 2.0f, in.z * 2.0f };
}
int main()
{
std::vector<Vertex> vertices = { {1,1,1}, {2,0,1}, {0,3,1}, {4,4,1} };
// A real GPU would run simpleShader() on all 4 of these AT THE SAME
// TIME, on 4 different cores. A CPU has to loop, one at a time:
for (Vertex& v : vertices)
{
v = simpleShader(v);
}
for (Vertex& v : vertices)
{
std::cout << "(" << v.x << ", " << v.y << ", " << v.z << ")\n";
}
}
Output:
(2, 2, 2)
(4, 0, 2)
(0, 6, 2)
(8, 8, 2)
On a CPU, this for loop runs one vertex after another. On a real GPU with, say, 2000 shader cores, all 4 of these (and thousands more from a real mesh) would run simpleShader in parallel, finishing in roughly the time it takes to do one of them. This is why a GPU can push millions of vertices and tens of millions of pixels every single frame: not because each core is fast, but because there are so many of them working at the same time.
if statements are more expensive on a GPU than on a CPU. Nearby cores are grouped and forced to march in lockstep (called a warp or wavefront depending on the hardware vendor). If some cores in a group take the if branch and others take the else, the hardware runs both branches for the whole group and throws away the wrong half for each core — called divergence. A shader full of branches can be far slower than one that avoids them, even if it looks like it is doing "more" work overall.Why this matters to you: you will not write the GPU's scheduler, but every design choice in the rest of this chapter — running the same tiny shader per vertex, then again per pixel, with no shared state between calls — exists because of this hardware shape. Keep this picture in your head through the rest of the chapter: one small program, run an enormous number of times, in parallel, on independent pieces of data.
Now the full picture, stage by stage. Some stages are programmable (you write the code that runs there — a shader), and some are fixed-function (the GPU hardware does a fixed job you cannot rewrite, only configure with a few settings).
Two stages are programmable: the vertex shader and the fragment shader (also called a pixel shader on some APIs — same idea, different name). These are the two places where you, the programmer, actually write code the GPU runs. Every other stage — assembling vertices into triangles, figuring out which pixels a triangle covers, testing depth — is done by fixed hardware circuits, refined over decades of GPU design, that you configure rather than rewrite.
The rest of this chapter walks the diagram top to bottom, one stage at a time, building small C++ code for each one so you can see exactly what is happening instead of trusting a black box.
Chapter 2.1 built the matrices this whole chapter runs on. Before touching the vertex shader itself, here is a fast recap of the coordinate spaces a vertex passes through, because the vertex shader's entire job is moving a point through this exact chain:
If any of these four names feel unfamiliar, go back and re-read chapter 2.1's section 9 before continuing — this chapter assumes you already know how the model, view, and projection matrices are built and multiplied together. This chapter picks up exactly where that one left off: where, inside the GPU pipeline, does this math actually run? The answer is: entirely inside the vertex shader, which is the next section.
The vertex shader is the first programmable stage. Its contract is strict and simple: one vertex position (and its other data) goes in, one clip-space position (and its other data) comes out. It cannot see any other vertex, cannot add or remove vertices, and cannot know anything about pixels yet. That narrow contract is exactly what lets the GPU run it on thousands of vertices at once, per section 2.
In real engines, the model, view, and projection matrices from chapter 2.1 are usually multiplied together once into a single 4x4 MVP matrix before the vertex shader ever runs — engine code on the CPU does this multiplication once per object per frame, since redoing it per-vertex, thousands of times, would waste work. The vertex shader's whole job then becomes one matrix-vector multiply:
#include <iostream>
struct Vec3 { float x, y, z; };
struct Vec4 { float x, y, z, w; };
struct Mat4 { float m[4][4]; };
Vec4 mul(const Mat4& M, Vec4 v)
{
Vec4 r;
r.x = M.m[0][0]*v.x + M.m[0][1]*v.y + M.m[0][2]*v.z + M.m[0][3]*v.w;
r.y = M.m[1][0]*v.x + M.m[1][1]*v.y + M.m[1][2]*v.z + M.m[1][3]*v.w;
r.z = M.m[2][0]*v.x + M.m[2][1]*v.y + M.m[2][2]*v.z + M.m[2][3]*v.w;
r.w = M.m[3][0]*v.x + M.m[3][1]*v.y + M.m[3][2]*v.z + M.m[3][3]*v.w;
return r;
}
// THE VERTEX SHADER: one local-space vertex in, one clip-space vertex out.
Vec4 vertexShader(Vec3 localPos, const Mat4& mvp)
{
Vec4 v = { localPos.x, localPos.y, localPos.z, 1.0f }; // w=1: this is a POINT (ch. 2.1)
return mul(mvp, v);
}
int main()
{
// A stand-in MVP matrix, already combining model, view, and projection
// (built the way chapter 2.1 sections 8-9 showed). Its bottom row is
// the part a real perspective projection adds: it copies depth into w.
Mat4 mvp = { {
{1, 0, 0, 0},
{0, 1, 0, 0},
{0, 0, 1, 0},
{0, 0, 1, 0}
} };
Vec3 localVertex = { 2, 3, 5 };
Vec4 clip = vertexShader(localVertex, mvp);
std::cout << "clip = (" << clip.x << ", " << clip.y << ", "
<< clip.z << ", " << clip.w << ")\n";
}
Output:
clip = (2, 3, 5, 5)
Because this example MVP's bottom row is {0, 0, 1, 0}, the output w came out equal to the input z — a simplified stand-in for what a real perspective projection matrix does: it makes w carry depth information, exactly like chapter 2.1 section 10 described. x and y passed through unchanged here only because the rest of this example matrix is the identity; a real MVP matrix rotates, scales, and offsets them too.
The vertex shader's output, clip space, is not yet a screen pixel. Two more steps happen automatically, outside any shader you write, before rasterization can use the result — but tracing them by hand makes the whole chain click into place:
struct ScreenPos { float x, y; };
// Step A: perspective divide -- turns clip space into NDC (Normalized
// Device Coordinates, roughly -1..1 on each axis). The GPU does this
// automatically; it is NOT something you write inside a shader.
Vec3 perspectiveDivide(Vec4 clip)
{
return { clip.x / clip.w, clip.y / clip.w, clip.z / clip.w };
}
// Step B: viewport transform -- stretches NDC's -1..1 square onto the
// actual pixel grid of the render target.
ScreenPos viewportTransform(Vec3 ndc, float screenW, float screenH)
{
ScreenPos s;
s.x = (ndc.x * 0.5f + 0.5f) * screenW;
s.y = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Y flipped: rows grow downward
return s;
}
int main()
{
Vec4 clip = { 2, 3, 5, 5 }; // from the vertex shader example above
Vec3 ndc = perspectiveDivide(clip);
ScreenPos screen = viewportTransform(ndc, 800.0f, 600.0f);
std::cout << "ndc = (" << ndc.x << ", " << ndc.y << ")\n";
std::cout << "screen = (" << screen.x << ", " << screen.y << ")\n";
}
Output:
ndc = (0.4, 0.6)
screen = (560, 120)
Trace it: dividing (2, 3, 5) by w = 5 gives NDC (0.4, 0.6) — inside the visible -1..1 square, so this vertex is on screen. The viewport step then stretches that square onto an 800x600 image: 0.4 maps to x = 560 (right of center), and 0.6 maps to y = 120 (flipped, landing near the top). This exact chain — MVP multiply, divide by w, stretch to pixels — is what every vertex in your game goes through, every frame, run in parallel across thousands of GPU cores at once.
w is not always 1. If you ever manually read back a clip-space position (for example, in a custom shader effect), dividing only x and y by w while forgetting z gives a broken depth value — and skipping the divide entirely gives coordinates the wrong scale to be pixels at all.After the vertex shader has run on every vertex, the GPU has a big list of individual points — it does not yet know which three points form a triangle. Primitive assembly is the fixed-function stage that regroups them, using an index buffer (a list of integers saying which vertices, by position in the vertex buffer, belong to which triangle) that was set up on the CPU side before the draw call.
#include <iostream>
#include <vector>
struct Triangle { int a, b, c; };
int main()
{
// 4 vertices forming a quad (a square), already run through the
// vertex shader. Reusing vertex 0 and 2 for both triangles avoids
// sending duplicate vertex data through the vertex shader twice.
std::vector<int> indices = { 0, 1, 2, 0, 2, 3 };
std::vector<Triangle> triangles;
for (size_t i = 0; i < indices.size(); i += 3)
{
triangles.push_back( { indices[i], indices[i+1], indices[i+2] } );
}
for (const Triangle& t : triangles)
{
std::cout << "triangle: (" << t.a << ", " << t.b << ", " << t.c << ")\n";
}
}
Output:
triangle: (0, 1, 2)
triangle: (0, 2, 3)
Two more things happen around this stage, both fixed-function: clipping chops off the parts of any triangle sticking outside the visible clip-space volume (a triangle half off-screen gets cut, not just discarded whole), and backface culling can throw away triangles facing away from the camera entirely, based on their winding order (whether their three vertices go clockwise or counter-clockwise on screen). Culling saves the GPU from ever rasterizing or shading a pixel you could never see, like the inside of a character's head.
Rasterization is where a triangle, a mathematical shape made of three points and straight edges, gets converted into actual pixels. It answers one question, over and over, for every pixel near the triangle: is this pixel's center inside the triangle?
The classic test uses an edge function. For an edge running from point a to point b, and a candidate point p, the edge function's sign tells you which side of the line p is on. Run it for all three edges of a triangle; if p lands on the same side of all three, it is inside.
#include <iostream>
struct Vec2 { float x, y; };
float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}
bool insideTriangle(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
float w0 = edgeFunction(b, c, p);
float w1 = edgeFunction(c, a, p);
float w2 = edgeFunction(a, b, p);
// Inside if p is on the same side of all three edges -- all
// non-negative, or all non-positive (handles either winding order).
return (w0 >= 0 && w1 >= 0 && w2 >= 0) ||
(w0 <= 0 && w1 <= 0 && w2 <= 0);
}
int main()
{
Vec2 A = {2, 1}, B = {14, 2}, C = {7, 8};
for (int y = 0; y < 10; y++)
{
for (int x = 0; x < 17; x++)
{
Vec2 pixelCenter = { x + 0.5f, y + 0.5f }; // sample the pixel's MIDDLE
std::cout << (insideTriangle(A, B, C, pixelCenter) ? '#' : '.');
}
std::cout << "\n";
}
}
Output:
.................
..######.........
...##########....
....########.....
....#######......
.....#####.......
......###........
.......#.........
.................
.................
Every # is a pixel the rasterizer decided belongs to this triangle. This nested loop over a bounding area is exactly the shape of what fixed-function rasterization hardware does, just built into silicon instead of a CPU loop, and tested on many pixels in parallel rather than one at a time.
x + 0.5, y + 0.5), not its corner, is a deliberate, standard choice — it keeps triangle edges looking consistent no matter which direction they run. Some GPUs sample several points per pixel instead of just the center, called MSAA (Multisample Anti-Aliasing), and blend the results to soften jagged triangle edges.The three edge function values used above are not thrown away in a real rasterizer — divided by the triangle's total area, they become barycentric coordinates: three weights (wA, wB, wC), one per vertex, that always add up to 1 for any point inside the triangle. They answer a second, equally important question: not just is this pixel inside, but how close is it to each of the three corners?
These weights are exactly what lets a triangle's flat, per-vertex data (a color, a texture coordinate, a normal) turn into a smooth gradient across its surface: multiply each vertex's value by its weight, and add. This is called interpolation, and it runs for nearly everything a fragment shader touches.
struct Color { float r, g, b; };
struct Barycentric { float wA, wB, wC; };
Barycentric computeBarycentric(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
float area = edgeFunction(a, b, c);
Barycentric bc;
bc.wA = edgeFunction(b, c, p) / area;
bc.wB = edgeFunction(c, a, p) / area;
bc.wC = edgeFunction(a, b, p) / area;
return bc;
}
Color interpolateColor(Barycentric bc, Color colA, Color colB, Color colC)
{
return {
bc.wA * colA.r + bc.wB * colB.r + bc.wC * colC.r,
bc.wA * colA.g + bc.wB * colB.g + bc.wC * colC.g,
bc.wA * colA.b + bc.wB * colB.b + bc.wC * colC.b
};
}
int main()
{
Vec2 A = {2, 1}, B = {14, 2}, C = {7, 8};
Color colA = {255, 0, 0}; // vertex A is pure red
Color colB = {0, 255, 0}; // vertex B is pure green
Color colC = {0, 0, 255}; // vertex C is pure blue
Vec2 p = { 7.5f, 4.5f }; // a pixel center inside the triangle
Barycentric bc = computeBarycentric(A, B, C, p);
Color result = interpolateColor(bc, colA, colB, colC);
std::cout << "weights = (" << bc.wA << ", " << bc.wB << ", " << bc.wC << ")\n";
std::cout << "color = (" << result.r << ", " << result.g << ", " << result.b << ")\n";
}
Output:
weights = (0.272152, 0.265823, 0.462025)
color = (69.4, 67.8, 117.8)
Pixel (7.5, 4.5) sits closest to vertex C (blue), a bit further from A (red) and B (green) — and its blended color, mostly blue with some red and green mixed in, reflects exactly that. This same weighted blend is how a mesh's smooth-looking lighting, smooth-looking color gradients, and smooth-looking texture coordinates all come from data that only actually exists at three corners.
Once rasterization decides a pixel is covered by a triangle and computes its barycentric weights, the GPU runs the second programmable stage: the fragment shader (also called a pixel shader). Just like the vertex shader, its contract is narrow: one fragment (a candidate pixel, with its interpolated data already computed) goes in, one final color comes out. It cannot see neighboring pixels or reach back to other vertices — everything it needs must already have been interpolated for it.
float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
struct Color { float r, g, b; };
// THE FRAGMENT SHADER: one interpolated surface normal in, one color out.
// (dot product recap: chapter 2.1, section 4)
Color fragmentShader(Vec3 interpolatedNormal, Vec3 lightDir, Color baseColor)
{
float brightness = dot(interpolatedNormal, lightDir);
if (brightness < 0.0f) brightness = 0.0f; // facing away from the light: no negative light
return { baseColor.r * brightness, baseColor.g * brightness, baseColor.b * brightness };
}
int main()
{
Vec3 normal = {0, 1, 0}; // this pixel's surface faces straight up
Vec3 lightDir = {0, 1, 0}; // light is coming from straight above
Color baseColor = {200, 150, 100};
Color shaded = fragmentShader(normal, lightDir, baseColor);
std::cout << "shaded color = (" << shaded.r << ", " << shaded.g << ", " << shaded.b << ")\n";
}
Output:
shaded color = (200, 150, 100)
The surface faces directly toward the light, so dot(normal, lightDir) = 1.0 and the base color passes through unchanged. Tilt the surface's normal away from the light and brightness drops toward 0, darkening the pixel — the same dot-product-measures-alignment idea from chapter 2.1, now deciding how lit a single pixel looks.
A fragment shader can also sample a texture (an image wrapped onto the surface, using interpolated texture coordinates from section 7) instead of, or blended with, a flat base color — that full topic is its own future chapter, but section 10 of this chapter explains one crucial detail about interpolating those texture coordinates correctly.
Triangles are rasterized and shaded in some order, but that order has nothing to do with which one is actually closest to the camera. Without extra help, a wall's triangle rasterized after a character's triangle would simply overwrite the character on screen, even though the wall is behind it. Sorting every triangle by distance before drawing (the painter's algorithm, like a painter working back to front) sounds like a fix, but it breaks down the moment two triangles overlap or poke through each other — there is no single correct order for two triangles that intersect.
The real fix works per pixel, not per triangle: alongside the framebuffer's color, the GPU keeps a second buffer the same size, the depth buffer (or z-buffer), storing the depth of the closest fragment written to each pixel so far. Every new fragment's depth is compared against it before its color is allowed to overwrite anything — this comparison is the depth test, the fixed-function stage right after the fragment shader in section 3's diagram.
#include <iostream>
#include <vector>
#include <string>
struct Vec2 { float x, y; };
float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}
bool insideTriangle(Vec2 a, Vec2 b, Vec2 c, Vec2 p)
{
float w0 = edgeFunction(b, c, p);
float w1 = edgeFunction(c, a, p);
float w2 = edgeFunction(a, b, p);
return (w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0);
}
struct Triangle { Vec2 a, b, c; float depth; char symbol; };
void drawTriangle(const Triangle& tri, std::vector<std::vector<float>>& depthBuf,
std::vector<std::string>& canvas)
{
for (size_t y = 0; y < canvas.size(); y++)
{
for (size_t x = 0; x < canvas[y].size(); x++)
{
Vec2 p = { x + 0.5f, y + 0.5f };
if (!insideTriangle(tri.a, tri.b, tri.c, p)) continue;
// THE DEPTH TEST: only write if this fragment is closer
// (smaller depth) than whatever is already at this pixel.
if (tri.depth < depthBuf[y][x])
{
depthBuf[y][x] = tri.depth;
canvas[y][x] = tri.symbol;
}
}
}
}
int main()
{
const int W = 18, H = 11;
std::vector<std::vector<float>> depthBuf(H, std::vector<float>(W, 1e9f));
std::vector<std::string> canvas(H, std::string(W, '.'));
Triangle farTri = { {2,1}, {15,1}, {8,9}, 0.8f, '1' }; // farther from camera
Triangle nearTri = { {6,3}, {12,3}, {9,7}, 0.3f, '2' }; // closer to camera
// Drawn far-then-near on purpose -- watch the depth test fix the order.
drawTriangle(farTri, depthBuf, canvas);
drawTriangle(nearTri, depthBuf, canvas);
for (const std::string& row : canvas) std::cout << row << "\n";
}
Output:
..................
..1111111111111...
...11111111111....
....112222221.....
.....1122221......
.....111221.......
......1111........
.......11.........
..................
..................
..................
Triangle 2 (depth 0.3, nearer) correctly punches through triangle 1 (depth 0.8, farther) wherever they overlap, even though this code draws 1 first and 2 second. Swap the order of the two drawTriangle calls and rerun: the picture comes out byte-for-byte identical, because the depth test, not draw order, decides the winner at every pixel. This is exactly what makes the z-buffer so powerful: a whole scene's triangles can be submitted in any order — usually whichever order is fastest to send to the GPU — and the picture still comes out correct.
Section 7 interpolated color using barycentric weights computed in screen space — the flat, 2D pixel grid. That works perfectly for a triangle that is roughly flat-on to the camera. But most triangles in a 3D scene tilt away into the distance, and screen-space interpolation of things like texture coordinates gets visibly wrong the moment they do. This section is about why, and how the fix works.
The problem: perspective projection (section 5, and chapter 2.1 section 10) is not a straight-line operation — it divides by w, which squashes distant points together non-linearly. A straight line in 3D world space does not stay evenly spaced once projected to the screen; points near the camera spread out, points far away bunch up. But barycentric weights computed directly from screen-space pixel positions assume even spacing. The result: interpolating an attribute using plain screen-space weights gives the wrong value whenever a triangle's corners sit at different depths.
Here is a worked, numeric example. Picture one edge of a triangle: vertex 0 is close to the camera (clip-space w = 1), vertex 1 is far away (clip-space w = 4), and each carries a texture coordinate: u = 0 at vertex 0, u = 1 at vertex 1. What u value belongs exactly at the screen-space midpoint between them?
#include <iostream>
int main()
{
float w0 = 1.0f, w1 = 4.0f; // vertex 0 is near, vertex 1 is far
float u0 = 0.0f, u1 = 1.0f; // texture coordinate at each vertex
float t = 0.5f; // the SCREEN-SPACE midpoint (halfway in pixels)
// WRONG: plain linear interpolation using the screen-space t.
float uNaive = u0 * (1.0f - t) + u1 * t;
// CORRECT: interpolate (attribute / w) and (1 / w) using the same
// screen-space t, THEN divide -- this is perspective-correct
// interpolation, and it is what real GPU rasterizers actually do.
float numerator = (u0 / w0) * (1.0f - t) + (u1 / w1) * t;
float denominator = (1.0f / w0) * (1.0f - t) + (1.0f / w1) * t;
float uCorrect = numerator / denominator;
std::cout << "naive u = " << uNaive << " (WRONG)\n";
std::cout << "correct u = " << uCorrect << " (what the true 3D midpoint has)\n";
}
Output:
naive u = 0.5 (WRONG)
correct u = 0.2 (what the true 3D midpoint has)
That is not a rounding error — it is a completely different number. The screen-space halfway point between a near vertex and a far vertex does not correspond to the halfway point along the original 3D edge; because the far vertex's geometry gets more "compressed" by the perspective divide, the true 3D midpoint actually lands much closer to the near vertex's texture coordinate (0.2, not 0.5) once you measure back in screen space. Interpolating attribute / w and 1 / w linearly, then dividing them, exactly cancels out that compression and recovers the correct value.
1/w version automatically, in fixed-function hardware, for every triangle, at no extra cost to you as a programmer.When you need to think about this yourself: almost never in Unity or Unreal — both engines and their GPU targets always do perspective-correct interpolation for you. It matters when you write your own software rasterizer (section 12), when debugging a warped-texture bug on old fixed-function or low-end mobile paths, or simply to understand why this division exists instead of memorizing it as an unexplained rule.
Every stage so far ends with a color landing in the framebuffer — a block of memory holding one full image, one color per pixel. Your monitor reads from a framebuffer continuously, top row to bottom row, refreshing typically 60, 120, or 144 times a second (its refresh rate). This creates a timing problem: what if the GPU is still halfway through drawing a new frame into that exact same memory the monitor is currently reading from?
If there is only one framebuffer, the monitor can end up displaying the top half of an old frame and the bottom half of a new one in the same instant, because the GPU overwrote the memory mid-scan. This visible seam is called screen tearing.
The fix is double buffering: keep two framebuffers. The monitor always reads from the front buffer, a complete, finished frame. The GPU always draws the next frame into the other one, the back buffer, which the monitor never sees while it is unfinished. Once the back buffer is completely done, the two buffers swap roles — ideally timed to happen only between the monitor's scans, called VSync (Vertical Synchronization) — so the monitor never catches a frame mid-draw.
In code, this shows up as one call at the very end of your render loop — the exact Render() step from the game loop you saw back in chapter 6.7:
while (gameIsRunning)
{
ProcessInput();
Update(deltaTime);
DrawEverythingIntoBackBuffer(); // every stage of this whole chapter
SwapBuffers(); // back buffer becomes the new front buffer
}
SwapBuffers() (the real function name varies: glfwSwapBuffers, Present(), and others depending on the API) is doing exactly the buffer swap shown in the diagram above. Every frame you have ever seen rendered by Unity or Unreal ended with a call like this one.
Every idea in this chapter — coverage testing, barycentric weights, the depth test — already appeared in small pieces across sections 7 and 9. This final section puts them together into one complete, runnable program: a genuine software rasterizer, small enough to read top to bottom, doing in a single CPU loop exactly what a real GPU's fixed-function hardware does across thousands of parallel cores.
#include <iostream>
#include <vector>
#include <string>
struct Vec2 { float x, y; };
float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}
struct Triangle { Vec2 a, b, c; float depth; char symbol; };
// This one function is the whole rendering pipeline, shrunk down:
// - the "vertices" (a, b, c) already arrived in screen space (section 5)
// - this loop over pixels IS rasterization (section 7)
// - the edge-function test IS the inside test (section 7)
// - the depth comparison IS the z-buffer / depth test (section 9)
// - framebuffer[y][x] = symbol IS writing the final framebuffer (section 11)
void rasterize(const Triangle& tri,
std::vector<std::vector<float>>& depthBuffer,
std::vector<std::string>& framebuffer)
{
int H = (int)framebuffer.size();
int W = (int)framebuffer[0].size();
for (int y = 0; y < H; y++)
{
for (int x = 0; x < W; x++)
{
Vec2 p = { x + 0.5f, y + 0.5f };
float w0 = edgeFunction(tri.b, tri.c, p);
float w1 = edgeFunction(tri.c, tri.a, p);
float w2 = edgeFunction(tri.a, tri.b, p);
bool inside = (w0 >= 0 && w1 >= 0 && w2 >= 0) ||
(w0 <= 0 && w1 <= 0 && w2 <= 0);
if (!inside) continue;
if (tri.depth < depthBuffer[y][x])
{
depthBuffer[y][x] = tri.depth;
framebuffer[y][x] = tri.symbol;
}
}
}
}
int main()
{
const int W = 18, H = 11;
std::vector<std::vector<float>> depthBuffer(H, std::vector<float>(W, 1e9f));
std::vector<std::string> framebuffer(H, std::string(W, '.'));
// Two triangles submitted in an arbitrary order -- the near one first
// this time, on purpose, to prove the depth test (not draw order) wins.
Triangle nearTri = { {6,3}, {12,3}, {9,7}, 0.3f, '2' };
Triangle farTri = { {2,1}, {15,1}, {8,9}, 0.8f, '1' };
rasterize(nearTri, depthBuffer, framebuffer);
rasterize(farTri, depthBuffer, framebuffer);
for (const std::string& row : framebuffer)
{
std::cout << row << "\n";
}
}
Output:
..................
..1111111111111...
...11111111111....
....112222221.....
.....1122221......
.....111221.......
......1111........
.......11.........
..................
..................
..................
Same picture as section 9, even with the draw order reversed — the near triangle (2) still correctly shows through the far triangle (1) wherever they overlap. This roughly 40-line program is, in every meaningful sense, a real (if tiny, slow, and color-less) GPU: it takes triangles, rasterizes them, tests their depth, and writes a framebuffer, using exactly the stages from section 3's diagram, minus the vertex and fragment shaders (which you already wrote separately, in sections 5 and 8) and minus the massive parallelism a real GPU applies to all of it.
If you want to extend this yourself: add the barycentric-weighted color interpolation from section 7 instead of a flat symbol per triangle, and you have a tiny, complete, if very slow, textured-and-shaded software renderer — built entirely from ideas you now understand one stage at a time, instead of one unexplained black box.
(1, 1, 4) by this MVP matrix (its bottom row copies depth into w, plus a constant, just like the worked example in section 5):
M = | 1 0 0 0 |
| 0 1 0 0 |
| 0 0 1 0 |
| 0 0 1 2 |
By hand (or in code), compute the clip-space output, then the NDC after the perspective divide, then the final screen position for a 400x300 render target. Show all three intermediate results.
#include <iostream>
int main()
{
// clip = M * (1, 1, 4, 1)
float clipX = 1*1 + 0*1 + 0*4 + 0*1; // = 1
float clipY = 0*1 + 1*1 + 0*4 + 0*1; // = 1
float clipZ = 0*1 + 0*1 + 1*4 + 0*1; // = 4
float clipW = 0*1 + 0*1 + 1*4 + 2*1; // = 6
float ndcX = clipX / clipW;
float ndcY = clipY / clipW;
float screenX = (ndcX * 0.5f + 0.5f) * 400.0f;
float screenY = (1.0f - (ndcY * 0.5f + 0.5f)) * 300.0f;
std::cout << "clip = (" << clipX << ", " << clipY << ", " << clipZ << ", " << clipW << ")\n";
std::cout << "ndc = (" << ndcX << ", " << ndcY << ")\n";
std::cout << "screen = (" << screenX << ", " << screenY << ")\n";
}
Output:
clip = (1, 1, 4, 6)
ndc = (0.166667, 0.166667)
screen = (233.333, 125)
The bottom row {0, 0, 1, 2} means w = z + 2 = 6, so dividing x and y by 6 shrinks them a lot — this vertex sits fairly deep into the scene. The resulting NDC (0.167, 0.167) is close to the center of the screen, just slightly toward the bottom-right, landing at pixel (233, 125) on a 400x300 target.
A = (0, 0), B = (6, 0), C = (0, 6). Compute the barycentric weights of point P = (2, 2) using the edge-function formula from section 7. Is P inside the triangle? Each vertex also carries a depth value: zA = 1.0, zB = 2.0, zC = 3.0. What depth would a rasterizer interpolate for P?#include <iostream>
struct Vec2 { float x, y; };
float edgeFunction(Vec2 a, Vec2 b, Vec2 p)
{
return (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
}
int main()
{
Vec2 A = {0,0}, B = {6,0}, C = {0,6}, P = {2,2};
float area = edgeFunction(A, B, C);
float wA = edgeFunction(B, C, P) / area;
float wB = edgeFunction(C, A, P) / area;
float wC = edgeFunction(A, B, P) / area;
float zA = 1.0f, zB = 2.0f, zC = 3.0f;
float depth = wA*zA + wB*zB + wC*zC;
std::cout << "weights = (" << wA << ", " << wB << ", " << wC << ")\n";
std::cout << "interpolated depth = " << depth << "\n";
}
Output:
weights = (0.333333, 0.333333, 0.333333)
interpolated depth = 2
P = (2, 2) is the exact centroid of this triangle (the average of the three corners), so all three weights come out equal at 1/3 — and since all three are positive and sum to 1, P is inside. The interpolated depth is simply the average of the three vertex depths, (1 + 2 + 3) / 3 = 2, which makes sense: the centroid is "equally close" to all three corners, so it gets an equal share of each one's value.
if (fragmentDepth > depthBuffer[y][x])
{
depthBuffer[y][x] = fragmentDepth;
framebuffer[y][x] = color;
}
The comparison is backwards. > means "only write if this fragment's depth is bigger than what's stored" — but in this convention (matching section 9), a smaller depth means closer to the camera, and closer things should win. As written, every farther fragment that comes after a nearer one will incorrectly overwrite it, which matches exactly the symptom described: far triangles drawn on top of near ones. The fix is to flip the comparison:
if (fragmentDepth < depthBuffer[y][x])
{
depthBuffer[y][x] = fragmentDepth;
framebuffer[y][x] = color;
}
Now a fragment only overwrites the buffer when it is genuinely closer to the camera than whatever is already there, which is the whole point of the depth test from section 9 — the nearest fragment at each pixel wins, regardless of the order triangles were drawn in.