all lessons / computer_vision_3d / 08 · realistic rasterization lesson 8 / 13

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.

The plan
Six moves. (1) Name the target — the rendering equation — and see that all of realism is how well you approximate one integral; lesson 07's local shading is its crudest possible truncation. (2) Replace ad-hoc Blinn–Phong with a physically-based microfacet BRDF (Cook–Torrance: the D, F, G terms) and the metallic–roughness workflow, so one material looks correct under any lighting. (3) Shadow mapping — reuse the z-buffer from a second viewpoint (the light's) to add the visibility term local shading ignored. (4) Global illumination — the bounced light a single raster pass can't compute, and the family of fakes (SSAO, IBL, lightmaps, SSR, hybrid ray tracing) that approximate it. (5) The pipeline reality: do all lighting in linear light, produce HDR, then tone-map and antialias. (6) The payoff — forward rendering is photoreal but hand-authored and still non-differentiable, so to capture realism from photographs you must invert the renderer, which hands us straight to 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:

Lo(x, ωo) = Le(x, ωo) + ∫Ω fr(x, ωi, ωo) · Li(x, ωi) · (ωi · n) dωi .

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:

RendererHow it approximates the rendering equationCost
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:

fspec =   D · F · G    4 (ωo·n)(ωi·n)  

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

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

base color (albedo)
the material's intrinsic color, RGB
metallic ∈ {0, 1}
is it a metal or a dielectric?
roughness ∈ [0, 1]
smooth mirror → matte (the α in D)

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 albedonone — metals have no diffuse term
Specular F00.04 (a small, white/achromatic constant)= the base color (a bright, tinted reflectance)
Whylight penetrates, scatters, re-emerges as diffuse; only ~4% reflects specularly at the surfacefree 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:

F0 = mix(0.04, baseColor, metallic),    kd = (1 − metallic) · baseColor .

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.

Why PBR is the standard
The payoff is not prettier defaults — it is portability of appearance. Because the BRDF is grounded in physics and the parameters are physical, one authored material looks correct under any lighting: noon sun, overcast sky, a dim interior. Hand-tuned Blinn–Phong is tuned to one light rig and breaks under every other. You feed spatial detail through texture maps — albedo, normal (per-pixel normals, lesson 07 §3), roughness, metallic, and ambient-occlusion maps — so a single shader paints an entire richly varied surface.

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.

  1. 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?"
  2. 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:

ArtifactCauseFix
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 itselfadd 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 groundtune 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 structural limit of rasterization
A single rasterization pass shades each fragment knowing only that fragment and the lights — it has no access to the rest of the scene's surfaces, because rasterization draws one triangle at a time with no memory of the others. So it computes direct lighting only. The bounced term Li (which needs to know what light other surfaces are sending) is exactly the recursion a path tracer does natively and a raster pass cannot. Everything below is a way to fake that bounce cheaply.

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:

TechniqueWhat it approximatesHow
SSAO (screen-space ambient occlusion)how much ambient light a crevice blocksin 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 environmentprecompute 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 probesstatic 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 probesmirror-like reflections of the sceneray-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 — correctlytrace 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.

Do all lighting math in LINEAR light — the classic realism bug
Image textures and displays are encoded in sRGB (gamma), roughly L ≈ V2.2 — the gamma curve from CV lesson 01 (../computer_vision/01_image_representation.html). Gamma is nonlinear, and light physically adds linearly. So the pipeline must: decode sRGB textures to linear on read → do all BRDF, shadow, and light-sum math in linear space → re-encode to sRGB for display. Skip the decode and you are adding gamma-encoded values as if they were linear — the textbook realism bug: too-dark midtones, wrong-colored edges where two lights or two textures blend, halos around highlights. "Why does my lighting look dingy and my blends muddy?" is almost always "you're lighting in gamma space." Decode in, light linear, encode out.

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.

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

