Ambient Occlusion & Global Illumination
The renderer's ambient term is still a flat constant, so crevices and contact points glow as bright as open surfaces. Ambient occlusion darkens that ambient where geometry blocks it, and we build SSAO right on top of the deferred G-buffer. Then a survey of how engines fake the harder thing AO can't do: indirect bounce light. The honest framing throughout: AO is occlusion, not GI.
01What AO is, and is not
is a cheap approximation of how much ambient/indirect light a point can receive given nearby geometry that blocks it. It darkens crevices, contact points, and concavities. Formally it's the cosine-weighted fraction of the hemisphere above a point that is not occluded within some radius[1]:
The ∫Ω sweeps every direction ω over the hemisphere above the point. For each one, V is 1 if that direction sees open sky within the radius and 0 if geometry blocks it, weighted by the cosine n·ω so straight-overhead directions count most. The 1/π normalizes so a fully unoccluded point integrates to AO = 1 (open), and a sealed crevice trends toward 0 (dark). Hover any symbol to see what it stands for.
Need a refresher on what a hemisphere integral means?
The ∫ sign means "add up a quantity over a continuous range," the continuous version of a sum. Here the range is every direction in the hemisphere (Ω): the half-dome above the surface point, since occluders can only block light arriving from in front of the surface, not through it.
Picture splitting that dome into many tiny patches. Each patch is a direction ω; for it you ask "is anything blocking this direction within the radius?" (that is V), weight by the cosine n·ω, and add the results. SSAO (§2) cannot do that exactly per pixel, so it replaces the integral with a finite count over a few scattered samples.
- Not global illumination. No light bounces, no color transfer. AO only removes ambient light; it never adds indirect light (that's §6).
- Not a shadow from direct lights. It's view- and light-independent occlusion of the ambient term, not a shadow cast by the sun or a lamp (that's the Shadows tutorial).
- A coarse approximation. It assumes the incoming ambient is a uniform constant; it's "the occlusion of indirect light," not a simulation of indirect light.
02SSAO: the core idea
(Mittring/Crytek, 2007, shipped in Crysis) approximates AO from just the depth buffer[2]. For each pixel: reconstruct its view-space position (the position-from-depth you already built), scatter sample points in a volume around it, project each back to screen space, read the stored depth there, and count how many samples landed behind geometry. That occluded fraction is the occlusion[3].
SSAO sees only what's in the depth buffer, so it misses occluders that are off-screen or hidden behind other geometry, it's view-dependent (AO shifts and pops as the camera moves), and it halos at depth discontinuities[3]. These limits are exactly why the range check (§3) and later HBAO/GTAO (§5) exist. The original Crytek version sampled a full sphere, which marks ~half the samples on any flat surface as occluded, giving a uniform gray cast and white edge halos, the hemisphere fix is the first detail that matters.
The widget tests samples in the normal-oriented hemisphere against a draggable occluder; samples behind it count as occluded:
03SSAO: the details
Four details turn the core idea into something that looks right[3][4]:
- Normal-oriented hemisphere, not a sphere: keeps samples in the upper hemisphere, killing the flat-surface gray and most halos.
- Sample radius: too small, no visible contact darkening; too large, distant geometry smears occlusion onto unrelated surfaces.
- Range check + bias: a small depth bias stops a flat surface self-occluding (acne); a range check (
smoothstep(0,1, radius/abs(Δz))) discards "occlusions" where the occluder is far in front, killing haloing. - Rotation noise + blur: a tiny tiled noise texture rotates the kernel per pixel (so few samples suffice); a small blur then hides the resulting noise.
// hemisphere kernel, samples biased toward the origin
for (int i = 0; i < 64; ++i) {
glm::vec3 sample(rand01()*2-1, rand01()*2-1, rand01()); // z in [0,1] -> hemisphere
sample = glm::normalize(sample) * rand01();
float scale = (float)i / 64.0f;
sample *= lerp(0.1f, 1.0f, scale*scale); // cluster near the origin
kernel.push_back(sample);
}
// 4x4 rotation noise: z = 0 (rotate about tangent-space z)
for (int i = 0; i < 16; ++i) noise.push_back({ rand01()*2-1, rand01()*2-1, 0.0f });
let lerp = |a: f32, b: f32, f: f32| a + f * (b - a);
for i in 0..64u32 {
let mut s = Vec3::new(rand01()*2.0-1.0, rand01()*2.0-1.0, rand01()).normalize() * rand01();
let scale = i as f32 / 64.0;
s *= lerp(0.1, 1.0, scale*scale); // bias toward origin
kernel.push(s);
}
for _ in 0..16 { noise.push(Vec3::new(rand01()*2.0-1.0, rand01()*2.0-1.0, 0.0)); } // z=0
vec3 fragPos = texture(gPosition, uv).xyz; // view-space (reuse Deferred)
vec3 normal = normalize(texture(gNormal, uv).rgb);
vec3 randVec = normalize(texture(noiseTex, uv * noiseScale).xyz);
vec3 tangent = normalize(randVec - normal * dot(randVec, normal)); // Gram-Schmidt
mat3 TBN = mat3(tangent, cross(normal, tangent), normal);
float occ = 0.0;
for (int i = 0; i < kernelSize; ++i) {
vec3 samplePos = fragPos + (TBN * kernel[i]) * radius; // tangent -> view, offset
vec4 off = projection * vec4(samplePos, 1.0);
off.xyz = (off.xyz / off.w) * 0.5 + 0.5; // -> [0,1] screen UV
float sceneDepth = texture(gPosition, off.xy).z;
float range = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sceneDepth)); // kill halos
occ += (sceneDepth >= samplePos.z + bias ? 1.0 : 0.0) * range; // bias kills acne
}
float ao = 1.0 - occ / kernelSize; // then a small (depth-aware) blur
The teaching shader uses a stored view-space position target for clarity, reconstruct from depth as in Deferred. It also uses a plain box blur, production uses a depth-aware (bilateral) blur so AO doesn't bleed across silhouettes. Also skipped: half-resolution AO plus upsample (a near-universal perf win), temporal accumulation, and the HBAO/GTAO horizon math (§5). The depth compare here assumes a negative view-space Z (the camera looks down −Z, LearnOpenGL's convention); with a positive linear-depth buffer the comparison direction flips. 64 samples is illustrative, not a fixed cost.
04Where AO goes
AO multiplies the ambient/indirect/IBL term only, and that result is added to direct lighting[8]: color = directLighting + ambientIBL * ao.
Multiply direct light by AO and you crush surfaces that should be fully lit. The canonical counterexample: bake AO into a character's mouth, then shine a flashlight in, it stays black, because direct light got multiplied by occlusion. Direct light has its own visibility term: shadow maps. The precise rule is "AO modulates the indirect term, not the direct term." (Footnote for rigor: modern PBR also derives a specular occlusion for indirect specular, and bent normals can apply a constrained occlusion, but for a buildable SSAO, multiply the diffuse ambient/IBL and stop.)
float ao = texture(ssaoBlurred, uv).r;
vec3 ambient = ambientIBL * albedo * ao; // AO multiplies the INDIRECT term
vec3 color = directLighting + ambient; // direct light is NOT scaled by AO
Toggle AO, sweep the radius and bias, and flip the "apply to direct light" switch to see the over-darkening bug:
05HBAO & GTAO
Two better AO methods, scoped. HBAO (horizon-based, Bavoil & Sainz) treats the depth buffer as a heightfield and, for several directions, marches it to find the horizon angle (the steepest angle to nearby geometry), integrating occlusion from that, more geometrically faithful than random hemisphere sampling[5]. GTAO (ground-truth, Jimenez et al., Activision 2016) is a horizon method tuned so the result matches a ray-traced AO reference, with a multi-bounce term (an albedo polynomial) to avoid single-bounce over-darkening[6].
GTAO is still ambient occlusion: local, no light transport, no color bleeding. "Ground truth" means it matches a Monte-Carlo AO reference, not that it's correct GI[7]. The multi-bounce term is a fitted polynomial approximating interreflection darkening, not real bounce light. And HBAO/GTAO are still screen-space, off-screen and occluded geometry are still missed; they improve the integral's fidelity, not the screen-space blindness. (Engine note: GTAO ships in Call of Duty and Unity's HDRP; it is not Unreal's default AO.)
06AO is not GI
Direct lighting plus AO still can't produce indirect bounce light or color bleeding (a red wall tinting a white floor). AO only removes ambient; it never transports light between surfaces. That requires solving the indirect part of the rendering equation[9]: the integral of incoming radiance over the hemisphere, where that incoming radiance is itself the outgoing radiance of other surfaces.
The light leaving a point toward your eye (Lo) is whatever it emits (Le, usually zero) plus a sum over every incoming direction. The ∫Ω is that sum over the hemisphere of directions ωi. For each one, take the incoming light Li, scale by the BRDF (the fraction reflected toward the viewer), and weight by the cosine n·ωi. The recursion that makes this GI: Li is itself the Lo of whatever surface that direction sees.
Direct lighting evaluates that integral only for the light sources; approximates the recursive rest. A flat "ambient" constant (or a single IBL probe) is the crude stand-in. Every GI family below is a different way to compute a better indirect term.
07The GI family map
No real-time GI method is solved or universally best; each approximates with a characteristic failure mode. From cheap-and-incomplete to expensive-and-complete:
Baked is free at runtime because it's frozen, it can't react to a moving light. Probes interpolate, so a probe straddling a wall leaks outdoor light inside; SH is low-frequency and can ring. DDGI fixes leaking statistically (per-probe distance + a Chebyshev visibility test) but is diffuse-only and needs ray tracing. SSGI is off-screen-blind like all screen-space tricks. Lumen is a hybrid (screen trace → software ray tracing against signed-distance fields + a surface cache → optional hardware ray tracing), not pure hardware RT, so its default path is approximate. Even path tracing is denoised approximation in real time.
Compare a scene under flat ambient, baked, probes, and a path-traced reference; watch the light move and the box slide:
Wrong answers, and why: a muddy image is AO crushing direct light (not radius or a depth clear); and GTAO is more-accurate AO matching a reference integral, still screen-space and still not GI.
08Pitfalls
09What's next
The lit, occluded image is nearly final. The last image-quality stage is Post-Processing & Anti-Aliasing: bloom, tone mapping and exposure, and the AA families (MSAA, FXAA/SMAA, TAA). Then the 3D-game capstone ties the whole renderer into a game. The full 3D path is on the series hub.
- Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 11. realtimerendering.com. The AO definition, the cosine-weighted visibility integral, and the GI overview.
- Martin Mittring. "Finding Next Gen: CryEngine 2." SIGGRAPH 2007. advances.realtimerendering.com. The original depth-buffer SSAO, shipped in Crysis.
- Joey de Vries. LearnOpenGL, "SSAO." learnopengl.com. The buildable SSAO: hemisphere kernel, rotation noise, range check, bias, and the blur.
- John Chapman. "SSAO Tutorial." john-chapman-graphics.blogspot.com. A clear derivation of the range check and bias rationale.
- Louis Bavoil and Miguel Sainz. "Image-Space Horizon-Based Ambient Occlusion" (NVIDIA, 2008/2011). developer.nvidia.com. The horizon-angle method over a depth heightfield.
- Jorge Jimenez et al. "Practical Real-Time Strategies for Accurate Indirect Occlusion." Activision, SIGGRAPH 2016. iryoku.com. GTAO: matching the ray-traced AO integral, plus the multi-bounce approximation.
- Intel GameTechDev. XeGTAO. github.com/GameTechDev/XeGTAO. The canonical open GTAO implementation; "ground truth = the ray-traced AO reference."
- Michael Bunnell. "Dynamic Ambient Occlusion and Indirect Lighting." GPU Gems 2, ch. 14 (NVIDIA). developer.nvidia.com. AO modulates the indirect/ambient term, added to direct light.
- James T. Kajiya. "The Rendering Equation." SIGGRAPH 1986. overview. The recursive indirect integral global illumination approximates.
- David Neubelt and Matt Pettineo (Ready at Dawn). Baked lighting in The Order: 1886. 80.lv. All-baked diffuse GI + baked specular; the static-GI tradeoff in a shipped AAA title.
- Zander Majercik et al. "Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields." JCGT 2019. jcgt.org. Probes updated per frame by ray tracing, with a per-probe distance/visibility test that fixes light leaking.
- Cyril Crassin et al. "Interactive Indirect Illumination Using Voxel Cone Tracing." CGF 2011. research.nvidia.com. The sparse voxel octree + cone tracing approach (VXGI).
- Epic Games. "Lumen Technical Details" (Unreal Engine 5). dev.epicgames.com. The hybrid: screen traces, software ray tracing against signed-distance fields plus a surface cache, and optional hardware ray tracing.