7.8 Real-Time Rendering Techniques

Phase 7 · Graphics & Rendering · Study time: 60+ h

How shipping renderers do shadows, global illumination, anti-aliasing (TAA) and GPU-driven rendering — mostly learned from SIGGRAPH talks.

Every technique in this chapter exists because of one hard rule: a game has to draw a new image many times a second, forever, without ever missing its deadline. A movie renderer can spend ten minutes on one frame of a Pixar film. A game engine gets a few milliseconds. This chapter is about the tricks shipping engines — Unreal, Unity's HDRP, and the in-house engines at studios like HoYoverse and Riot — use to fake shadows, smooth edges, indirect light, and reflections well enough, fast enough, that a player never notices the shortcut. Earlier chapters covered the rendering pipeline and shaders in general terms (vertices in, pixels out). This chapter is about the specific, named techniques built on top of that pipeline that make a real-time image look convincing.

1. What "Real-Time" Means: the Frame Budget

A game running at 60 frames per second (FPS, frames per second — how many complete images are shown every second) has to finish a new frame every 16.6 milliseconds (ms, one thousandth of a second). If anything in that frame — game logic, physics, animation, rendering — takes longer, the frame rate drops and the game visibly stutters. This time limit is called the frame budget.


#include <cstdio>

int main() {
    double target_fps = 60.0;
    double frame_budget_ms = 1000.0 / target_fps;
    printf("Target FPS: %.1f\n", target_fps);
    printf("Frame budget: %.3f ms\n", frame_budget_ms);
    return 0;
}

Output:


Target FPS: 60.0
Frame budget: 16.667 ms

16.667 ms is the entire budget — game logic, physics, animation, audio, AI, and rendering all have to fit inside it, and rendering itself is often only a fraction of that. A game targeting 30 FPS gets roughly double the budget (33.3 ms); one targeting 120 FPS for a competitive shooter gets less than half (8.3 ms). Every technique in this chapter is a trade: how much realism can you buy for how many milliseconds.

Recall the basic pipeline shape from earlier chapters: the CPU decides what to draw and submits commands, the GPU (Graphics Processing Unit) turns triangles into pixels through vertex shading, rasterization, and fragment shading, and the result lands on screen.

CPU GPU ----------------------------- ------------------------------------ game logic, physics visibility culling -> vertex shading build the draw call list rasterization (triangles -> pixels) submit commands -> fragment/pixel shading post-processing, present to screen

Everything from here on is about what happens inside that GPU column: how to get shadows, smooth edges, indirect light, and reflections out of it without blowing the budget.

2. Shadow Maps: Depth From the Light's Point of View

A shadow is really one question, asked for every pixel on screen: "is anything between this point and the light?" Checking that directly, for every light and every pixel, by testing against every other object in the scene, is far too slow for a frame budget. The standard trick is called a shadow map, and it turns that expensive question into a single number comparison.

The idea is a two-pass render. In the first pass, you place a camera at the light and render the scene from there, but you only care about depth (distance from the light), not color. Whatever the light "sees" first at each point of its view is the closest surface to the light along that direction — call that a depth buffer (a grid of distances, one per pixel, also called a z-buffer), and when it is used this way it is the shadow map. In the second pass, you render normally from the camera, and for every pixel you project that pixel's position into the light's view and compare its distance to what the shadow map already recorded. If the pixel is farther from the light than the shadow map says the nearest surface is, something else is blocking the light — the pixel is in shadow.

PASS 1 - shadow pass (from the LIGHT) PASS 2 - camera pass (normal render) -------------------------------------- -------------------------------------- light looks at the scene camera looks at the scene render DEPTH ONLY (no color) for each visible pixel: store nearest depth per texel project point into light space -> shadow_map[texel] = depth compare depth to shadow_map[texel] closer or equal -> lit farther -> in shadow

Here is that comparison as a tiny simulation. Treat the shadow map as a plain array (the same kind of array from earlier chapters) — one row of depth values as seen from the light, with a box sitting in the middle of its view (texels 3 and 4 are close, meaning something is nearby there):


#include <cstdio>

// Depth values captured from the LIGHT's point of view (a tiny shadow map).
// Index = which texel; value = distance from the light to the surface it sees there.
float shadow_map[8] = { 5.0f, 5.0f, 5.0f, 2.0f, 2.0f, 5.0f, 5.0f, 5.0f };

// texel: which shadow-map texel this point projects onto
// dist_from_light: the ACTUAL distance from the light to this surface point
bool is_lit(int texel, float dist_from_light) {
    float bias = 0.01f; // a small offset explained below
    float closest = shadow_map[texel];
    return dist_from_light <= closest + bias;
}

int main() {
    // Point A: on the floor at texel 3, and it IS the closest surface there (2.0)
    printf("Point A lit? %s\n", is_lit(3, 2.0f) ? "yes" : "no");
    // Point B: on the floor under the box, texel 3, but the box (2.0) is closer
    printf("Point B lit? %s\n", is_lit(3, 4.5f) ? "yes" : "no");
    return 0;
}

Output:


Point A lit? yes
Point B lit? no

Point A's actual distance from the light (2.0) matches what the shadow map recorded as the closest surface at that texel, so nothing is blocking it: lit. Point B is farther from the light (4.5) than the shadow map's recorded closest surface (2.0) — something (the box) is between it and the light, so it is in shadow. That one comparison, repeated per pixel per light, is the entire algorithm real engines run.

Shadow acne and the bias

Notice the bias variable added before the comparison. Without it, a surface can incorrectly shadow itself. A shadow map only has limited resolution and limited depth precision, so a flat floor lit at a grazing angle can round its own depth up or down slightly differently in the two passes, causing some pixels to fail the comparison against their own true depth. The result is a speckled, noisy pattern of shadow across a surface that should be fully lit, called shadow acne. Pushing the comparison point slightly toward the light (the bias) gives the surface a little slack so it stops failing against itself.