Takeaway
All of realism is how well you approximate one integral — the rendering equation Lo = Le + ∫Ω fr · Li · (ωi·n) dωi. Lesson 07's local shading is its crudest truncation (drop the integral, sum point lights, visibility = 1). Rasterization puts the pieces back with tricks: a microfacet BRDF (Cook–Torrance D·F·G / 4(ωo·n)(ωi·n) — GGX roughness, Schlick Fresnel, Smith geometry) driven by the metallic–roughness workflow (dielectric F0≈0.04 + colored diffuse; metal F0=base color, no diffuse — why gold reflects gold), so one material is correct under any light; shadow mapping reuses the z-buffer from the light's viewpoint to add the visibility term (watch for acne → bias, peter-panning, jaggies → PCF/CSM); and GI fakes (SSAO, IBL, lightmaps, SSR, hybrid RTX) approximate the bounced Li a single raster pass can't compute. Do all lighting in linear light (decode sRGB → light → re-encode; the classic dingy-midtone bug), produce HDR, then tone-map (ACES/Reinhard + exposure) and antialias (TAA) + post. The ceiling: this is photoreal but still the non-differentiable hard rasterizer and hand-authored — which is exactly why the next lessons invert the renderer.
PBR material sphere (Cook–Torrance / GGX)
A sphere shaded per-pixel with a real physically-based BRDF — the same Cook–Torrance D·F·G from §2, evaluated in the browser. For each pixel on the disk we reconstruct the surface normal, then compute GGX distribution, Schlick Fresnel, and Smith geometry, add a Lambert diffuse, tone-map and gamma-encode. Drive the metallic–roughness knobs: low roughness → a tight mirror highlight, high roughness → a broad matte sheen. Metallic 0 gives a colored body with a white highlight; metallic 1 kills the diffuse body entirely and tints the specular — pick the gold preset and watch the highlight go gold. Watch the Fresnel rim brighten at the silhouette on every material.
base color:
Roughness α
0.25
Metallic
0.00
F₀ (head-on refl.)
0.04 (white)
Look
dielectric
Show the core JS
// Per-pixel Cook-Torrance on a sphere disk of radius R centred on the canvas.
// v = view dir (toward camera); l = light dir from the angle slider; h = half-vector.
var v = [0, 0, 1];
var l = norm([Math.sin(theta), 0.40, Math.cos(theta)]);   // theta = light-angle slider
var a = rough * rough;                                     // GGX uses alpha = roughness^2
// F0: dielectric = 0.04 (white); metal = base colour (tinted). Diffuse fades with metallic.
var F0  = [ mix(0.04, base[0], metal), mix(0.04, base[1], metal), mix(0.04, base[2], metal) ];
var kd  = 1 - metal;                                       // no diffuse for pure metal

for (each pixel (px,py) with r2 = px*px + py*py <= R*R) {
  var nx = px / R, ny = -py / R, nz = Math.sqrt(1 - r2 / (R*R));   // reconstruct normal
  var n = [nx, ny, nz];
  var h  = norm([l[0]+v[0], l[1]+v[1], l[2]+v[2]]);
  var nl = Math.max(dot(n,l), 0), nv = Math.max(dot(n,v), 1e-4);
  var nh = Math.max(dot(n,h), 0), hv = Math.max(dot(h,v), 0);

  // D — GGX / Trowbridge-Reitz
  var dn = nh*nh*(a*a - 1) + 1;
  var D  = (a*a) / (Math.PI * dn * dn + 1e-7);
  // F — Schlick (per channel);  F = F0 + (1-F0)(1 - h·v)^5
  var fp = Math.pow(1 - hv, 5);
  // G — Smith with Schlick-GGX, k = (rough+1)^2 / 8
  var k  = (rough + 1)*(rough + 1) / 8;
  var G  = (nv/(nv*(1-k)+k)) * (nl/(nl*(1-k)+k));

  for (each RGB channel c) {
    var F    = F0[c] + (1 - F0[c]) * fp;
    var spec = D * F * G / (4 * nv * nl + 1e-4);            // Cook-Torrance specular
    var diff = kd * base[c] / Math.PI;                      // Lambert diffuse
    var col  = (diff + spec) * LIGHT[c] * nl + 0.03*base[c]; // + tiny ambient
    col = col / (1 + col);                                  // Reinhard tone-map
    out[c] = Math.pow(col, 1/2.2) * 255;                   // gamma encode to sRGB
  }
  setPixel(px, py, out);
}

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