7.1 Rendering Pipeline Fundamentals

Phase 7 · Graphics & Rendering · Study time: 40–60 h

How a triangle becomes pixels — vertices, rasterization, fragment shading, the depth buffer. Build a tiny software renderer first so none of it is magic.

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.

1. The Big Picture: From Triangle to Pixels

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.

a mesh (many small triangles) the same mesh, rendered +----+----+----+----+ +-------------------+ | /| /| /| /| | .-. | |/ |/ |/ |/ | | ( o o ) | +----+----+----+----+ | '-' | +-------------------+ just numbers in memory: the same numbers, turned into positions, colors, indices... colored pixels on a screen

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:

vertices --> vertex shader --> primitive assembly --> rasterization --> fragment shader --> framebuffer --> your monitor

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.

2. The GPU Mental Model: Thousands of Tiny Workers

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.

CPU: a few powerful, independent cores GPU: thousands of small, synchronized cores +--------+ +--------+ +--+--+--+--+--+--+--+--+--+--+--+--+ | core 0 | | core 1 | each core can run |c |c |c |c |c |c |c |c |c |c |c |c | | doing | | doing | totally different +--+--+--+--+--+--+--+--+--+--+--+--+ | task A | | task B | code all running the SAME shader program, +--------+ +--------+ each on a DIFFERENT vertex or pixel

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.

Tip 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.

3. The Pipeline at a Glance

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).

[1] Vertex buffer -- raw vertex data sitting in memory | v [2] VERTEX SHADER -- PROGRAMMABLE. runs once per vertex. | transforms local position to clip space (section 5) v [3] Primitive assembly -- FIXED-FUNCTION. groups vertices into triangles, | clips them, may cull back-facing ones (section 6) v [4] RASTERIZATION -- FIXED-FUNCTION. finds which pixels each | triangle covers (section 7) v [5] FRAGMENT SHADER -- PROGRAMMABLE. runs once per covered pixel. | computes a color (section 8) v [6] Depth test -- FIXED-FUNCTION. keeps only the nearest | color per pixel, using the z-buffer (section 9) v [7] Framebuffer -- the finished image, shown on your monitor (section 11)

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.

Tip In Unity's Shader Graph or a raw HLSL/GLSL shader file, the two functions you write map directly onto this diagram: the "vertex" function is stage [2], and the "fragment" (or "pixel") function is stage [5]. Everything else in this diagram is Unity's underlying graphics API and your GPU driver, working automatically.

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.

4. Coordinate Spaces Recap: Model, World, View, Clip

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:

local space (model space) -- the mesh's own tiny coordinate system, | centered on the object itself | MODEL matrix (object's own position/rotation/scale) v world space -- one shared coordinate system every | object in the level agrees on | VIEW matrix (re-measure relative to the camera) v view space (camera space) -- camera sits at the origin, looking | down a fixed axis | PROJECTION matrix (sets up perspective) v clip space -- a 4-value (x, y, z, w) space, ready for the GPU's clipping and divide step

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.

Common mistake Doing gameplay math (distance checks, "is the player near this object") in the wrong space — for example, comparing a mesh's raw local-space vertex positions to a world-space player position. Local space is centered on the object itself; it has no idea where that object was placed in the level. Always transform into world space first (multiply by the model matrix) before comparing positions between different objects. This is a gameplay bug, not a rendering bug, but it is the exact same coordinate-space confusion the vertex shader exists to solve correctly, every frame, automatically.

5. The Vertex Shader: From Local Space to Screen Pixels

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.

Multiplying by the combined MVP matrix

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.

From clip space to screen: the perspective divide and viewport

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.

Common mistake Forgetting that clip-space 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.