without bias with bias ------------ --------- surface depth == shadow_map depth compare point pushed slightly -> rounding makes some texels towards the light first fail against themselves -> no more false self-shadowing ("shadow acne", speckled look) (too much bias detaches the shadow from its object instead, called "peter-panning")
Common mistake Using too much bias to be safe "just in case." Too little bias gives acne; too much bias makes shadows visibly float or detach from the base of the object casting them, called peter-panning (the object looks like it is floating, as if it has no shadow tying it to the ground directly beneath it). Bias is a tuned value per scene, not a constant you set once and forget.
Tip A common trick to fight peter-panning without cranking up the bias: render the shadow map using the back faces of objects instead of the front faces (flip which side of a triangle counts as "front" only for the shadow pass). Since the back face of a solid object is naturally a bit farther from the light than the front face, this gives extra slack without needing as much numeric bias.

3. Cascaded Shadow Maps: Shadows Across Huge Scenes

A single shadow map has a fixed number of texels, say 2048x2048. If it has to cover a view distance of hundreds of meters (a large open-world level), each texel ends up covering a big chunk of world space, and shadows near the camera — right where the player is looking closely — turn out blocky. If you instead size the shadow map tightly around just the nearby area, distant objects lose their shadows entirely once they leave that tight area.

Cascaded shadow maps (CSM) solve this by not using one shadow map at all. The camera's view frustum (the pyramid-shaped volume it can see) is sliced into several distance ranges called cascades — near, middle, far — and each cascade gets its own shadow map, sized to just that range. The near cascade covers a small area with high texel density (sharp shadows close to the camera, where it matters most); the far cascade covers a huge area with low texel density (blockier shadows far away, where the player won't notice).

near far |--- cascade 0 ---|--- cascade 1 ---|--- cascade 2 ---|--- cascade 3 ---| | high res | | | low res | | (sharp detail | | | (far away, | | near camera) | | | blur is fine) | camera sits at "near"

A common way to pick the split distances blends two simple schemes: a uniform split (equal-sized distance ranges, which wastes resolution far away) and a logarithmic split (ranges that grow with distance, matching how perspective makes distant things look smaller anyway). Real engines blend the two with a tunable weight, lambda:


#include <cstdio>
#include <cmath>

// Compute 4 cascade split distances between the near and far planes,
// blending a logarithmic split and a uniform split.
void compute_splits(float near_p, float far_p, int count, float lambda, float out[]) {
    for (int i = 1; i <= count; ++i) {
        float p = (float)i / (float)count;
        float log_split = near_p * powf(far_p / near_p, p);
        float uniform_split = near_p + (far_p - near_p) * p;
        out[i - 1] = lambda * log_split + (1.0f - lambda) * uniform_split;
    }
}

// Which cascade should shade a pixel at this view-space depth?
int pick_cascade(float view_depth, float splits[], int count) {
    for (int i = 0; i < count; ++i) {
        if (view_depth <= splits[i]) return i;
    }
    return count - 1;
}

int main() {
    float splits[4];
    compute_splits(0.1f, 200.0f, 4, 0.5f, splits);
    for (int i = 0; i < 4; ++i)
        printf("Cascade %d ends at %.2f units\n", i, splits[i]);

    printf("A pixel at depth 12.0 uses cascade %d\n", pick_cascade(12.0f, splits, 4));
    printf("A pixel at depth 150.0 uses cascade %d\n", pick_cascade(150.0f, splits, 4));
    return 0;
}

Output:


Cascade 0 ends at 25.37 units
Cascade 1 ends at 52.26 units
Cascade 2 ends at 89.97 units
Cascade 3 ends at 200.00 units
A pixel at depth 12.0 uses cascade 0
A pixel at depth 150.0 uses cascade 3

Notice the cascades get much wider as distance grows: the first cascade covers only about 25 units, but the last one covers over 100 units on its own. A nearby pixel (depth 12.0) lands in the tight, high-resolution cascade 0; a distant pixel (depth 150.0) lands in the loose, low-resolution cascade 3. Each cascade needs its own full shadow-map render pass, so 4 cascades roughly means 4 shadow passes per light — cascades trade extra render passes for shadow quality that stays sharp near the camera at any view distance.

Common mistake Picking a hard cutoff between cascades with no overlap causes visible "popping" or a seam line where an object's shadow quality suddenly jumps as it crosses a cascade boundary, especially if the object is moving. Real engines blend two neighboring cascades together in a small overlap zone near each boundary so the transition is not a hard edge.

4. Soft Shadow Edges: PCF (Percentage-Closer Filtering)

A single shadow-map lookup gives a hard yes/no answer per pixel, which produces a razor-sharp shadow edge — itself a form of aliasing (Section 5 covers this in general), since a real shadow edge softens gradually as you move away from the object casting it. PCF (percentage-closer filtering) fixes this cheaply: instead of testing one texel of the shadow map, test several texels in a small area around the point and average how many of them say "lit." The result is a fraction between 0 (fully shadowed) and 1 (fully lit) instead of a hard binary answer, which reads as a soft, gradient edge.

shadow map texels around the sample point: 5 5 5 5 [2] 5 <- center texel being shaded 5 5 5 PCF tests all 9, not just the center, and averages pass/fail: -> a blended shadow factor instead of one hard 0 or 1

#include <cstdio>

// Shadow map depths as seen from the light. 2.0 = top of a box, 5.0 = the floor beyond it.
float shadow_map[7][7] = {
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5}
};

// Percentage-Closer Filtering: sample a 3x3 grid around the texel and
// average how many samples say "lit" instead of trusting a single texel.
float pcf_shadow(int cx, int cy, float dist_from_light) {
    float bias = 0.01f;
    int lit_count = 0;
    for (int dy = -1; dy <= 1; ++dy)
        for (int dx = -1; dx <= 1; ++dx)
            if (dist_from_light <= shadow_map[cy + dy][cx + dx] + bias)
                lit_count++;
    return (float)lit_count / 9.0f;
}

