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 this module builds SSAO on top of the deferred G-buffer. Then it surveys how engines approximate what AO can't provide: indirect bounce light. AO only removes light, so it never substitutes for 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. 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]. The range check (§3) tames the halos; HBAO and GTAO (§5) improve the estimate but keep the screen-space blindness. The original Crytek version sampled a full sphere, which marks about half the samples on any flat surface as occluded: flat areas read mid-gray and convex edges read bright. Orienting the samples to the normal's hemisphere fixes that, and it's the first detail that matters.
The widget tests samples in the normal-oriented hemisphere against a draggable occluder ledge; samples that land inside geometry 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. rand01(): uniform float in [0,1)
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 *= glm::mix(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 sample = Vec3::new(rand01()*2.0-1.0, rand01()*2.0-1.0, rand01()).normalize() * rand01();
let scale = i as f32 / 64.0;
sample *= lerp(0.1, 1.0, scale*scale); // cluster near the origin
kernel.push(sample);
}
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 occlusion = 0.0;
for (int i = 0; i < kernelSize; ++i) {
vec3 samplePos = fragPos + (TBN * kernel[i]) * radius; // tangent -> view, offset
vec4 sampleClip = projection * vec4(samplePos, 1.0);
vec2 sampleUV = (sampleClip.xy / sampleClip.w) * 0.5 + 0.5; // NDC -> [0,1] screen UV
float sceneDepth = texture(gPosition, sampleUV).z; // view-space z of what's really there
float range = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sceneDepth)); // kill halos
occlusion += (sceneDepth >= samplePos.z + bias ? 1.0 : 0.0) * range; // bias kills acne
}
float ao = 1.0 - occlusion / float(kernelSize); // then a small (depth-aware) blur
The teaching shader reads a stored view-space position target for clarity; production code reconstructs position from depth as in Deferred. It pairs with a plain box blur, where production uses a depth-aware (bilateral) blur so AO doesn't bleed across silhouettes. Also skipped: half-resolution AO plus upsample (a common performance 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 every surface the AO pass reads as partly occluded loses direct light too, even under a bright lamp; since SSAO rarely returns a clean 1.0 on open surfaces either, the whole image goes muddier. A standard counterexample: bake AO into a character's mouth and shine a flashlight in. The mouth stays black, because the direct light was 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 bias, and flip the "apply to direct light" switch while the light passes over the boxes:
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), and integrates occlusion from that, which is 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 that brightens AO to approximate interreflections; it doesn't transport any light. HBAO and GTAO are still screen-space, so off-screen and hidden geometry is still missed: they improve the integral's fidelity without curing the screen-space blindness. (GTAO came out of Activision's Call of Duty work[6]; Intel's XeGTAO is an open implementation[7].)
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 nearly free at runtime because it's frozen: it can't react to a moving light. Probes interpolate, so a surface shaded from probes on both sides of a wall leaks outdoor light inside; SH is low-frequency and can ring. DDGI reduces 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 traces, then software ray tracing against signed-distance fields and a surface cache, with hardware ray tracing optional), so its default path trades accuracy for speed. Even path tracing is denoised approximation in real time.
A top-down floor beside a thin red wall, lit by a light that moves along the wall. Compare flat ambient, baked, probes, and a reference:
Wrong answers, and why: lit contacts staying dark is AO scaling direct light (not the 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 course notes. dl.acm.org. 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.