6. Primitive Assembly: Vertices Become Triangles

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)
vertex 3 *-----------* vertex 2 | .'| | tri B .' | | .' | | .' | | .' tri A | |.' | vertex 0 *-----------* vertex 1 index buffer: 0,1,2, 0,2,3 triangle A = vertices (0, 1, 2) triangle B = vertices (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.

Common mistake Importing or generating a mesh with inconsistent winding order. If a triangle's vertices are listed in the wrong rotational order, backface culling throws it away even though it is facing the camera — the classic symptom is a hole in a mesh, or an entire imported model rendering as invisible, that disappears the instant you flip the camera to look at it from the "inside." Most engines let you disable culling temporarily as a debugging step to confirm this is the cause.

7. Rasterization and Barycentric Coordinates: Which Pixels, and How Much of Each Vertex

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?

a triangle overlaid on a pixel grid (small example): . . . . . . . . X X . . . X X X X . . X X X X . . . X X . . . . . . . . X = pixel center falls inside the triangle -> rasterizer keeps it . = pixel center falls outside -> rasterizer skips it entirely

The edge function: an inside test

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.

Tip Sampling at the pixel's exact center (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.

Barycentric coordinates: the same numbers, normalized

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?

triangle A, B, C with point P inside: A /| / | / | / P | / | B-----C (drawn as a right triangle here just for clean ASCII art; the real formula works for ANY triangle shape) P's barycentric weights are the areas of the three sub-triangles (P,B,C), (P,C,A), (P,A,B), each divided by the area of the whole triangle (A,B,C): wA = area(P,B,C) / area(A,B,C) wB = area(P,C,A) / area(A,B,C) wC = area(P,A,B) / area(A,B,C)

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.

8. The Fragment Shader: Computing Each Pixel's Color

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.

vertex shader runs: once per VERTEX (a mesh might have 2,000 vertices) fragment shader runs: once per PIXEL covered on screen (could be 200,000+ pixels for one large character) the fragment shader usually runs FAR more times per frame than the vertex shader -- which is why expensive math belongs in the vertex shader whenever the result can be interpolated smoothly instead of recomputed per pixel

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.

Common mistake Doing per-pixel work that only ever needed to happen once per vertex — for example, recomputing a value from scratch inside the fragment shader when it could have been calculated once in the vertex shader and smoothly interpolated instead. Given the count difference in the diagram above, moving work "up" from fragment to vertex shader is one of the most common real performance fixes in game graphics.

9. The Depth Buffer (Z-Buffer): Solving "What's In Front"

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.

one pixel, two triangles both cover it: camera o -----------------> screen pixel ^ near triangle | depth = 0.3 (this one wins) | far triangle | depth = 0.8 (hidden behind it) depth test: 0.3 < 0.8, so the NEAR triangle's color is the one actually kept in the framebuffer for this pixel
#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.

Common mistake Two surfaces at nearly the exact same depth (a decal flush against a wall, two overlapping coplanar polygons) can flicker between which one wins the depth test from frame to frame, or even pixel to pixel, because of tiny floating-point rounding differences. This artifact is called z-fighting. The usual fixes are nudging one surface slightly closer to the camera (a small depth bias), or simply not creating perfectly coplanar geometry in the first place.

10. Perspective-Correct Interpolation: Why Naive Interpolation Warps Textures

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.

a floor grid receding away from the camera: perspective-correct (right): naive screen-space lerp (wrong): +-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+ each square shrinks squares bunch up unevenly, smoothly and evenly stretch, and "swim" as the toward the horizon camera moves -- the classic PS1-era wobbly-texture look

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.

Tip The original PlayStation (1994) had no hardware support for perspective-correct interpolation — texture coordinates were interpolated the naive, cheap way shown above. That is the exact cause of the wobbly, swimming textures people remember from PS1-era games, especially on floors and walls stretching into the distance. Modern GPUs do the correct 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.

11. Double Buffering: Why the Screen Doesn't Tear

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.

SINGLE buffer (tears): monitor scans this buffer top-to-bottom while the GPU is ALSO writing into it -- if the GPU writes a new frame partway through a scan, the monitor shows OLD top / NEW bottom in one image: +-------------------+ | frame N (old) | <- monitor already scanned this part |- - - - - - - - - -| <- GPU swapped mid-scan: visible seam | frame N+1 (new) | <- monitor scans this part next +-------------------+

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.

DOUBLE buffering: frame 1: [FRONT: showing frame A] [BACK: GPU drawing frame B] | | frame B finishes, buffers swap (ideally at VSync) v frame 2: [FRONT: showing frame B] [BACK: GPU drawing frame C] | | swap again v frame 3: [FRONT: showing frame C] [BACK: GPU drawing frame D] the monitor only ever reads a COMPLETE frame -- never a half-drawn one

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.

Tip Some engines and APIs go further with triple buffering: a third buffer lets the GPU keep drawing a following frame even if the previous back buffer is still waiting for its turn to become the front buffer, trading a small amount of extra input lag (delay between your input and its effect on screen) for smoother frame delivery when frame times are inconsistent.

12. Building a Tiny Software Rasterizer: Putting It All Together

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.

Tip This is not a toy exaggeration — early 3D games, including parts of the original Quake's software renderer, before consumer 3D graphics cards were common, rendered entire scenes exactly this way: a CPU looping over triangles, testing pixel coverage, and writing directly into a buffer shown on screen. GPUs did not replace this pipeline; they replaced the CPU loop with dedicated, massively parallel hardware running the exact same steps.

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.

13. Glossary

14. Exercises

Exercise 1 — Trace a Vertex Through the Pipeline A vertex shader multiplies a local-space vertex (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.
Show answer
#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.

Exercise 2 — Barycentric Weights by Hand Triangle 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?
Show answer
#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.

Exercise 3 — Find the Z-Buffer Bug A teammate wrote this depth-test line inside a software rasterizer and says triangles behind other triangles keep incorrectly showing up on top. Find the bug and fix it.
if (fragmentDepth > depthBuffer[y][x])
{
    depthBuffer[y][x] = fragmentDepth;
    framebuffer[y][x] = color;
}
Show answer

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.

← Back to all chapters