int main() {
    float d = 2.4f; // actual distance from light to this floor point
    printf("Deep inside the box's shadow  (3,3): %.3f\n", pcf_shadow(3, 3, d));
    printf("Right at the shadow's edge    (2,2): %.3f\n", pcf_shadow(2, 2, d));
    printf("Mostly outside the shadow     (1,1): %.3f\n", pcf_shadow(1, 1, d));
    return 0;
}

Output:


Deep inside the box's shadow  (3,3): 0.000
Right at the shadow's edge    (2,2): 0.556
Mostly outside the shadow     (1,1): 0.889

Deep inside the box's shadow, all 9 sampled texels agree it's occluded: 0.000, fully dark. Right at the boundary of the box, the 3x3 sample straddles both occluded and open texels: 0.556, a partial gray value — this is the soft edge. Mostly outside the shadow, only one sampled texel still disagrees: 0.889, nearly fully lit. Real engines usually use more samples (16 or more, often arranged in a rotated or Poisson-disk pattern instead of a plain grid, to hide repeating patterns) and can grow the sample radius based on distance to the object casting the shadow, called PCSS (percentage-closer soft shadows), which makes shadows closer to their caster sharper and shadows farther from their caster softer — matching how real shadows behave under an area light. The core idea stays the one shown above: average several tests instead of trusting one.

5. Why Edges Look Jagged: Aliasing and Point Sampling

A triangle's edge, mathematically, is a perfectly straight line. But a screen is a fixed grid of square pixels, and the rasterizer (the fixed GPU hardware stage that decides which pixels a triangle covers) usually makes its decision by checking a single point — normally the pixel's exact center — and asking "is this one point inside the triangle or not?" That binary decision, repeated pixel by pixel, turns a smooth diagonal or curved edge into a staircase. This general problem — a continuous shape represented by a fixed grid of discrete samples losing information — is called aliasing, and it is the reason diagonal and curved edges in games look "jagged" without further work.


#include <cstdio>

// A triangle's edge, simplified to the line x + y = 8.
// A pixel counts as "inside" if its CENTER point satisfies the test.
bool inside_triangle(int x, int y) {
    float cx = x + 0.5f;
    float cy = y + 0.5f;
    return (cx + cy) < 8.0f;
}

int main() {
    for (int y = 0; y < 8; ++y) {
        for (int x = 0; x < 8; ++x) {
            putchar(inside_triangle(x, y) ? '#' : '.');
        }
        putchar('\n');
    }
    return 0;
}

Output:


#######.
######..
#####...
####....
###.....
##......
#.......
........
true diagonal edge (math) rasterized pixels (point sampling) / . . . # / . . # # / . # # # / # # # .

The math boundary is a single straight diagonal line. What actually got drawn is a staircase, because each pixel only ever asked "is my one center point inside or outside," with no notion of "I am 30% covered." Every anti-aliasing (AA) technique in the next three sections is a different answer to the same underlying problem: get a better answer than a single yes/no sample per pixel, without paying the full cost of, say, rendering the image at 4x or 16x resolution and shrinking it down afterward (called supersampling or SSAA, which works but is expensive because it reruns the entire pipeline, shading included, many times per final pixel).

6. MSAA: Multi-Sample Anti-Aliasing

MSAA (multi-sample anti-aliasing) improves on the single-center-sample test from Section 5 without paying the full cost of supersampling. The trick: test coverage (is this sub-pixel point inside the triangle?) at several fixed positions inside each pixel, but only run the expensive fragment shader (the actual color/lighting math) once per pixel per triangle. The final pixel color then blends based on what fraction of those sample points were covered.

one pixel, 4 MSAA sample points (x = sample): +-----------+ | x x | each x tested against the triangle edge | | edge covers some x's but not others | x x | -> pixel color = blend weighted by how many x's are inside +-----------+

#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

// 4x MSAA: test 4 sub-pixel sample points instead of just the pixel center.
float msaa4_coverage(int px, int py) {
    float offsets[4][2] = {
        {0.25f, 0.25f}, {0.75f, 0.25f},
        {0.25f, 0.75f}, {0.75f, 0.75f}
    };
    int covered = 0;
    for (int i = 0; i < 4; ++i) {
        float sx = px + offsets[i][0];
        float sy = py + offsets[i][1];
        if (inside_triangle(sx, sy)) covered++;
    }
    return covered / 4.0f;
}

int main() {
    printf("Pixel (2,3) coverage: %.2f\n", msaa4_coverage(2, 3));
    printf("Pixel (3,3) coverage: %.2f\n", msaa4_coverage(3, 3));
    printf("Pixel (4,3) coverage: %.2f\n", msaa4_coverage(4, 3));
    return 0;
}

Output:


Pixel (2,3) coverage: 1.00
Pixel (3,3) coverage: 0.25
Pixel (4,3) coverage: 0.00

Pixel (2,3) sits entirely inside the triangle: all 4 samples agree, coverage 1.00, full color. Pixel (4,3) sits entirely outside: coverage 0.00. Pixel (3,3) straddles the edge: only 1 of 4 samples is inside, coverage 0.25, so the final pixel blends 25% triangle color with whatever is behind it — a soft, partially-covered edge instead of a hard jump. Because the expensive fragment shader still runs only once per pixel (not 4 times), MSAA is much cheaper than full 4x supersampling while still smoothing geometric edges.

Common mistake Expecting MSAA to fix every jagged-looking thing. MSAA only tests geometric coverage — the edges of triangles. It does nothing for aliasing that comes from inside the shader itself, such as a small, bright specular highlight that flickers pixel to pixel, or a foliage texture using alpha-testing (a hard cutout, not a smooth blended edge) to cut out leaf shapes from a flat quad — those edges are decided inside the fragment shader, not by the rasterizer's coverage test, so MSAA does not smooth them.

7. FXAA: Fast Approximate Post-Process Anti-Aliasing

MSAA needs access to geometry and coverage information while the scene is being rasterized. FXAA (fast approximate anti-aliasing) takes a completely different approach: it runs after the entire image is already rendered, as a post-processing pass (an image-processing step applied to the final 2D picture, with no knowledge of the 3D scene that produced it). It scans the finished image for sharp brightness jumps — high-contrast edges, which usually mean a jagged geometric edge — and blends those pixels with their neighbors to soften them.


