All tutorials Mighty Professional
Build a Game Engine · 3D Rendering

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.

Time~55 min LevelSenior PrereqsThe Deferred Rendering tutorial (the G-buffer + position from depth SSAO reuses) and PBR (the ambient/IBL term). StackGLSL · C++ & Rust
◂ Build a Game Engine Phase 7 · 3D Rendering Next · Post-Processing & Anti-Aliasing ▸

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

eq. 1 · ambient-occlusion integral AO(p) = 1π Ω V(p, ω) · (n·ω) dω

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.

Three things AO is not

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

It's screen-space, with screen-space blindness

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:

Green samples are visible, red are occluded by the ledge; the readout is the occlusion fraction. Shrink the radius and contact darkening vanishes; grow it and the far ledge darkens an unrelated surface. Switch to the full sphere and watch ~half the samples go red on a flat surface with nothing nearby, the gray cast and halos the hemisphere orientation fixes.

03SSAO: the details

Four details turn the core idea into something that looks right[3][4]:

The SSAO kernel + noise (CPU) and the fragment shader (GLSL)
// 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
What's intentionally missing

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.

Applying AO to direct light is the classic bug

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

Apply AO to the ambient term only (GLSL)
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:

AO on adds the contact darkening in corners and where boxes meet the floor. Low bias brings back acne; high bias erases the occlusion. Flip "apply to direct light" and the whole lit image goes muddy and dark, AO belongs on the ambient term only.

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

"Ground truth" means matching the AO integral, not solving GI

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.

eq. 2 · the rendering equation Lo = Le + Ω fr · Li · (n·ωi) dωi

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:

FamilyWhat it doesFailure mode
Baked lightmapsOffline radiosity/path-trace into textures[10]Static: can't change with dynamic lights/geometry
Probes (irradiance + reflection)Sample indirect at points (), interpolateLight leaking through walls; low-frequency
DDGIProbes updated per frame by ray tracing[11]Diffuse only; needs RT hardware; leaks reduced not zero
Voxel cone tracingVoxelize, cone-trace the mip pyramid[12]Approximate, memory-heavy, leaks through thin geo
SSGIGather indirect from on-screen pixelsOff-screen contribution missing; pops with view
Ray-traced / SDF (Lumen)Trace into the scene; hybrids[13]Noise + denoise cost; Lumen's default SDF path is approximate
The recurring traps

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:

Flat ambient: no red bleed onto the floor, no indirect at all. Baked: the bleed is there but the lighting freezes when the light moves (static). Probes: bleed plus visible leaking through the thin wall. Path-traced reference: correct, but labeled offline-cost. Each mode shows its signature compromise.

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

AO on direct lightCrushes lit surfaces. AO modulates the ambient/indirect term only.
Full-sphere SSAOGray cast + halos. Orient the kernel to the normal hemisphere.
No range checkForeground objects halo distant surfaces. Add the smoothstep range check.
Naive box blurAO bleeds across silhouettes. Use a depth-aware (bilateral) blur.
"GTAO solves GI"It matches an AO reference, not GI. Still local, no bounce.
"AO simulates indirect light"It removes ambient; it never adds bounce light or color bleed.
Probe light leakingProbes interpolate through walls; use visibility data (DDGI) or placement.
SSGI as full GIOff-screen-blind. A supplement to baked/probe GI, not a replacement.

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.

  1. 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.
  2. Martin Mittring. "Finding Next Gen: CryEngine 2." SIGGRAPH 2007. advances.realtimerendering.com. The original depth-buffer SSAO, shipped in Crysis.
  3. Joey de Vries. LearnOpenGL, "SSAO." learnopengl.com. The buildable SSAO: hemisphere kernel, rotation noise, range check, bias, and the blur.
  4. John Chapman. "SSAO Tutorial." john-chapman-graphics.blogspot.com. A clear derivation of the range check and bias rationale.
  5. 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.
  6. 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.
  7. Intel GameTechDev. XeGTAO. github.com/GameTechDev/XeGTAO. The canonical open GTAO implementation; "ground truth = the ray-traced AO reference."
  8. 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.
  9. James T. Kajiya. "The Rendering Equation." SIGGRAPH 1986. overview. The recursive indirect integral global illumination approximates.
  10. 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.
  11. 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.
  12. Cyril Crassin et al. "Interactive Indirect Illumination Using Voxel Cone Tracing." CGF 2011. research.nvidia.com. The sparse voxel octree + cone tracing approach (VXGI).
  13. 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.

See also