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

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.

Time~55 min LevelSenior PrereqsHelpful, not required: the 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 · Rendering: into 3D Next · Post-Processing & Anti-Aliasing ▸
What you'll be able to do by the end

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:

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

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.

Three things AO is not

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.

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

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:

A floor point, its normal and its samples, with a gray ledge you can drag; the readout gives the occluded count and fraction. Drag the ledge toward and away from the point, sweep Radius, then switch to the full sphere. Green samples are open, red ones landed inside geometry. At the defaults 5 of the 20 samples land in the ledge (25%). Shrink the radius and the ledge falls out of reach, so its contact darkening vanishes; grow it and occluders farther away start to count, which is how a large radius darkens surfaces with geometry that isn't really touching them. Switch to the full sphere and 8 of the 20 samples (25 of 48 at the maximum count) land below the floor itself, counted as occluded with nothing nearby: that self-occlusion is the uniform gray cast the hemisphere orientation removes.

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

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.

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

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.

Applying AO to direct light is the classic bug

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.

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

The widget uses those same numbers, 0.75 direct and 0.35 ambient:

A floor seen from above, three boxes, and a light sweeping across them; the readout gives the bias and how bright the lit contact areas are. Toggle AO, sweep Bias from 0 to 60, then tick "apply to direct light". AO on adds contact darkening around each box's base, but only to the ambient term, so when the light passes over a box its contact ring lights up. At low bias the whole floor speckles as flat surfaces occlude themselves (acne); at high bias real occluders get rejected and the darkening washes out. Below a bias of 8 the readout flags acne, and above 42 it flags the wash-out. Flip "apply to direct light" and the rings stay dark even in full light: at the default bias the lit contact areas read about 70 to 78% bright with AO on the ambient term, and about 50% with the bug. AO belongs on the ambient term only.

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.

"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, 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:

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

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:

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; usually needs hardware RT; leaks reduced, not eliminated
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 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:

A white floor seen from above, a thin red wall in the middle, and a light moving up and down on its left. Switch between the four modes and watch the red on the floor. Flat ambient: no red bleed onto the floor, no indirect at all. Baked: the direct light still moves, but the bleed stays where the light was at bake time (hollow circle). Probes: the bleed is sampled at the probe dots and interpolated between them, so it follows the light but blurs, and the cells straddling the wall blend lit-side red onto the shadowed side (the readout gives the leak, about 10 to 20% of a full bleed depending on the light's height and the canvas width). Reference: bleed that follows the light and stays on the lit side, drawn analytically here as the answer an offline path tracer converges to. Each mode shows its characteristic compromise.

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

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 course notes. dl.acm.org. 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