#include <cstdio>
#include <cmath>

// A row of pixel brightness values (0..1), with a hard edge in the middle.
float pixels[8] = {0.9f, 0.9f, 0.9f, 0.9f, 0.1f, 0.1f, 0.1f, 0.1f};

int main() {
    float smoothed[8];
    for (int i = 0; i < 8; ++i) smoothed[i] = pixels[i];

    for (int i = 1; i < 7; ++i) {
        float left = pixels[i - 1];
        float right = pixels[i + 1];
        float contrast = fabsf(left - right);
        if (contrast > 0.3f) {
            // High contrast: likely an edge. Blend this pixel with its neighbors.
            smoothed[i] = (left + pixels[i] + right) / 3.0f;
        }
    }

    for (int i = 0; i < 8; ++i) printf("%.2f ", smoothed[i]);
    printf("\n");
    return 0;
}

Output:


0.90 0.90 0.90 0.63 0.37 0.10 0.10 0.10 

The original row jumps sharply from 0.9 to 0.1 in one step — a hard edge. The high-contrast check catches pixels near that jump and blends them with their neighbors, turning the sudden step into a short ramp: 0.9, 0.63, 0.37, 0.1. The edge is now a couple of pixels of gradient instead of one sharp line, which reads as smooth from normal viewing distance. FXAA is popular because it is very cheap (one pass over the final image, no extra geometry rendering) and works with any renderer, but because it only looks at 2D contrast, it cannot tell a real geometric edge apart from a high-contrast texture detail, like a painted line or small text.

Common mistake Relying on FXAA for a UI-heavy or text-heavy scene. Because FXAA blends any high-contrast boundary it finds, it can noticeably blur fine text and crisp UI icons, which is why most engines render UI in a separate pass, after post-processing AA has already been applied to the 3D scene underneath it.

8. TAA: Temporal Anti-Aliasing

MSAA spends extra samples within one frame. TAA (temporal anti-aliasing) spends extra samples across frames instead: each frame, the sample position is shifted slightly (called jitter, a small, changing sub-pixel offset), and the new, jittered frame is blended with an accumulated history of previous frames. Over several frames, this converges toward the same answer a multi-sample test would have given in a single frame — but spread out over time, at almost no extra cost per frame.

Reusing a previous frame's pixel requires knowing where that surface point is now, since the camera or the object may have moved. Engines compute a velocity buffer (also called a motion vector buffer): for every pixel, how far did this surface point move on screen since last frame? That vector is used to reproject — look up where this same surface point was in the previous frame's image — before blending.

previous frame current frame --------------- -------------- [ . . H . . . ] [ . . . P . . ] | | | velocity vector: where did this | surface point move to on screen? +----------------------------------------->| sample history buffer at H, reproject to P's position, blend with the new sample

Here is the same triangle edge from the MSAA example, but sampled one jittered point per frame instead of four points in one frame, and blended into a running history using a fixed blend weight:


#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

int main() {
    // TAA jitters the sample point to a different sub-pixel offset every
    // frame, then blends the new sample into the accumulated history.
    float jitter_x[4] = {0.25f, 0.75f, 0.25f, 0.75f};
    float jitter_y[4] = {0.25f, 0.25f, 0.75f, 0.75f};
    int px = 3, py = 3;

    float history = 0.5f; // first guess, no history yet
    float alpha = 0.25f;  // how much weight the new frame gets

    for (int frame = 0; frame < 4; ++frame) {
        float sample = inside_triangle(px + jitter_x[frame], py + jitter_y[frame]) ? 1.0f : 0.0f;
        history = history * (1.0f - alpha) + sample * alpha;
        printf("Frame %d: jittered sample=%.1f  accumulated=%.4f\n", frame, sample, history);
    }
    return 0;
}

Output:


Frame 0: jittered sample=1.0  accumulated=0.6250
Frame 1: jittered sample=0.0  accumulated=0.4688
Frame 2: jittered sample=0.0  accumulated=0.3516
Frame 3: jittered sample=0.0  accumulated=0.2637

These are the exact same four jitter offsets used as the four MSAA sample points in Section 6, where pixel (3,3) came out to a true coverage of 0.25. Here, spread one-per-frame across four frames and blended, the accumulated value is drifting down toward that same 0.25 — TAA reconstructs the same kind of answer as MSAA, using time instead of extra samples in a single frame, at a fraction of the per-frame cost.

Common mistake Trusting the history buffer blindly. If an object moves fast, if the camera cuts, or if something new becomes visible that was hidden a frame ago (called disocclusion — a surface newly revealed because whatever was blocking it moved out of the way), the velocity buffer either has no correct answer or points to color data that no longer belongs to that surface. Blending in that wrong history produces a trailing smear behind fast-moving edges, called ghosting. The standard mitigation is neighborhood clamping: before accepting a history sample, clamp its color to the range of colors seen in the current frame's neighboring pixels, so wildly wrong history gets pulled back toward something plausible instead of smearing across the screen. This is a tuning problem every TAA implementation has to balance — clamp too aggressively and you lose the anti-aliasing benefit; clamp too loosely and ghosting slips through.
Tip Extending the trace above to 8 frames (cycling the same 4 jitter offsets twice) shows the accumulated value does not settle exactly on 0.25 — it drifts based on which samples were seen most recently, since a fixed blend weight favors recent frames over old ones. This is exactly why shipped TAA implementations do not use a flat, fixed weight; they combine it with clamping and sometimes a variable weight that starts higher (trusting new data more while the history is still short) and settles lower once the history is well established. Exercise 2 at the end of this chapter walks through this in full.

9. Forward Shading vs. Deferred Shading

Lighting math needs to happen somewhere in the pipeline: for every visible pixel, combine its surface material with every light that reaches it. There are two very different ways to organize that work.

