Realistic rasterization — PBR, shadows, and global illumination
Lesson 07 gave us local shading and nothing more: a couple of point lights, an ad-hoc Blinn–Phong highlight, a normal per fragment — and then it stopped. No shadows. No bounced light. No principled materials. But a real photograph carries all of it — the soft shadow under a chair, the red of a wall bleeding onto a white floor, gold that looks like gold under any sky. This lesson climbs the realism ladder that turns a flat-shaded model into a photoreal render: physically-based materials, shadow mapping, global illumination, and the linear-light pipeline that ties them together. All of it still rides the same hard rasterizer from lessons 06–07 — non-differentiable, and entirely hand-authored — which is exactly the ceiling that will force the inverse turn to NeRF in lesson 09.
1 · The target: the rendering equation
Before we add tricks, name the thing they approximate. All of physically-correct rendering is a single equation — Kajiya's rendering equation — that says how much light leaves a surface point in a given direction. The outgoing radiance Lo at a point x in direction ωo is what the surface emits plus everything it reflects:
Read it left to right. Le is emitted radiance (zero unless the surface is a light). The integral runs over the hemisphere Ω of all incoming directions above the surface. Inside it: the BRDF fr — the bidirectional reflectance distribution function from lesson 07 §3, "how does this material scatter light arriving from ωi out toward ωo?" — times the incoming radiance Li from that direction, times a cosine foreshortening factor (ωi · n).
That cosine is Lambert's cosine law, and it is worth pausing on: light arriving at a grazing angle spreads its energy over a larger patch of surface, so each point receives less of it. A beam hitting head-on (ωi = n, cosine 1) delivers full power; the same beam at 60° off-normal (cosine 0.5) delivers half. This is the exact n·l term that showed up in lesson 07's diffuse formula — now revealed as one factor inside the full integral.
Every renderer is an approximation of this one integral. The three we care about differ only in how they attack it:
| Renderer | How it approximates the rendering equation | Cost |
|---|---|---|
| Local shading (lesson 07) | Drop the integral entirely. Replace ∫Ω with a sum over a few point lights, use an ad-hoc fr, and assume every point sees every light (no visibility term). | trivial |
| Ray / path tracing (lesson 06) | Sample the integral directly. Shoot rays into the hemisphere, recurse for the bounced Li, Monte-Carlo average. Converges to the true answer. | very high |
| Rasterization (this lesson) | Approximate each piece with a trick. A principled fr (§2), a shadow pass for visibility (§3), and cheap fakes for the bounced Li (§4). | low, real-time |
So lesson 07's local shading is not "a different model" — it is the crudest possible truncation of this equation: keep the cosine, replace the BRDF integral with a couple of point-light evaluations, and silently set the visibility to 1 everywhere. The rest of this lesson is a guided tour of putting the dropped pieces back — the BRDF (§2), the visibility term (§3), and the bounced-light term (§4) — while staying inside the real-time budget of the rasterizer.
2 · PBR materials — the microfacet BRDF
Lesson 07 shaded with an ad-hoc Blinn–Phong lobe: a diffuse color, a specular color, and a shininess exponent you hand-tuned until it "looked right" under your test light. Move the object to a different scene and it looks wrong — too shiny, too matte, the wrong tint on the highlight. The fix is to stop guessing and use a BRDF derived from physics. That is physically-based rendering (PBR), and its workhorse is the microfacet model.
The idea. A real surface is not smooth at the scale of light. It is millions of tiny mirrors — microfacets — each perfectly reflective but oriented randomly. A polished surface has facets nearly all aligned with the macroscopic normal n (so reflections are sharp); a rough surface has facets scattered in orientation (so reflections are blurred). The microfacet specular BRDF, in its Cook–Torrance form, is:
Three factors, each answering a physical question about those microfacets. Let h = normalize(ωi + ωo) be the half-vector (the microfacet normal that would mirror the light straight into the eye):
- D — the normal distribution function (NDF). "What fraction of microfacets are oriented to reflect the light toward me?" — i.e. how many face along h. The modern choice is GGX / Trowbridge–Reitz, controlled by roughness α. Small α concentrates the facets near n → a tight, bright mirror highlight; large α spreads them → a broad, dim, matte sheen. This is the single knob that replaces Blinn–Phong's shininess exponent, and it does so with a physically meaningful long tail that Blinn–Phong lacks.
- F — the Fresnel term. "How reflective is the surface at this viewing angle?" The physical fact: everything becomes a mirror at grazing angles. Look straight down into calm water and you see the bottom; look across it at a shallow angle and it is a mirror of the sky. The Schlick approximation captures it cheaply:
F = F0 + (1 − F0)(1 − (h·ωo))5where F0 is the reflectance at head-on incidence and the (1−·)5 drives it toward 1 at the silhouette.
- G — the geometry (shadowing–masking) term. "At this angle, how many microfacets shadow or occlude each other?" At grazing angles a facet's neighbors block its light coming in (shadowing) and its light going out (masking). The Smith formulation splits this into the two directions and multiplies them. It keeps the BRDF from over-brightening at grazing angles and is what makes energy add up.
The 4(ωo·n)(ωi·n) in the denominator is a normalization from the change of variables between microfacet and macroscopic geometry. In code you guard it with a small ε so a grazing fragment doesn't divide by zero.
The metallic–roughness workflow
PBR would be no improvement if it needed a physicist to author. Its triumph is a tiny, intuitive parameter set — the metallic–roughness workflow (glTF, Unreal, Unity all use it):
The physics collapses cleanly onto these three because of a real dividing line in nature — metals versus everything else (dielectrics: plastic, wood, skin, stone, paint):
| Dielectric (metallic 0) | Metal (metallic 1) | |
|---|---|---|
| Diffuse (body color) | colored diffuse albedo | none — metals have no diffuse term |
| Specular F0 | ≈ 0.04 (a small, white/achromatic constant) | = the base color (a bright, tinted reflectance) |
| Why | light penetrates, scatters, re-emerges as diffuse; only ~4% reflects specularly at the surface | free electrons absorb transmitted light instantly, so all response is surface reflection — and it is colored |
Two worked constants to memorize. A dielectric has F0 ≈ 0.04 — that is why a plastic ball has a small white highlight regardless of its body color. A metal has F0 = base color and no diffuse — which is the whole reason gold reflects gold: its specular highlight and reflections are tinted by the metal's own color, not white. In shader code this is one mix:
Energy conservation. A surface cannot reflect more light than it receives, so diffuse + specular must not exceed the incoming energy: diffuse + specular ≤ 1. The metallic term enforces the most important half of this automatically — as a material goes metallic, its diffuse fades to zero exactly as its tinted specular takes over. Ad-hoc Blinn–Phong had no such guarantee; you could (and people did) author materials that emitted more light than fell on them, which reads as "fake" the moment the lighting changes.
3 · Shadows — shadow mapping
Look again at the rendering equation's integrand: Li(x, ωi) is the light actually arriving from direction ωi. If something blocks the path between x and the light, Li is zero from that direction — the point is in shadow. Lesson 07's local shading quietly assumed every point sees every light (visibility always 1). Real light is occluded constantly, and shadows are one of the strongest cues the eye uses to read a scene's geometry. So we need a visibility term. The rasterizer's answer is beautifully economical: reuse the z-buffer from lesson 06, run from a second viewpoint.
Shadow mapping — two passes.
- Depth pass, from the light. Put the camera at the light and render the scene, but keep only the depth buffer — the distance to the nearest surface along each of the light's rays. Store it as a depth texture: the shadow map. This is exactly the z-buffer of lesson 06 §4, just rendered from a different eye point. It answers, for every direction the light looks, "how far is the closest thing I can see?"
- Shading pass, from the camera. Now render normally from the real camera. For each fragment at world point x, transform it into the light's clip space to find (a) where it lands in the shadow map and (b) its depth as seen from the light. Compare: if the fragment's light-space depth is greater than the stored nearest depth at that texel, something else was closer to the light — the fragment is occluded, so it is in shadow (skip its contribution from this light). If the depths match, the fragment is the closest surface, so it is lit.
That comparison — "am I farther from the light than the nearest thing the light could see?" — is the entire visibility test, and it is one texture lookup plus one subtraction per fragment per light.
Artifacts and their fixes
The idea is clean; the finite-resolution reality has three classic failure modes, and knowing the fix for each is a standard interview beat:
| Artifact | Cause | Fix |
|---|---|---|
| Shadow acne (surfaces self-shadow in a moiré of dark stripes) | a lit surface is compared against a shadow-map texel that quantized slightly in front of it; it reads as occluding itself | add a small depth bias — push the stored depth back a hair before comparing |
| Peter-panning (the shadow detaches, object floats above its own shadow) | too much bias — the fix for acne overshoots, so contact shadows lift off the ground | tune the bias, or use a slope-scaled bias / render back-faces into the map |
| Jagged edges (blocky, aliased shadow boundary) | the shadow map is a finite grid; one texel maps to many screen pixels near the edge — aliasing, again (CV lessons 01–02) | PCF (percentage-closer filtering): sample several neighboring texels, run the depth test on each, and average the results for a soft, antialiased edge |
For a large view frustum a single shadow map can't be sharp everywhere — nearby shadows want high resolution, distant ones don't. Cascaded shadow maps (CSM) split the view frustum into depth slices and give each its own shadow map, so resolution follows where you're looking. The whole apparatus is, once more, the same rasterizer run a second time — no new machinery, just a clever reuse of coverage + z-buffer to recover the visibility term the rendering equation demanded.
4 · Global illumination — the bounce rasterization can't do in one pass
Even with a principled BRDF and correct shadows, one thing is still missing, and it is the term that makes rendered images feel lit rather than painted. In the rendering equation, Li — the light arriving at a point — is not just light straight from a lamp. It includes light that bounced off other surfaces first. A red wall bleeds red onto a white floor beside it (color bleeding). The inside of a crevice is darker than the open surface because ambient light from the environment is blocked from reaching it (ambient occlusion). A mirror shows the room. That entire recursive, scene-wide transport is global illumination (GI).
The honest integral of §1 is expensive because the bounced Li is itself an outgoing radiance from somewhere else — the equation is recursive. Real-time rendering approximates the bounce term with a toolbox of cheats, each trading a slice of correctness for speed:
| Technique | What it approximates | How |
|---|---|---|
| SSAO (screen-space ambient occlusion) | how much ambient light a crevice blocks | in screen space, sample the depth buffer around each pixel; if neighbors are close and in front, the point is in a pit → darken it |
| IBL (image-based lighting) | ambient light from the whole surrounding environment | precompute an environment map into a diffuse irradiance map + a prefiltered specular map (by roughness); a cheap ambient that matches the surroundings and pairs perfectly with §2's PBR |
| Baked lightmaps / irradiance probes | static bounced light (indirect GI on fixed geometry) | compute the expensive GI offline, store it in textures / a probe grid, and just sample it at runtime |
| SSR (screen-space reflections) / reflection probes | mirror-like reflections of the scene | ray-march the depth buffer to reflect on-screen geometry; fall back to precomputed reflection probes for what's off-screen |
| Hybrid ray tracing (RTX) | shadows, reflections, and GI — correctly | trace a few real rays on top of the raster pass; the modern convergence of the two renderers contrasted in lesson 06 |
Notice the throughline: IBL and SSAO both approximate the ambient (indirect diffuse) slice; SSR and probes approximate the specular slice; lightmaps bake the static part offline; and RTX stops faking and traces the real thing where the budget allows. Hybrid ray tracing is the telling one — it is the two renderers from lesson 06 (rasterizer + ray tracer) finally sharing a frame, rasterizing primary visibility for speed and tracing secondary rays for the transport a raster pass structurally cannot do. Frame the whole section this way: the honest integral of §1 is expensive, and every technique here is an approximation of its bounce term.
5 · The pipeline reality: linear light, HDR, tone mapping, AA/post
You can have a perfect BRDF, correct shadows, and good GI, and the image can still look subtly wrong — muddy midtones, dirty edges — because of the color space the math ran in. Two facts about the pipeline are non-negotiable.
HDR and tone mapping. Lighting in linear space produces high dynamic range radiance — a sunlit highlight or a bright bulb has a value well above 1.0, because real luminances span many orders of magnitude. But a display shows only [0, 1]. Clamping (min(color, 1)) destroys all detail in the brights — a window blows out to a flat white rectangle. Instead you tone-map: apply a curve (Reinhard, or the filmic ACES curve, scaled by an exposure control) that gently compresses the HDR range into [0, 1], preserving highlight detail the way film does. Tone mapping happens after all linear lighting and before the sRGB encode.
Antialiasing and post. Finally, the modern pipeline layers on the filmic polish: temporal antialiasing (TAA, which reuses samples across frames to smooth edges — the successor to lesson 07's MSAA), then post-process effects — bloom (glow around HDR highlights), depth of field, motion blur, and color grading. These don't add physical correctness; they add the final layer of realism the eye reads as "photographic." Order matters throughout: decode → light in linear/HDR → tone-map → encode to sRGB → antialias + grade.
6 · Where forward rendering ends → the inverse pivot
Stand back and look at what we have built across lessons 06–08. We can turn a hand-modeled scene into a photorealistic image: coverage and a z-buffer (06), perspective-correct interpolation and per-fragment shading (07), and now principled materials, shadows, GI, and a linear-HDR pipeline (08). This is the summit of forward rendering. And at the summit, two hard limits come into focus — the same two the last lesson foreshadowed.
- It is still the non-differentiable hard rasterizer. Nothing in this lesson touched the core machinery of lesson 07 §5: coverage is a hard argmax, the z-test a hard min, so ∂(pixel color)/∂(vertex position) is zero almost everywhere and undefined at edges. A prettier BRDF doesn't change the fact that you cannot backpropagate an image loss into the geometry through this pipeline.
- It is entirely hand-authored. Every input we've assumed came from a human: an artist modeled the mesh, painted the albedo/normal/roughness maps, and placed the lights. Photorealism here is a authoring achievement, not a capture one.
Now flip the goal. Suppose you don't want to author a scene — you want to capture a real one, from nothing but photographs. No modeling, no painting, no light placement: just images of a real object, and out the other end a renderable 3D scene. You cannot get there by running this forward pipeline harder. You have to run it backward — invert the renderer: define a scene representation with free parameters, render it, compare to the photos, and use the gradient of that photometric loss to update the parameters by gradient descent until the renders match. That loop is exactly the inverse-rendering loop lesson 01 promised — and it demands a differentiable renderer, which the hard rasterizer is not.
Where this points next
We have reached photorealism on the rasterizer — a principled microfacet BRDF, shadows from a reused z-buffer, faked global illumination, and a linear-light HDR pipeline with tone mapping. But we did it by authoring everything (mesh, materials, lights) and running a non-differentiable forward pass. Same rendering equation, but only ever in the forward direction. Lesson 09 takes the opposite turn: instead of authoring a scene and rasterizing it, capture a real scene from photographs by inverting the renderer. This needs a representation that is differentiable by construction — colored, semi-transparent density integrated along each ray, so an image loss flows all the way back into the scene by gradient descent. That is the radiance field of NeRF, and it finally closes the represent → render → compare → backprop loop lesson 01 promised and this forward pipeline structurally cannot. Put crisply: forward realism = author + rasterize; inverse realism = capture + optimize. Lesson 10 (3D Gaussian Splatting) will then bring that inverse idea back onto a — now soft, now differentiable — rasterizer.
Interview prompts
- Write the rendering equation and locate lesson 07's local shading inside it. (§1 — Lo = Le + ∫Ω fr·Li·(ωi·n) dωi; local shading drops the hemisphere integral, sums a few point lights with an ad-hoc fr, and assumes visibility = 1. The cosine is Lambert's law.)
- Why does gold's highlight look gold but a plastic ball's look white? (§2 — metals have F0 = base color and no diffuse (tinted specular); dielectrics have F0 ≈ 0.04 (achromatic specular) plus a colored diffuse. Metallic selects between them.)
- Name the three terms of the Cook–Torrance BRDF and what each controls. (§2 — D (GGX) = microfacet distribution, driven by roughness (tight vs broad highlight); F (Schlick) = grazing-angle reflectance rim; G (Smith) = shadowing/masking; over 4(ωo·n)(ωi·n).)
- How does shadow mapping work, and what causes shadow acne vs peter-panning? (§3 — render depth from the light, then in shading compare the fragment's light-space depth to the stored nearest depth; acne = self-shadowing from depth quantization, fixed with a bias; peter-panning = over-bias detaching the shadow; jaggies → PCF, big frusta → CSM.)
- Why can't a single rasterization pass do global illumination, and how is the bounce faked? (§4 — a raster pass shades a fragment knowing only itself + the lights, so it gets direct lighting only; the recursive bounced Li is approximated by SSAO, IBL, baked lightmaps/probes, SSR, or hybrid RTX ray tracing.)
- Why must lighting be computed in linear space, and what happens if you don't? (§5 — light adds linearly but sRGB is gamma-encoded (L≈V2.2, CV lesson 01); decode textures to linear, light, re-encode. Skipping it gives too-dark midtones and muddy/wrong-colored blends. Lighting also produces HDR, which must be tone-mapped, not clamped.)