Ambient Occlusion & Global Illumination
Your lit scene still looks flat: the corner where a wall meets the floor is as bright as the middle of the room, and crates look pasted onto the ground instead of resting on it. The cause is the ambient term, one constant everywhere. Ambient occlusion darkens that ambient where nearby geometry blocks it, and this page builds SSAO on top of the deferred G-buffer. Then it surveys how engines approximate what AO can't provide: light that bounces off one surface onto another. AO only removes light, so it never substitutes for global illumination.
- Say what an AO value means, and why AO can only darken ambient light, never add it.
- Build SSAO over the G-buffer: a normal-oriented hemisphere kernel, rotation noise, a range check, a bias and a blur.
- Apply AO to the ambient term only, and recognize the bug when it lands on direct light.
- Explain what HBAO and GTAO improve over SSAO and what they still miss.
- Pick a GI family (baked, probes, DDGI, voxels, SSGI, ray-traced) by the failure mode you can live with.
01What AO is, and is not
A crate sits on the floor, and the floor right next to it is exactly as bright as open floor a meter away. In a real room the crate blocks part of the walls and ceiling that would light that strip, so it's darker, and that darkening is how your eye reads "resting on the ground". (AO) 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. It's the share of the hemisphere (the half-dome of directions above the point) that is not blocked within some radius, with directions near straight up counting most[1].
Worked example
A point in the middle of an open floor sees the whole dome: AO = 1. A point right at the base of a tall wall has half its dome blocked by the wall, and since the weighting is symmetric, AO = 0.5. Tuck the point into a corner where two walls meet the floor and only a quarter of the dome is open: AO = 0.25. The shader multiplies the ambient light by those numbers, so the corner gets a quarter of the open floor's ambient.
The formula below computes that number, from 0 (sealed) to 1 (open), for a point p; the shader then multiplies the ambient light by it:
In words: 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
Evaluating that integral exactly means casting rays against the whole scene in many directions for every pixel, every frame, which most games can't afford. SSAO gets a usable answer from data a deferred renderer already has. (Mittring/Crytek, 2007, shipped in Crysis) approximates AO from just the depth buffer, the per-pixel distance to the nearest surface[2]. For each pixel: reconstruct its view-space position, its 3D position relative to the camera (the position-from-depth step from Deferred Rendering), 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].
Worked example
A pixel on the floor beside a crate scatters 16 sample points in the dome above it. Each one is projected to the screen and its depth compared with the depth buffer there. Four land inside the crate, behind the surface the depth buffer recorded, so the occlusion is 4 ÷ 16 = 0.25 and the pixel's AO is 1 − 0.25 = 0.75. A pixel in open floor finds all 16 in open air: AO = 1.
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 is a cross-section through one floor pixel and its samples:
03SSAO: the details
Implement the core idea literally and you get gray flat floors, dark halos around characters, speckle across flat walls, and banding from too few samples. Four details turn it 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, the same speckle shadow maps get); 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.
Worked example
With a 0.5 m radius, a sample whose depth-buffer surface is 0.3 m in front of the pixel gets range weight smoothstep(0, 1, 0.5 ÷ 0.3) = 1: a real contact. One that hits a character 6 m in front gets smoothstep(0, 1, 0.5 ÷ 6) = 0.02, so the character no longer darkens the wall behind it. The kernel loop below scales sample 0 by 0.1 of the radius, sample 32 by 0.325 and sample 63 by 0.97, bunching samples close to the pixel where occluders matter most. A 4 × 4 noise texture tiled over 1920 × 1080 repeats 480 × 270 times (that's noiseScale), so each 4 × 4 block of pixels uses 16 different rotations, and a 4 × 4 blur averages them back together.
// 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: at 1080p it's 133 million depth reads a frame, 33 million at 16 samples, and 33 million again for 64 samples at half resolution.
04Where AO goes
You have an AO value per pixel; where it enters the shading decides whether a lamp can still light a corner. AO multiplies the ambient/indirect/IBL term only (IBL, image-based lighting, is ambient light read from an environment map), 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 a bent normal, one tilted toward the least-blocked direction, can apply a constrained occlusion, but for a buildable SSAO, multiply the diffuse ambient/IBL and stop.)
Worked example
A floor pixel at the base of a box has AO = 0.5. A lamp overhead gives it 0.75 of direct light, and the ambient is 0.35. The right formula gives 0.75 + 0.35 × 0.5 = 0.925: the contact is slightly darker than open floor, and the lamp still lights it. Multiply everything by AO and you get (0.75 + 0.35) × 0.5 = 0.55, so the lamp has lost half its effect on that spot.
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
The widget uses those same numbers, 0.75 direct and 0.35 ambient:
05HBAO & GTAO
SSAO counts sample points that land inside geometry, which only loosely tracks the direction-by-direction integral in eq. 1, and a few random points per pixel give a noisy count. Two better AO methods, scoped. HBAO (horizon-based, Bavoil & Sainz) treats the depth buffer as a heightfield, a terrain whose elevation at each pixel is that pixel's depth, 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].
Worked example
March from a floor pixel toward a step 1 m away and 1 m tall, and the steepest angle up to geometry, the horizon, is atan(1 ÷ 1) = 45°: every direction in that slice lower than 45° above the floor is blocked. Move the step 2 m away and the horizon drops to atan(1 ÷ 2) = 27°, so less of the slice is blocked. A horizon method finds that angle in a few directions around the pixel and integrates the open part of each slice in closed form, instead of counting random points.
GTAO is still ambient occlusion: local, no light transport, no color bleeding. "Ground truth" means it matches a Monte-Carlo AO reference, a slow solve that averages many random rays, 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
Put a sunlit red wall next to a white floor and, in a photo, the floor beside it picks up a pink tint. With direct lighting plus AO it stays white. That's indirect bounce light, and the tint is color bleeding, a surface's color carried onto its neighbors. AO only removes ambient; it never transports light between surfaces.
Worked example
A red wall reflects 80% of red light and 10% of green and blue, so the light bouncing off it carries 8 times as much red as green. The white floor beside it receives that light and reflects it, tinted pink. A flat ambient constant adds the same gray to every pixel, and AO can only scale that gray down; neither can make one patch of floor redder than another. Each bounce also keeps at most the surface's albedo: light bouncing around a white room with albedo 0.8 keeps 80%, 64%, then 51% after one, two and three bounces, while in a dark room with albedo 0.3 it's 30%, then 9%.
Getting that pink right means solving the indirect part of the rendering equation, Kajiya's equation for the light leaving a point[9]. It adds up the incoming radiance (the light arriving from each direction) over the hemisphere, and that incoming radiance is itself the light leaving other surfaces:
In words: 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
Picking a GI technique means picking which failure you can live with: light that can't move, light that leaks through walls, bounce light that vanishes when its source leaves the screen, or noise. No real-time GI method is solved or universally best. A few terms first: a lightmap is a texture of light computed offline; a probe is a point in space that records the light arriving from every direction, often as spherical harmonics (SH), a handful of coefficients that store a smooth function of direction; and a signed distance field stores each point's distance to the nearest surface, so rays can step through it quickly. 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.
Worked example
A probe storing 9 SH coefficients per color channel holds 27 numbers, 108 bytes in 32-bit floats. A grid of 32 × 16 × 32 probes is 16,384 probes and 1.8 MB. The cost shows up as leaks: with probes every 2 m and a wall halfway between two of them, a floor point just behind the wall is 1 m from each, so plain linear interpolation gives it 50% of the lit-side probe's light.
The widget puts the red wall and the white floor side by side, with four ways to compute the bounce:
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.