Forward shading is the direct approach: for each object, for each light that might affect it, compute the lighting right there in the object's fragment shader, one triangle at a time. It is simple and works naturally with transparency and MSAA, but it has a problem — overdraw (shading the same screen pixel more than once, because a later, closer object gets drawn on top of an earlier one that already paid the full shading cost) multiplies badly with a large light count, since every overdrawn pixel pays for every light again.

Deferred shading splits the work into two passes instead. The first pass, the geometry pass, draws every object but does not compute any lighting yet — it just writes each pixel's material information (surface color, normal direction, depth, roughness) into a set of full-screen textures called the G-buffer (geometry buffer). The second pass, the lighting pass, runs once per final screen pixel, reads the G-buffer, and loops over the lights that reach that pixel. Lighting math only ever runs on the pixels that actually end up visible on screen, not on every overdrawn layer underneath them.

FORWARD DEFERRED -------- -------- for each object: PASS 1 - geometry: write G-buffer once for each light touching it: [Albedo][Normal][Depth][Rough/Metal] shade pixel with that light PASS 2 - lighting: read G-buffer once -> lights x objects x overdraw for each light: shading work accumulate lit pixels on screen -> lights x screen pixels, no overdraw

A small simulation makes the difference concrete. Say a scene has 50 objects, each averaging 2000 pixels of screen coverage (with some overlap between objects), 40 lights, and the final visible image is 80,000 pixels:


#include <cstdio>

int main() {
    int objects = 50;
    int pixels_per_object = 2000; // average screen coverage, including overlap
    int lights = 40;              // e.g. many small point lights in a level

    // Forward: naively shades EVERY light against EVERY pixel of EVERY
    // object, even pixels that later get drawn over by something closer.
    long forward_ops = (long)objects * pixels_per_object * lights;

    // Deferred: pay the material write once per pixel (geometry pass),
    // then pay the lighting cost once per pixel per light, on the FINAL
    // visible pixels only - overdraw never reaches the lighting pass.
    long screen_pixels = 80000;
    long gbuffer_ops = screen_pixels;
    long lighting_ops = screen_pixels * lights;
    long deferred_ops = gbuffer_ops + lighting_ops;

    printf("Forward shading operations:  %ld\n", forward_ops);
    printf("Deferred shading operations: %ld\n", deferred_ops);
    return 0;
}

Output:


Forward shading operations:  4000000
Deferred shading operations: 3280000

Notice objects x pixels_per_object (100,000) is larger than the final screen_pixels (80,000) — that 1.25x gap is overdraw: some screen pixels got shaded by more than one overlapping object in the forward version. Deferred shading pays the material cost exactly once per final pixel and never re-pays the lighting cost for pixels that end up hidden. The gap between forward and deferred grows fast as the light count grows, which is why deferred shading became popular specifically for scenes with many small dynamic lights.

Tip A G-buffer with several full-resolution render targets (albedo, normal, depth, roughness/metallic, sometimes more) uses real GPU memory bandwidth just to write and later read. Engines pack data tightly to reduce this — for example, storing a normal vector (which needs 3 numbers, x/y/z) in only 2 numbers using an encoding trick (octahedral encoding), since a normalized vector's third component can often be reconstructed from the other two.
Common mistake Assuming deferred shading is a strictly better replacement for forward shading. Deferred struggles with transparency (a G-buffer can only store one surface per pixel, but a transparent object needs to blend with whatever is behind it) and does not combine as directly with MSAA (the G-buffer would need multiple samples stored per pixel, multiplying its already-large memory cost). Because of this, many shipped engines use a hybrid: deferred (or a variant called clustered or Forward+ shading, which still shades in one pass like forward but first sorts lights into 3D screen-space grid cells so each pixel only tests nearby lights) for opaque objects, and a separate forward pass just for transparent objects like glass, foliage, and particle effects.

10. Approximating Global Illumination

Global illumination (GI) means light that bounces more than once before reaching your eye — a white wall lit by direct sunlight also lights up the room around it, a red rug tints the wall next to it slightly red. Simulating this exactly means tracing how light bounces around an entire scene, which is far too slow for a frame budget. Real-time engines instead use a handful of targeted approximations, each covering a different piece of what full GI would give you.

Light probes: pre-baked indirect light at a few points

A light probe is a point in the level where, ahead of time (usually when the level is built, not while the game is running), the engine calculates what indirect light looks like at that exact spot — light bounced from walls, floors, and other static geometry. Probes are placed throughout a level, often more densely in visually important areas. At runtime, a moving object (which cannot have its own pre-baked lighting, since it can be anywhere) looks up the nearest probes and blends between them based on distance, to get a cheap estimate of the indirect light hitting it right now.

P1(dim) P2(bright, near window) o--------------------o | | | [crate] | crate blends nearest probes | | based on distance to each o--------------------o P3(dim) P4(bright)

#include <cstdio>

// Two light probes along a hallway, storing indirect light (simplified to
// one brightness number instead of full RGB for clarity).
float probe_A_light = 0.2f; // near a dark corner
float probe_B_light = 0.8f; // near a bright window

int main() {
    float probe_A_pos = 0.0f;
    float probe_B_pos = 10.0f;
    float object_pos = 7.0f; // a crate somewhere between the two probes

    float t = (object_pos - probe_A_pos) / (probe_B_pos - probe_A_pos);
    float indirect_light = probe_A_light * (1.0f - t) + probe_B_light * t;

    printf("Blend factor t = %.2f\n", t);
    printf("Interpolated indirect light on the crate: %.3f\n", indirect_light);
    return 0;
}

Output:


Blend factor t = 0.70
Interpolated indirect light on the crate: 0.620

The crate sits 70% of the way from the dim probe toward the bright probe, so it picks up 70% of the brightness difference between them: 0.62, brighter than the dark corner but dimmer than right next to the window. Real light probes store full spherical lighting information (light arriving from every direction, not one number), and real engines blend more than two neighboring probes at once, but the underlying idea — sample a few pre-computed points and interpolate — is exactly this.

Screen-space reflections (SSR)

