Project overview
Implemented paper: Approximating Dynamic Global Illumination in Image Space - Ritschel, T., Grosch, T., & Seidel, H.-P. (2009).
This project extends the deferred PBR + IBL + SSAO pipeline from Project 4 by adding Screen-Space Directional Occlusion (SSDO), based on Ritschel et al. (2009). SSAO from the previous project approximates occlusion with a scalar visibility estimate multiplied onto the ambient term, which decouples visibility from the direction the light actually arrives from. SSDO removes that decoupling: it samples the hemisphere around each surface point, tests each direction for occlusion in screen space, and accumulates only the light that arrives from unoccluded directions.
The result captures directional environment occlusion, where surfaces occluded from bright env map directions are darkened proportionally to the energy they are blocked from receiving, and at the same time, through a second pass over the occluding samples, approximates one-bounce indirect illumination, where occluding surfaces re-emit their reflected radiance toward the receiver and produce color bleeding.
There are two rendering modes. Visibility mode computes the fraction of environment energy that reaches the surface and scales the SH diffuse term by it, which is analogous to a direction-aware version of SSAO. Radiance mode accumulates the actual environment radiance from each unoccluded direction and uses it directly, replacing the SH diffuse term entirely. Both modes support an optional indirect bounce pass on top.
Note: This project implements the Directional Occlusion and one-bounce Indirect Illumination (using environment map) from the paper. The screen-space shadow correction and Instant Radiosity hybrid extension are not implemented
Note: The Sponza model used in this project has known material issues and relies on bump maps, which the current pipeline does not support. As a result, the scene is rendered using vertex normals only, so the output may differ slightly from the typical Sponza appearance.
Architecture overview
Updated rendering pipeline
Four new passes are inserted between the SSAO stage and the IBL lighting pass:
────────────────────────────────────────────────────────────
RENDERING PIPELINE
─────────────────────────────────────────────────────────────
1. GEOMETRY PASS
↓
Render scene geometry to G-Buffer (same as Prj 1–4)
2. SSAO PASS (Project 4, unchanged)
↓
Raw SSAO → Bilateral blur
3. SSDO DIRECT PASS [NEW]
↓
Hemisphere sampling → screen-space visibility test
Output 0: visibility scalar
Output 1: direct environment radiance (Ldir)
→ Bilateral blur (both outputs simultaneously)
4. SSDO INDIRECT PASS [NEW]
↓
Re-use occluders from direct pass as secondary emitters
Output: one-bounce indirect radiance (Lind)
→ Bilateral blur
5. IBL LIGHTING PASS (Extended)
↓
Evaluate SH irradiance + GGX specular
Modulate diffuse by SSDO visibility or replace with Ldir + Lind
6. POST-PROCESSING
↓
Draw sky dome from active HDR map
Render UI (ImGui)
─────────────────────────────────────────────────────────────
Key components added
| Component | File | Purpose |
|---|---|---|
| SSDO Direct Shader | shaders/rawdirectssdo.frag | 3D hemisphere sampling, visibility test, environment radiance accumulation |
| SSDO Indirect Shader | shaders/rawindirectssdo.frag | One-bounce indirect: occluders as senders, form-factor transfer |
| SSDO Blur Shader | shaders/blurssdo.frag | Bilateral blur, shared for direct (MRT) and indirect (single channel) |
| SSDO Direct FBO | Scene.cpp | 3-attachment: visibility, radiance, (unused reserve) |
| SSDO Direct Blur FBO | Scene.cpp | 2-attachment: blurred visibility, blurred radiance |
| SSDO Indirect FBO | Scene.cpp | 1-attachment: raw indirect radiance |
| SSDO Indirect Blur FBO | Scene.cpp | 1-attachment: blurred indirect radiance |
The lighting shader (deferred_lighting.frag) is extended with a new ssdo uniform block that switches between the two rendering modes and optionally layers in the indirect term.
Debug visualization
Six new debug modes are added alongside those from Project 4:
| Mode | Visualization |
|---|---|
| Mode 11 | Raw SSDO visibility (grayscale, darker = more occluded) |
| Mode 12 | Blurred SSDO visibility |
| Mode 13 | Raw SSDO direct radiance (colored environment light from unoccluded directions) |
| Mode 14 | Blurred SSDO direct radiance |
| Mode 15 | Raw SSDO indirect radiance (one-bounce color bleeding) |
| Mode 16 | Blurred SSDO indirect radiance |
Modes 11 and 13 side by side show where SSDO departs from SSAO: mode 11 is a greyscale occlusion mask like SSAO, while mode 13 carries full color from the environment map. Crevices shadowed from a warm sky appear cool, surfaces open to a blue region appear blue, and so on.
SSDO algorithm
How SSDO differs from SSAO
SSAO (Project 4) decouples visibility from illumination. It computes a scalar obscurance value S by counting how many nearby samples are above the surface, then multiplies the full ambient term by the resulting AO factor A. The direction from which light arrives is entirely ignored, so occluded areas darken equally whether the blocked directions contain bright or dim, warm or cool light.
SSDO couples visibility and illumination at the sample level. For each of the N sampled hemisphere directions, it evaluates both the environment light arriving from that direction and whether that direction is blocked. Only unblocked directions contribute to the direct radiance sum. An area occluded from a bright warm sky hemisphere but open toward a cool shaded area therefore appears cool rather than uniformly dark: the color of the shadow matches the missing light source.
The paper’s formulation for the direct radiance at point P:
Ldir(P) = Σ (ρ/π) · Lin(ωi) · V(ωi) · cos(θi) · Δω
where Lin(ωi) is the incoming radiance from direction ωi, V(ωi) is 1 if that direction is unoccluded and 0 otherwise, and Δω = 2π/N is the solid angle per sample. This implementation evaluates the sum directly in screen space.
Parameters
struct SSDO {
float radius = 0.1f; // World-space hemisphere radius
int numSamples = 35; // Hemisphere sample count
float contrast = 1.0f; // Visibility sharpness exponent
float lod = 8.5f; // Env map mip level for direct light sampling
bool enable = false;
int doMethod = 0; // 0 = visibility mode, 1 = radiance mode
int blurRadius = 5;
float blurVariance = 0.01f;
// Indirect bounce
bool enableIndirect = false;
float indirectRadius = 0.1f;
float distanceClamp = 0.25f; // Min distance for 1/d² (singularity guard)
float strength = 1.0f; // Indirect intensity scale
int indirectBlurRadius = 5;
};
Direct SSDO pass (rawdirectssdo.frag)
3D hemisphere sampling
Unlike SSAO, which distributes samples in a 2D screen-space spiral, SSDO samples directions on the 3D hemisphere aligned to the surface normal. A tangent frame (T, B, N) is constructed by choosing an auxiliary up vector and taking cross products:
vec3 up = abs(N.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
vec3 T = normalize(cross(up, N));
vec3 B = cross(N, T);
Sample directions are then generated with a stratified elevation + golden-ratio azimuthal layout. The elevation parameter cosElevation = 1 - alpha concentrates samples toward the pole (where cos θ is largest and projected solid angle is greatest), giving an area-proportional distribution over the hemisphere:
float alpha = (float(index) + 0.5) / float(total);
float cosElevation = 1.0 - alpha;
float sinElevation = sqrt(1.0 - cosElevation * cosElevation);
float theta = 2.0 * PI * alpha * (7.0 * float(total) / 9.0) + phi;
vec3 localDir = vec3(sinElevation * cos(theta),
sinElevation * sin(theta),
cosElevation);
// Lift into world space via TBN
return normalize(T * localDir.x + B * localDir.y + N * localDir.z);
The per-pixel rotation phi uses Interleaved Gradient Noise (same as SSAO) to break up the regular pattern across screen space without requiring a texture lookup:
float phi = PI * fract(52.9829189 * fract(0.06711056 * gl_FragCoord.x
+ 0.00583715 * gl_FragCoord.y));
Each sample ray is given a random length λ = R × hash(gl_FragCoord.xy + i) within the hemisphere radius, so the sample point P + sampleDir × λ is jittered in depth. This avoids coherent banding at the cost of some noise that the bilateral blur later removes.
Screen-space visibility test
The 3D sample point is projected back into screen space by multiplying through the camera view and projection matrices:
vec4 clipPos = projection * view * vec4(samplePoint, 1.0);
vec2 sampleUV = (clipPos.xy / clipPos.w) * 0.5 + 0.5;
vec3 surfacePos = texture(gPosition, sampleUV).xyz;
A sample is classified as visible (not occluded) in three cases:
- Its UV falls outside
[0, 1]², so no geometry is recorded at that screen location (open sky). - The G-Buffer normal at
sampleUVis zero, a background pixel. - The sample point’s view-space distance from the camera is ≤ the G-Buffer surface distance at
sampleUV, so the sample sits in front of or on the recorded surface.
Otherwise the sample is behind the recorded surface and is classified as an occluder. This is the same single-depth-layer approximation described in the paper: geometry hidden from the camera cannot contribute, and thin surfaces may cause false occlusion at their silhouettes.
float sampleDist = length(samplePoint - viewPos);
float surfaceDist = length(surfacePos - viewPos);
bool visible = (sampleDist <= surfaceDist);
Environment light weighting
The environment map is sampled at a user-controlled mip level (lod, default ~8.5) to retrieve a pre-blurred, diffuse-like radiance estimate for each hemisphere direction. The weight for each sample combines the environment luminance with the cosine foreshortening term:
float cosTheta = max(0.0, dot(N, sampleDir));
vec3 Lin = textureLod(envMap, uvOfW(sampleDir), lod).rgb;
float weight = dot(Lin, vec3(0.2126, 0.7152, 0.0722)) * cosTheta;
Using a blurred mip level avoids high-frequency noise from sharp features in the environment map (e.g. the sun disc) propagating into the SSDO signal at low sample counts.
Visibility and radiance accumulation
Both quantities are accumulated simultaneously over the N samples:
totalLight += weight; // full hemisphere budget
visibleLight += weight; // only if sample is visible
Ldir += (1.0 / PI) * Lin * cosTheta * deltaOmega; // only if visible
where deltaOmega = 2π / N. At the end of the loop:
float visibility = totalLight > 0.0 ? visibleLight / totalLight : 1.0;
visibility = clamp(pow(visibility, contrast), 0.0, 1.0);
visibility is the ratio of environment energy arriving from unoccluded directions to the total environment energy in the hemisphere, weighted by cosine and environment brightness. The contrast exponent sharpens the transition between occluded and open regions.
Ldir is the accumulated environment radiance from all unoccluded directions, where each visible sample contributes (1/π) · Lin · cosθ · Δω. The output is an RGB irradiance estimate rather than a scalar: occluded directions contribute nothing, so the result darkens in a way that reflects the color of the blocked light.
The pass writes two outputs via MRT:
layout(location = 0) out vec4 FragVisibility; // (visibility, -)
layout(location = 1) out vec4 FragRadiance; // Ldir (rgb)
FragVisibility = vec4(vec3(visibility), 1.0);
FragRadiance = vec4(Ldir, 1.0);
Effect of sample count (n)
At low sample counts the radiance output is noticeably noisy, since the environment is undersampled and individual sample directions dominate. Increasing n smooths the accumulated radiance toward the correct hemisphere integral. The bilateral blur suppresses most of the noise at n = 35, which is the default.
Effect of sampling radius (R)
A small radius restricts occlusion to tight contact zones. A larger radius allows distant geometry to block light, producing broader soft shadows. Unlike SSAO where a large radius can pick up false occlusion from unrelated background geometry, SSDO’s environment-weighted accumulation makes large-radius results more physically meaningful, since the question it asks is whether distant geometry blocks light from a particular part of the sky.
Effect of LOD
At LOD 0, each sample fetches the full-resolution environment map. Sharp features like the sun disc produce high-frequency variation across sample directions, creating noise that the blur cannot fully remove. At LOD ~8 to 10, the environment is smooth enough that each sample represents a well-averaged solid angle region and the result is stable. Very high LOD averages too broadly and loses directional color variation.
Bilateral SSDO blur (blurssdo.frag)
The direct pass is denoised with the same joint bilateral filter structure used in Project 4, extended to handle two maps at once in a single render pass via MRT (blurMode = 1 for direct, blurMode = 0 for indirect):
for (int x = -radius; x <= radius; x++) {
for (int y = -radius; y <= radius; y++) {
vec3 Ni = texture(gNormal, sampleCoords).xyz;
float di = texture(gPosition, sampleCoords).w;
float spatial = gaussianWeights[abs(x)] * gaussianWeights[abs(y)];
float normalWeight = max(0.0, dot(N, Ni));
float depthWeight = exp(-(d - di) * (d - di) / (2.0 * s));
float W = spatial * normalWeight * depthWeight;
result0 += texture(inputMap0, sampleCoords).rgb * W; // visibility or indirect
result1 += texture(inputMap1, sampleCoords).rgb * W; // radiance (direct mode only)
totalWeight += W;
}
}
The three-component weight (spatial Gaussian × normal similarity × depth Gaussian) prevents the blur from crossing depth edges (sharp contact boundaries) or normal edges (surface orientation discontinuities). Both the visibility and radiance maps are blurred with the same weights in a single pass, keeping them spatially consistent with each other.
Indirect SSDO pass (rawindirectssdo.frag)
Concept
Every sample that the direct pass classified as an occluder blocked incoming light from a particular direction. That occluding surface received direct environment illumination (captured in blurSSDORadianceMap) and re-emits it as a secondary light source. The indirect pass collects these contributions and computes the approximate one-bounce color bleeding they produce at the receiver.
This is the screen-space approximation of the radiosity kernel between a small sender patch and a receiver point. The paper defines it as:
Lind(P) = Σ (ρ/π) · Lpixel · (1 - V(ωi)) · As · cos(θs) · cos(θr) / d²
where Lpixel is the direct radiance at the occluder, As is the sender patch area, θs is the angle between the sender normal and the direction toward the receiver, θr is the angle between the receiver normal and the direction toward the sender, and d is the distance between them.
Implementation
The indirect pass reuses the same hemisphere sampling loop as the direct pass. For each sample classified as an occluder:
// Sender direct radiance, modulated by sender albedo (Lambertian re-emission)
vec3 Lpixel = texture(ssdoRadMap, sampleUV).rgb;
vec3 senderAlbedo = texture(gAlbedo, sampleUV).rgb;
Lpixel *= senderAlbedo;
vec3 dir = surfaceToP / actualDist; // sender → receiver direction
float cosTheta_s = max(0.0, dot(surfaceNormal, dir)); // sender faces toward P
float cosTheta_r = max(0.0, dot(N, -dir)); // receiver faces toward sender
float falloff = max(0.0, 1.0 - actualDist / R); // linear attenuation at radius edge
float d = max(dClamp, actualDist); // clamp prevents 1/d² singularity
float As = PI * R * R / float(n); // approx. patch area (flat hemisphere / N)
Lind += (1.0 / PI) * Lpixel * As * cosTheta_s * cosTheta_r * strength * falloff / (d * d);
The ssdoRadMap input is the blurred radiance from the direct pass, not the raw output. Blurring first gives each occluder a spatially coherent estimate of its incoming light before it acts as a secondary emitter, which keeps single noisy pixels from dominating the illumination of nearby surfaces.
Multiplying by senderAlbedo converts the incoming radiance to exitant radiance under Lambertian re-emission, so the indirect contribution correctly inherits the color of the occluding surface.
The falloff term linearly ramps the contribution to zero at the hemisphere boundary R, preventing a hard cutoff in the indirect field and matching the smooth fade described in the paper (Figure 4).
The dClamp parameter (distanceClamp, default 0.25) sets the minimum effective distance for the 1/d² term, preventing singularities when the receiver and sender are very close.
Effect of indirect radius and strength
The indirect radius controls how far senders can be from the receiver to contribute. A small radius restricts color bleeding to immediate contact zones; a larger radius allows far walls and ceilings to bounce light across the space.
strength is a linear scale on the entire indirect term, providing artistic control over the intensity of the color bleeding without affecting the form-factor computation.
Integration into the lighting pass
The lighting shader (deferred_lighting.frag) branches on ssdo.doMethod and ssdo.enableIndirect to select how SSDO modulates the final color. The SSDO path sits inside the CalculatePBRLightIBL function, replacing or modulating the SH diffuse term:
if (ssdo.enable) {
vec3 Lind = vec3(0.0);
if (ssdo.enableIndirect) {
Lind = texture(blurindirectSSDOMap, TexCoords).rgb;
}
if (ssdo.doMethod == 0) {
// Visibility mode: scale SH diffuse by visibility fraction
float visibility = texture(blurSSDOVisibilityMap, TexCoords).r;
return kD * ((diffuse * visibility) + (albedo * Lind)) + specColor;
} else {
// Radiance mode: replace SH diffuse with accumulated environment radiance
vec3 Ldir = texture(blurSSDORadianceMap, TexCoords).rgb;
return kD * albedo * (Ldir + Lind) + specColor;
}
}
Visibility mode (Method 0): the SH diffuse term from Project 3 is multiplied by the SSDO visibility scalar. Open areas (visibility ≈ 1) are unaffected. Occluded areas darken proportional to how much environment energy is blocked. Indirect is added on top, scaled by albedo. The SH term provides the base color; SSDO removes the fraction that cannot reach the surface.
Radiance mode (Method 1): the SH diffuse term is bypassed entirely. Only the environment radiance that was directly accumulated from visible hemisphere directions contributes. This is more physically correct, since the surface receives exactly what arrives from unoccluded directions, but it requires more samples to be stable because the full environment is being importance-reconstructed rather than approximated with SH coefficients.
In both modes, the specular IBL term from Project 3 (GGX importance sampling) is unaffected by SSDO. SSDO modulates only the diffuse component.
Scene walkthrough
The walkthrough has two parts. The first shows the scene with IBL only, where the directional occlusion shows up as contact darkening and environment-weighted occlusion. The second adds depth with a directional light overhead, simulating the sun shining into Sponza, with IBL scaled down to fill in as the ambient term.
The ambient term is scaled down to make the indirect bounce contribution easier to see. Indirect illumination is physically a mild effect, most perceptible against darker shadowed regions, so reducing the ambient term increases that contrast and makes the color bleeding from nearby surfaces visible in the video. In a physically calibrated scene the ambient scale would be tuned to match the actual indirect lighting contribution of the environment.
The indirect bounce Range and Strength parameters have been intentionally pushed higher than typical production values to make the effect clearly visible in video. IBL and tone mapping are both active, and video compression tends to wash out subtle lighting differences, so the exaggerated values help the effect read on screen.
The opening walkthrough shows SSDO against IBL alone, with no directional light and no shadows. Even so, the scene reads more grounded: contact darkening separates the hanging plants from the wall behind them, the curtains from the floor beneath, and the drapes from the architecture they hang against.
Directional occlusion
The lion’s mane creates a well-defined cavity that makes the toggle easy to read. With SSDO off, the IBL treats the recess as fully exposed sky, flattening the depth. With SSDO on, the cavity darkens in proportion to hemisphere blockage, restoring the layered geometry.
The drapes show the same contact darkening at a larger scale. Without SSDO, the IBL illuminates drape and wall equally, collapsing the gap between them. With SSDO, the seam darkens continuously, anchoring the fabric to the surface. The same effect carries through to the curtains: crevice folds deepen, the base beneath the hem shadows, and the plant pots gain the ground contact that places them on the floor.
Indirect light single bounce
Adding a sun-direction directional light with shadows creates the contrast that makes the indirect bounce perceptible: the color contribution reads better where it falls on surfaces already in shadow.
The colored curtains scatter their warm and cool tones onto the stone floor and nearby pillars. The bleed is most legible on the shadowed side of the nave, where the absence of direct light lets the indirect contribution dominate the surface color.
The rear of the nave receives the least direct light, which amplifies the indirect contribution. The curtains here bleed onto the surrounding arches, pillars, and floor at once, the same effect as the front but more prominent against the darker baseline.
On the upper floor, the drapes mirror what the curtains do below: their reflected color bleeds onto the floor above where they terminate, producing the same soft falloff one story up.
The final walkthrough has everything on: directional light, IBL as ambient fill, SSDO, and single-bounce indirect. SSDO grounds objects through contact darkening, which reads as depth, and the indirect bounce carries color from lit surfaces into shadow.
UI and debug controls
SSDO controls
| Control | Range | Default | Effect |
|---|---|---|---|
| Enable SSDO | Toggle | Off | Activates all SSDO passes and lighting integration |
| DO Method | Radio: Visibility / Radiance | Visibility | Switches between the two lighting integration modes |
| SSDO Range | 0.01 to 0.5 | 0.05 | Hemisphere radius for direct occlusion sampling |
| SSDO Env Map LOD | 0.0 to 15.0 | 8.5 | Mip level for environment lookup: higher = smoother, less noise |
| SSDO Contrast | 1.0 to 10.0 | 1.0 | Power exponent applied to visibility ratio |
| Num Sample Points | 10 to 100 | 35 | Hemisphere sample count: quality vs. performance |
| Direct Blur Radius | 1 to 10 | 5 | Bilateral blur kernel half-width for direct maps |
| Enable SSDO Indirect | Toggle | Off | Activates the one-bounce indirect pass |
| SSDO GI Range | 0.1 to 5.0 | 0.7 | Hemisphere radius for indirect sender search |
| SSDO GI d clamp | 0.1 to 1.0 | 0.25 | Minimum sender distance (prevents 1/d² singularity) |
| SSDO GI Strength | 1.0 to 50.0 | 7.0 | Linear scale on indirect radiance contribution |
| Indirect Blur Radius | 1 to 10 | 5 | Bilateral blur kernel half-width for indirect map |
File structure
src/
├── shaders/
│ ├── rawdirectssdo.frag # Direct SSDO: 3D hemisphere, visibility test, MRT output
│ ├── rawindirectssdo.frag # Indirect SSDO: form-factor bounce from occluders
│ ├── blurssdo.frag # Bilateral blur: handles both direct (MRT) and indirect
│ ├── rawssao.frag # Alchemy SSAO (unchanged from Prj 4)
│ ├── blurssao.frag # SSAO bilateral blur (unchanged from Prj 4)
│ ├── deferred_lighting.frag # Extended: SSDO visibility/radiance integration
│ ├── deferred_lighting_debug.frag # Extended: debug modes 11–16 for SSDO buffers
│ ├── gbuffer.vert / gbuffer.frag # Geometry pass (unchanged)
│ ├── skydome.vert / skydome.frag # HDR sky dome rendering (unchanged)
│ └── ...
│
├── include/
│ ├── Scene.h # SSDO struct, 4 new FBO members, blur weight arrays
│ ├── SHIrradiance.h # SH coefficient precomputation (unchanged)
│ ├── HammersleyRandomGenerator.h # Hammersley UBO for IBL (unchanged)
│ └── ...
│
├── Scene.cpp # SSDODirectPass(), BlurSSDODirect(),
│ # SSDOIndirectPass(), BlurSSDOIndirect(),
│ # UpdateSSDOBlurWeights(), DrawSSDOSection()
└── Main.cpp # Application entry point (unchanged)
Conclusion
This project implemented Screen-Space Directional Occlusion following Ritschel et al. (2009), covering the direct occlusion pass and one-bounce indirect illumination. The algorithm couples visibility with illumination at the sample level: for each sampled hemisphere direction, it tests for screen-space occlusion and accumulates only the environment light arriving from unblocked directions, weighted by the actual color and intensity of the incoming radiance. The indirect pass then treats each occluding surface as a small re-emitting patch, approximating one-bounce radiance transfer based on relative positions and normals to produce color bleeding from nearby geometry.
Because occlusion is evaluated per-direction, surfaces blocked from bright sky directions receive proportionally less energy from those directions, producing darkening that varies with the environment. Surfaces adjacent to colored geometry additionally pick up a tint through the indirect pass. The bilateral blur, extended to process both the visibility and radiance outputs simultaneously in MRT mode, suppresses hemisphere sampling noise while preserving sharp geometry boundaries through depth and normal weighting.
The primary limitations stem from the screen-space representation: geometry not visible to the camera cannot contribute occlusion or indirect light, and the single-depth-layer visibility test can misclassify samples near silhouette edges. These are the standard tradeoffs of any screen-space occlusion technique, as acknowledged in the original paper.
References
- Ritschel, T., Grosch, T., & Seidel, H.-P. (2009). Approximating Dynamic Global Illumination in Image Space. ACM SIGGRAPH Symposium on Interactive 3D Graphics and Games (I3D)
- McGuire, M., Osman, B., Bukowski, M., & Hennessy, P. (2011). The Alchemy Screen-Space Ambient Obscurance Algorithm. HPG