Shader Lab
35.SSAO

SSAO — screen-space ambient occlusion

Arc 7 · Post-Pipeline · Lesson 35 of 35
coordinate signal color
0.65

Purpose

SSAO is fake soft shadowing that uses only the depth of the scene to figure out where surfaces are buried near each other — which is why a PBR scene without it looks floaty and a PBR scene with it feels grounded.

Key insight

Objects sitting on a floor, a book's spine against a shelf, the crease between a nose and a cheek — these places are darker in real life because surrounding geometry blocks some of the ambient light from reaching them. Computing real shadows for ambient light is expensive. SSAO cheats: for every pixel on screen, it samples a small hemisphere of nearby points and asks "is this sample buried inside scene geometry?" by checking the depth buffer. The fraction of samples that are buried becomes an occlusion value — darker where many samples are buried (deep creases, tight corners), lighter where few are (flat open surfaces).

Two things make this lesson conceptually distinct from every prior one. First, the shader reads from a second buffer — not color, but depth — which means the modern renderer outputs multiple images per pixel per frame. That collection is called the G-buffer. Second, because SSAO samples neighbors, it inherits the Lesson-1 multi-pass contract: this cannot be one-pass. Depth is rendered first, then SSAO samples from it, then the blur smooths the noisy samples, then the composite multiplies occlusion into the color.

Break it

Pull groundedness to 0. The sphere visibly floats — no soft contact shadow beneath it, no darkening where it kisses the plane, no crease where the cube meets the ground. The brain reads the sphere and cube as composited-on-top rather than sitting-on. Now slide up to a natural mid-value: they lock to the ground. Teaches: ambient occlusion is the single biggest contributor to "objects belong to each other." Without it, even perfect PBR looks like a collage. With it, even simple lighting feels physical. When a scene feels floaty, ask for "more SSAO" before adjusting any light.

Direct Claude

"more grounded" groundedness up "less dirty / cleaner feel" groundedness down "more weight / objects resting on things" groundedness up, larger sample radius "tighter creases / less bleed" smaller sample radius "softer, less noisy AO" wider blur on the AO buffer
Meta-phrase you gain here: "sample the depth buffer" / "G-buffer pass." SSAO is the first lesson where the brief involves more than color. Asking "what's in the G-buffer?" becomes the right question whenever you want screen-space effects that need more than just the rendered image.
Combines with: L32 (bloom) — SSAO multiplies into color before bloom thresholds, so AO-darkened creases bloom slightly less. L33 (ACES) — AO happens pre-tone-map, which is correct; it modifies the scene's lighting energy, not the displayed pixels. L34 (LUT) — LUT runs after everything, so it grades AO-darkened crevices the same way it grades everything else. Canonical stacking order for the AAA polish pass: SSAO → bloom → ACES → LUT → vignette/grain. Modify lighting energy → bloom the brights → squash to displayable → grade the look → finish.
the SSAO wiring running above
// SSAO needs the G-buffer: depth + normals + color.
// Three.js's SSAOPass handles all three internally — it re-renders the scene
// into depth + normal render targets, then samples from them.

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

// SSAOPass needs scene+camera because it renders the G-buffer itself.
const ssao = new SSAOPass(scene, camera, width, height);

// These parameters ARE the SSAO character:
//   kernelRadius  — how far to look for occluders
//                   (the `groundedness` slider above drives this)
//   minDistance   — reject self-occlusion within this depth range
//   maxDistance   — reject occluders farther than this (prevents halos)
ssao.kernelRadius = 8.0;
ssao.minDistance = 0.002;
ssao.maxDistance = 0.1;

composer.addPass(ssao);

// Ordering rule: SSAO runs BEFORE bloom + tone-map + LUT.
// It's modifying the scene's lighting energy — the other passes work on the
// result of that. Try it in the other order and the AO darkening gets
// graded twice, or bloom sees wrong brightness, or tone-map flattens the
// contact shadow you wanted.

composer.addPass(new UnrealBloomPass(...));   // L32
composer.addPass(new OutputPass());           // L33 (ACES tone-map)
composer.addPass(lutPass);                    // L34

// Slider: `groundedness` scales kernelRadius.
// At radius 0 the sample hemisphere collapses to a point and no occlusion
// is detected. Slide up and the crevices show their darkening.
function setGroundedness(v) { ssao.kernelRadius = v * 16.0; }