Screen-space reflections reuse information the renderer already has on screen — the color buffer and the depth buffer — to approximate reflections without tracing rays through the full 3D scene. A reflection ray is marched (stepped forward little by little) starting from the reflective surface, and at each step, its current depth is compared against what the depth buffer already recorded at that screen position. When the ray's depth catches up to (or passes) the depth buffer's value, that pixel is treated as the hit point, and its already-rendered color is reused as the reflection.


#include <cstdio>

// Depth buffer for a row of screen pixels the reflection ray crosses.
// (Distance from camera; a crate close to the camera sits at index 4-7.)
float depth_buffer[10] = {5,5,5,5, 2,2,2,2, 5,5};

int main() {
    float ray_depth = 1.0f; // the ray starts close to the camera
    float step = 0.5f;      // and marches away from it, pixel by pixel

    for (int px = 0; px < 10; ++px) {
        printf("pixel %d: ray_depth=%.2f buffer_depth=%.2f", px, ray_depth, depth_buffer[px]);
        if (ray_depth >= depth_buffer[px]) {
            printf("  -> HIT: use color at this pixel as the reflection\n");
            break;
        }
        printf("  (ray is still in front of the surface)\n");
        ray_depth += step;
    }
    return 0;
}

Output:


pixel 0: ray_depth=1.00 buffer_depth=5.00  (ray is still in front of the surface)
pixel 1: ray_depth=1.50 buffer_depth=5.00  (ray is still in front of the surface)
pixel 2: ray_depth=2.00 buffer_depth=5.00  (ray is still in front of the surface)
pixel 3: ray_depth=2.50 buffer_depth=5.00  (ray is still in front of the surface)
pixel 4: ray_depth=3.00 buffer_depth=2.00  -> HIT: use color at this pixel as the reflection

The ray marches forward, staying "in front of" the far wall (depth 5.00) for the first four pixels, then at pixel 4 its own depth (3.00) has caught up to the nearby crate's depth (2.00) — a hit. The color already rendered at pixel 4 gets reused as the reflected color. The catch is right there in the name: screen-space. If the thing that should be reflected is off-screen, or hidden behind something else from the camera's point of view, its color was never rendered in the first place, so the ray march fails to find a hit — engines fall back to a pre-baked reflection probe (similar in spirit to a light probe, but storing a full 360-degree image instead of one brightness value) whenever SSR comes up empty.

Screen-space ambient occlusion (SSAO)

Ambient occlusion (AO) is the soft darkening you see in corners, creases, and anywhere two surfaces meet closely — a crevice gets less ambient light than an open flat surface, because nearby geometry blocks part of the sky/environment light that would otherwise reach it. SSAO approximates this using only the depth buffer: for each pixel, sample nearby pixels' depths, and if a neighbor is noticeably closer to the camera than expected, count it as partially blocking light.

wall wall \ / \ dark / <- crevice: nearby depths are CLOSER than \ AO=0 / this point -> counted as occluders, darkened \ / -------OO------- <- open floor: no closer neighbors nearby AO near 1.0 -> stays bright

#include <cstdio>

// Scene depth (distance from camera) looking into a narrow crevice.
// The crevice walls (4.0) are close to the camera; the back of the
// crevice (7.0) is farther away and partly boxed in by the walls.
float depth[5][5] = {
    {10,10,10,10,10},
    {10, 4, 4, 4,10},
    {10, 4, 7, 4,10},
    {10, 4, 4, 4,10},
    {10,10,10,10,10}
};

// SSAO: check nearby depth samples. A neighbor that is noticeably CLOSER
// to the camera than this pixel blocks some of the ambient light that
// would otherwise reach it.
float ssao(int cx, int cy) {
    float center = depth[cy][cx];
    int occluders = 0, samples = 0;
    for (int dy = -1; dy <= 1; ++dy) {
        for (int dx = -1; dx <= 1; ++dx) {
            if (dx == 0 && dy == 0) continue;
            float neighbor = depth[cy + dy][cx + dx];
            if (neighbor < center - 0.5f) occluders++;
            samples++;
        }
    }
    return 1.0f - (float)occluders / (float)samples; // 1.0 = open, 0.0 = dark
}

int main() {
    printf("AO on the crevice wall (1,1): %.3f\n", ssao(1, 1));
    printf("AO at the crevice back (2,2): %.3f\n", ssao(2, 2));
    return 0;
}

Output:


AO on the crevice wall (1,1): 1.000
AO at the crevice back (2,2): 0.000

The point on the rim of the crevice (1,1) has no neighbor closer than itself, so AO stays at 1.000, fully open. The point at the back of the crevice (2,2) is surrounded on all 8 sides by the closer crevice walls, so every neighbor counts as an occluder: AO drops to 0.000, fully dark. Multiply this factor into the ambient/indirect light term of your lighting, and corners and creases naturally darken.

Common mistake Expecting SSR and SSAO to be physically correct. Both only see what is already on screen. A reflection of something behind the camera, an occluder just outside the frame, or geometry hidden by another object will simply be missing, since there is no depth or color data for it. This is the defining trade-off of every screen-space technique in this section: cheap, because it reuses data you already have, but incomplete, because "on screen right now" is not the same as "exists in the scene."

11. GPU-Driven Rendering and Culling

Rendering an object the camera cannot even see wastes the whole pipeline on it. Culling means deciding, before doing that expensive work, which objects can be skipped entirely.

The simplest and most common form is frustum culling: test each object's bounding volume (a simple shape, like a sphere or box, that fully contains the real mesh, cheap to test instead of testing every triangle) against the camera's frustum (the pyramid-shaped volume the camera can see, bounded by a near plane, a far plane, and the sides of its field of view). Anything entirely outside the frustum gets skipped before a single vertex of it is ever sent to the GPU.

far plane | | | FRUSTUM | | [ crate: OK ] | camera *-|---------------------- | [ rock: too far ] X (outside far plane) | [ off to the side ] X (outside side plane) | | near plane

#include <cstdio>
#include <cmath>

struct Sphere { float x, z, radius; };

// A simplified 2D frustum: camera at the origin looking down +z,
// with a field-of-view half-angle, and near/far clip distances.
bool in_frustum(Sphere s, float near_d, float far_d, float half_angle_rad) {
    if (s.z + s.radius < near_d) return false; // fully behind the near plane
    if (s.z - s.radius > far_d) return false;  // fully beyond the far plane

    // Distance the frustum's side plane is from the view axis at this depth.
    float side_limit = s.z * tanf(half_angle_rad);
    if (fabsf(s.x) - s.radius > side_limit) return false; // outside the side plane
    return true;
}

int main() {
    float near_d = 0.5f, far_d = 100.0f, half_angle = 0.6f; // about 34 degrees

    Sphere crate    = {2.0f, 20.0f, 1.0f};   // near the camera's path
    Sphere far_rock = {5.0f, 200.0f, 3.0f};  // way past the far plane
    Sphere off_side = {90.0f, 20.0f, 1.0f};  // way off to the side

    printf("crate visible?    %s\n", in_frustum(crate, near_d, far_d, half_angle) ? "yes" : "no");
    printf("far_rock visible? %s\n", in_frustum(far_rock, near_d, far_d, half_angle) ? "yes" : "no");
    printf("off_side visible? %s\n", in_frustum(off_side, near_d, far_d, half_angle) ? "yes" : "no");
    return 0;
}

Output:


crate visible?    yes
far_rock visible? no
off_side visible? no

The crate passes all three checks and gets drawn. The rock fails the far-plane check outright — it is simply too far away, regardless of angle. The off-to-the-side object passes the near/far checks but fails the side-plane check — it is within drawing distance, but well outside the camera's field of view, so drawing it would be wasted work.

Frustum culling only catches objects outside the camera's view volume. Occlusion culling catches a different case: an object fully inside the frustum but completely hidden behind something closer — a room behind a wall, for example. A common technique is Hi-Z (hierarchical depth buffer): build a mipmap-like pyramid of the depth buffer (each level storing the farthest depth of a block of pixels from the level below it), so testing whether an object's bounding volume is fully hidden behind existing geometry can be done with a handful of coarse lookups instead of comparing against every pixel it might cover.

Doing all of this culling on the CPU, one object at a time, becomes its own bottleneck once a scene has hundreds of thousands of objects — even a cheap-per-object test adds up, and every draw call the CPU decides to submit costs time crossing over to the GPU. GPU-driven rendering moves the decision itself onto the GPU: a compute shader (a general-purpose GPU program, not tied to one vertex or one pixel) tests all objects' visibility in parallel and writes the surviving ones directly into a buffer of indirect draw calls (draw commands whose parameters, like which objects to draw, are read from GPU memory instead of being specified by the CPU ahead of time). The CPU only has to kick off one dispatch; the GPU decides what to actually draw and then draws it, with far less CPU involvement per object.

CPU-driven (traditional) GPU-driven ------------------------ ---------- CPU loops over every object: CPU: dispatch ONE compute shader test frustum / occlusion GPU: cull all objects in parallel, if visible: submit a draw call write survivors into an (slow once object count is huge) indirect draw-call buffer GPU: execute the indirect draws (CPU barely involved after dispatch)
Tip Culling only saves time if the test itself is cheap compared to what it might skip. A bounding-sphere test costs almost nothing next to rendering a 50,000-triangle mesh, which is exactly why culling checks nearly always use a cheap approximate shape (sphere or box) instead of testing the real geometry directly.

12. Putting It Together: a Modern Frame, Pass by Pass

None of these techniques run in isolation. A typical frame in a modern deferred renderer runs its passes in a specific order, because each pass usually depends on data an earlier pass produced.

shadow pass (per light) -> depth prepass -> G-buffer pass -> SSAO -> lighting pass -> SSR -> forward pass (transparents) -> TAA resolve -> tonemap/post -> present

Reading this left to right: the shadow pass renders each light's shadow map first (Sections 2-4), since the main scene pass will need to sample it. A depth prepass often renders just depth for opaque objects before anything else, so later passes can skip shading pixels that end up hidden behind something closer, cutting overdraw even before the G-buffer pass begins. The G-buffer pass (Section 9) writes material data once per visible pixel. SSAO (Section 10) runs next because it only needs the depth/normal data the G-buffer just produced. The lighting pass reads the G-buffer, the shadow maps, and the SSAO result together to produce a lit image. SSR (Section 10) runs after lighting because it needs an already-shaded color buffer to sample reflections from. A separate forward pass handles transparent objects last among the 3D geometry, since they need to blend with everything already drawn behind them. TAA (Section 8) resolves the jittered frame against history near the end, before tonemapping (converting rendered brightness values into displayable colors) and any other post-processing, so those final steps operate on a stable, already-anti-aliased image rather than a jittered one.

Every studio's engine varies this order somewhat, and some passes (like SSR or SSAO) are sometimes skipped entirely on lower-end hardware to save budget. But the shape — shadows and depth first, then material data, then lighting, then screen-space effects that reuse what was already drawn, then temporal resolve, then final color grading — is close to universal across shipping engines.

13. How to Actually Learn This For Real

This chapter gave you the concept and a small worked simulation for each technique. That is enough to recognize these ideas and reason about their trade-offs, but implementing one for real in a working renderer is a different, deeper skill, and it is where the understanding actually sticks. A practical path:

read a talk/paper -> implement the smallest version -> inspect with a graphics debugger (RenderDoc/PIX/Nsight) -> compare vs a reference -> tune parameters -> repeat
Tip You can use RenderDoc on games you did not write, not just your own projects. Capturing a frame from a shipped game and stepping through its actual G-buffer layout or shadow cascade setup is one of the fastest ways to see how a real studio solved the exact problems this chapter covers.

14. Glossary

15. Exercises

Exercise 1 — Predict a PCF Value Using the exact shadow_map array and pcf_shadow function from Section 4, work out by hand what pcf_shadow(4, 3, 2.4f) should return — a point on the right-hand edge of the box's shadow. Show which of the 9 sampled texels are occluders and which are lit, then write the small program to check your answer.
Show answer

The 3x3 neighborhood around (cx=4, cy=3) covers rows 2, 3, and 4, columns 3, 4, and 5. Looking at the shadow_map array, rows 2-4 are all {5,5,2,2,2,5,5}, so columns 3, 4, and 5 of each of those rows are 2, 2, 5. That gives 9 sampled values: 2,2,5, 2,2,5, 2,2,5 — six texels at depth 2 (occluders, since 2.4 <= 2.01 is false) and three texels at depth 5 (lit, since 2.4 <= 5.01 is true). So lit_count = 3, and the expected result is 3 / 9 = 0.333.


#include <cstdio>

float shadow_map[7][7] = {
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,2,2,2,5,5},
    {5,5,5,5,5,5,5},
    {5,5,5,5,5,5,5}
};

float pcf_shadow(int cx, int cy, float dist_from_light) {
    float bias = 0.01f;
    int lit_count = 0;
    for (int dy = -1; dy <= 1; ++dy)
        for (int dx = -1; dx <= 1; ++dx)
            if (dist_from_light <= shadow_map[cy + dy][cx + dx] + bias)
                lit_count++;
    return (float)lit_count / 9.0f;
}

int main() {
    printf("PCF at (4,3): %.3f\n", pcf_shadow(4, 3, 2.4f));
    return 0;
}

PCF at (4,3): 0.333

This confirms it: 0.333, meaning this point on the right edge of the box's shadow is one-third lit. Combined with the values from Section 4 — 0.000 deep inside, 0.556 on the left edge, 0.889 mostly clear — the shadow reads as a soft gradient across its whole boundary, exactly the effect PCF is meant to produce.

Exercise 2 — Extend TAA to 8 Frames Take the TAA accumulation code from Section 8 and change it to run for 8 frames instead of 4, cycling through the same 4 jitter offsets a second time (frame index modulo 4). Print the accumulated value at every frame. Does it settle exactly on 0.25, the true average? Explain what you observe.
Show answer

#include <cstdio>

bool inside_triangle(float x, float y) {
    return (x + y) < 7.0f;
}

int main() {
    float jitter_x[4] = {0.25f, 0.75f, 0.25f, 0.75f};
    float jitter_y[4] = {0.25f, 0.25f, 0.75f, 0.75f};
    int px = 3, py = 3;

    float history = 0.5f;
    float alpha = 0.25f;

    for (int frame = 0; frame < 8; ++frame) {
        int j = frame % 4; // cycle through the same 4 jitter offsets again
        float sample = inside_triangle(px + jitter_x[j], py + jitter_y[j]) ? 1.0f : 0.0f;
        history = history * (1.0f - alpha) + sample * alpha;
        printf("Frame %d: jittered sample=%.1f  accumulated=%.4f\n", frame, sample, history);
    }
    return 0;
}

Frame 0: jittered sample=1.0  accumulated=0.6250
Frame 1: jittered sample=0.0  accumulated=0.4688
Frame 2: jittered sample=0.0  accumulated=0.3516
Frame 3: jittered sample=0.0  accumulated=0.2637
Frame 4: jittered sample=1.0  accumulated=0.4478
Frame 5: jittered sample=0.0  accumulated=0.3358
Frame 6: jittered sample=0.0  accumulated=0.2519
Frame 7: jittered sample=0.0  accumulated=0.1889

It does not settle on 0.25. After frame 4's sample of 1.0, the accumulated value jumps back up to 0.4478, then decays down to 0.1889 by frame 7 — lower than where it was at frame 3 (0.2637). A fixed blend weight (alpha = 0.25) is an exponential moving average, which always weighs recent frames more heavily than older ones. Since the true "bright" sample only occurs once every 4 frames, right after it appears the average spikes up, and right before it reappears the average has decayed toward the "dark" samples that dominated the frames in between. It never truly converges to a single fixed number for a repeating pattern like this one — it oscillates around the true average instead. This is exactly why real TAA implementations pair a blend weight with neighborhood clamping (Section 8's warning box) and often use a special, higher weight for the first few frames after something newly appears, rather than trusting one fixed alpha forever.

Exercise 3 — Choosing a Pipeline You are optimizing a scene with roughly 200 small dynamic lights and dense, alpha-tested foliage that overlaps itself heavily (lots of overdraw). Would you lean toward forward or deferred shading for the opaque terrain and rocks, and would you pick MSAA or TAA for anti-aliasing? Justify your answer in a few sentences, and say what you would do differently for the foliage itself.
Show answer

For the opaque terrain and rocks, plain forward shading would multiply 200 lights by every overdrawn pixel, which gets expensive fast (Section 9's worked example shows exactly this kind of gap). A pure deferred G-buffer pass fixes the light-count problem well, since lighting only runs once per final visible pixel regardless of how many lights exist — but deferred does not handle the alpha-tested, heavily overlapping foliage well, since a G-buffer can only store one surface per pixel and foliage needs many overlapping alpha-tested layers. The practical answer most shipped engines land on is a hybrid: deferred (or Forward+/clustered shading, Section 9) for the opaque terrain and rocks, and a separate forward pass just for the foliage, run after the deferred lighting pass, so foliage can blend and alpha-test correctly against what is already drawn behind it.

For anti-aliasing, alpha-tested foliage is exactly the case MSAA struggles with (Section 6's warning box) — MSAA only smooths triangle edges, not the cutout edges inside a leaf texture's alpha channel, so foliage edges would still look jagged even with MSAA turned on. TAA smooths those cutout edges too, since it works on the final rendered image over time rather than only on geometric coverage, at the cost of needing careful handling (velocity buffers, clamping) for the foliage's fine, high-frequency detail so it does not ghost or smear as it sways or as the camera moves past it. Given the heavy alpha-tested foliage, TAA is the better overall fit here, paired with the hybrid forward+deferred pipeline described above.

← Back to all chapters