Project overview
This project extends the deferred PBR + IBL pipeline from Project 3 by adding screen space ambient occlusion (SSAO) as a post-processing stage. SSAO approximates the reduction in ambient light reaching a surface point due to nearby occluders, darkening crevices, corners, and contact shadows that the IBL irradiance integral alone cannot capture.
The implementation is based on the Alchemy SSAO algorithm (McGuire et al., 2011). For each fragment, n sample points are distributed in a spiral around the fragment in screen space. Each sample is lifted to world space via the G-Buffer position texture and tested against the surface normal to estimate the solid-angle obscurance above the surface. Samples that project above the surface plane contribute occlusion weighted by their proximity and angle. The raw per-pixel occlusion is then denoised with an edge-aware bilateral filter that preserves depth and normal discontinuities while smoothing sampling noise. The final AO value is multiplied onto the IBL result in the lighting pass, darkening ambient contributions in geometrically enclosed areas without affecting direct specular highlights.
Architecture
Updated rendering pipeline
Two new passes are inserted between the geometry pass and the IBL lighting pass:
────────────────────────────────────────────────────────────
RENDERING PIPELINE
─────────────────────────────────────────────────────────────
1. GEOMETRY PASS
↓
Render scene geometry to G-Buffer (Same as Prj 1, 2, 3)
2. SSAO PASS [NEW]
↓
Raw SSAO: Alchemy SSAO solid-angle obscurance per pixel
Bilateral Blur: edge-aware denoising
3. IBL LIGHTING PASS (Extended)
↓
Evaluate SH irradiance + GGX importance sampling
Multiply by AO value from blurred SSAO map
4. POST-PROCESSING
↓
Draw sky dome from active HDR map
Render UI (ImGui)
─────────────────────────────────────────────────────────────
Components added
| Component | File | Purpose |
|---|---|---|
| Raw SSAO shader | shaders/rawssao.frag | Alchemy SSAO solid-angle obscurance computation per pixel |
| Bilateral blur shader | shaders/blurssao.frag | Edge-aware denoising of raw SSAO output |
| Raw SSAO framebuffer | Scene.cpp (SSAOPass) | Single-channel render target for raw occlusion |
| Blurred SSAO framebuffer | Scene.cpp (SSAOPass) | Single-channel render target for filtered AO |
| Gaussian weight CPU | Scene.cpp::UpdateSSAOBlurWeights() | Precompute 1D Gaussian weights for the blur kernel |
The IBL lighting shader (deferred_lighting.frag) is extended with a conditional AO multiply at the end of the lighting evaluation, with no changes to the BRDF or IBL loops themselves.
Debug visualization
Two new modes are added to the existing debug menu, to inspect the AO signal before and after filtering:
| Mode | Visualization |
|---|---|
| Mode 9 | Raw SSAO map (grayscale, darker = occluded) |
| Mode 10 | Blurred SSAO map (grayscale) |
They separate filtering artifacts from sampling noise, and let the blur parameters be tuned independently of the full lit scene.
Framebuffer setup
Both SSAO render targets are single-channel (red-only) full-resolution framebuffers with no depth attachment:
m_rawSSAOBuffer = std::make_unique<FrameBuffer>();
m_rawSSAOBuffer->init(width, height, 1, false); // 1 channel, no depth
m_blurSSAOBuffer = std::make_unique<FrameBuffer>();
m_blurSSAOBuffer->init(width, height, 1, false);
Rendering both at full resolution ensures the AO edges align precisely with the G-Buffer geometry. A half-resolution AO map would introduce misaligned halos at silhouette edges.
Scene setup
Two setups are used to evaluate SSAO.
The first is an indoor scene of PBR models arranged across shelves, tables, and the floor: statues, furniture, tools, and decorative objects, spanning materials from aged bronze and rough marble to metal, plastic, leather, and wood. It puts SSAO in a realistic context where multiple objects share a space, occlude each other, and sit on surfaces. The camera is farther from any individual model, and tone mapping compresses the luminance range and reduces the perceived contrast of the AO signal, so higher scale and contrast values (approx R = 0.05, scale = 2.0, contrast = 3.0) are needed to make the effect visible at scene scale.
The second is a per-model inspection setup where each model is isolated and viewed up close. The camera is much nearer to the geometry, so the SSAO parameters are dialed back (R ≈ 0.08, scale ≈ 1.7, contrast ≈ 2.2) to keep the effect from becoming overbearing. It shows how SSAO responds to specific surface features: crevices, layered components, contact areas, and fine detail.
At full scene scale SSAO is intentionally subtle: open surfaces and model tops see little to no darkening because they are not geometrically enclosed. The effect is most legible when toggling it on and off while panning across the scene, or when zooming into the denser areas of the scene.
SSAO algorithm
Parameters
struct SSAO {
float R = 0.1f; // World-space sampling radius
int n = 15; // Number of spiral sample points
float bias = 0.001f; // Bias to prevent self-occlusion
float scale = 1.0f; // Occlusion intensity multiplier
float contrast = 1.0f; // Power exponent applied to final AO
bool enable = false;
bool enableBlur = false;
int blurRadius = 2;
float blurVariance = 0.01f;
};
Raw occlusion pass (rawssao.frag)
For each screen-space fragment, n sample points are distributed in a logarithmic-spiral pattern around the fragment. Each sample is fetched from the G-Buffer, and the vector from the current surface point to the sample position is tested against the surface normal. Samples above the horizon (positive normal dot product within radius R) contribute occlusion weighted by their angle and proximity. The accumulated solid-angle occlusion is scaled and raised to a power to produce the final AO value in [0, 1].
Sample distribution
Samples are distributed using stratified random placement along a spiral:
// Per-fragment pesudo-random phase angle
int phi = (( 30 * x_prime) ^ y_prime) + (10 * x_prime * y_prime);
for (int i = 0; i < n; i++) {
float alpha = float(i + 0.5) / float(n); // Stratified position [0, 1]
// Screen-space radius scaled inversely with depth for perspective correction
float h = alpha * R / d;
// Spiral angle: quasi-golden-ratio multiplier reduces banding
float theta = 2.0 * PI * alpha * (7.0 * float(n) / 9.0) + float(phi);
vec2 samplePos = vec2(x, y) + vec2(cos(theta), sin(theta)) * h;
}
The angle multiplier 7n/9 distributes samples uniformly around the circle without regular banding. The phi hash gives neighbouring fragments different starting angles, which breaks up the spiral pattern across screen space. Dividing by depth d applies a perspective correction: the screen-space disc covers a constant world-space radius R regardless of the fragment’s depth.
Effect of sample count (n)
At low sample counts (n = 10), the spiral banding in the raw AO map is visible as arc-shaped artifacts in the grayscale debug view. Increasing to n = 30 produces a smoother raw result at the cost of more shader work per pixel. With bilateral blur enabled, the banding is largely suppressed even at n = 15.
Solid-angle obscurance accumulation
float c = 0.1 * R; // Minimum separation distance
for (int i = 0; i < n; i++) {
// ... (sample position computed above)
vec3 Pi = texture(gPosition, samplePos).xyz;
vec3 omega = Pi - P; // Vector to sample
// Radius mask: discard samples outside radius R
float H = step(0.0, R - length(omega));
// Occlusion: projected solid angle above surface
float num = max(0.0, dot(N, omega) - bias) * H;
float denom = max(c * c, dot(omega, omega));
S += (2.0 * PI * c / float(n)) * (num / denom);
}
// Scale and contrast
float A = pow(max(0.0, 1.0 - scale * S), contrast);
The numerator dot(N, omega) measures how far above the surface horizon the sample sits. The denominator dot(omega, omega) gives inverse-square falloff so distant samples within the radius contribute less. The small constant c² = (0.1R)² in the denominator clamps the weight for samples at near-zero separation, preventing self-occlusion spikes. The bias offset lifts the horizon slightly to avoid false occlusion on planar surfaces caused by floating-point imprecision.
Effect of sampling radius (R)
A small radius (R = 0.01) darkens only tight crevices and contact points directly at the surface. A larger radius (R = 0.1) extends the influence to broader geometric cavities, such as the area under the lion head’s mane or between the horse’s legs, but can also pick up false occlusion from distant background surfaces that are not true occluders in world space.
Effect of scale and contrast
scale and contrast act on the final accumulated occlusion value S before it is output as the AO factor:
float A = pow(max(0.0, 1.0 - scale * S), contrast);
Scale is a linear multiplier on the raw solid-angle sum. At scale = 1.0 the accumulated occlusion is taken as-is; pushing it to 5.0 amplifies even weakly enclosed areas into dark crevices. The effect is global: every occluded fragment darkens proportionally, so increasing scale on a flat surface eventually produces false darkening even on open geometry.
Contrast applies a power curve after the scale step. With contrast = 1.0 the mapping is linear; values above 1 push mid-grey occlusion values toward black while leaving fully open areas (AO = 1) untouched. That darkens the crevices further without affecting flat surfaces: it narrows the transition band between occluded and unoccluded regions instead of uniformly darkening everything.
Scale controls how much occlusion is detected; contrast controls how sharply it maps to darkness.
Mathematical summary
| Symbol | Meaning |
|---|---|
R | World-space search radius |
n | Number of spiral samples |
d | Fragment depth (world space) |
h = αR/d | Screen-space sample radius at stratum α |
θ = 2πα(7n/9) + φ | Spiral angle with random phase |
c = 0.1R | Near-clamp distance |
H = step(0, R - ‖ω‖) | Radius mask (0 outside radius) |
S += (2πc/n) · max(0, N·ω − bias)·H / max(c², ‖ω‖²) | Occlusion accumulation |
A = (max(0, 1 − s·S))^k | Final AO with scale s and contrast k |
Bilateral blur
Purpose
The raw SSAO output has per-pixel noise from the randomized sampling pattern. A naive box or Gaussian blur would smear AO across depth and normal discontinuities, blurring the sharp contact shadow at an object boundary into the background. The bilateral filter preserves these edges by weighting each blur tap by its depth and normal similarity to the center fragment.
Algorithm (blurssao.frag)
for (int x = -radius; x <= radius; x++) {
for (int y = -radius; y <= radius; y++) {
vec2 sampleCoords = TexCoords + vec2(x, y) * texelSize;
float aoSample = texture(ssaoMap, sampleCoords).r;
vec3 Ni = texture(gNormal, sampleCoords).xyz;
float di = texture(gPosition, sampleCoords).w;
// Spatial Gaussian (depends only on kernel offset position)
float spatial = gaussianWeights[abs(x)] * gaussianWeights[abs(y)];
// Normal similarity: penalize samples with very different normals
float normalWeight = max(0.0, dot(N, Ni));
// Depth similarity: Gaussian falloff based on depth difference
float depthDiff = d - di;
float depthWeight = exp(-(depthDiff * depthDiff) / (2.0 * s));
float W = spatial * normalWeight * depthWeight;
result += aoSample * W;
totalWeight += W;
}
}
FragColor = vec4(vec3(result / totalWeight), 1.0);
The three weights multiply together:
- Spatial (Gaussian): a bell-curve falloff over the kernel, so closer taps contribute more than edge taps regardless of scene content.
- Normal weight
max(0, N·Ni): drops to zero for samples whose surface normal differs by more than 90°, which keeps the blur from crossing object boundaries where normals flip. - Depth weight
exp(-Δd² / 2σ²): Gaussian falloff on the depth difference. The varianceσ(exposed asblurVariance) controls how sensitive the filter is to depth edges; smaller values preserve edges more sharply.
Effect of blur parameters
With blur disabled, the raw AO map shows per-pixel noise from the randomized spiral phase. Enabling the bilateral filter with radius = 2, sigma = 0.01 smooths the noise while keeping sharp edges at depth discontinuities: the silhouette of the model against the background remains clean. Increasing blur radius or sigma softens the result further at the cost of edge leakage at very tight depth transitions.
Gaussian weight precomputation (Scene.cpp)
The 1D spatial weights are computed on the CPU whenever blurRadius changes and uploaded as a uniform array:
void Scene::UpdateSSAOBlurWeights() {
m_ssaoBlurWeights.clear();
float s = static_cast<float>(m_ssao.blurRadius) / 2.0f;
float sum = 0.0f;
for (int i = 0; i <= m_ssao.blurRadius; i++) {
float kw = exp(-0.5f * pow(static_cast<float>(i) / s, 2));
m_ssaoBlurWeights.push_back(kw);
sum += kw;
}
// Normalize accounting for symmetry: weights[0] contributes once, rest twice
sum = 2.0f * sum - m_ssaoBlurWeights[0];
for (auto& w : m_ssaoBlurWeights)
w /= sum;
}
Using s = radius / 2 sets the standard deviation to half the kernel radius so the weights fall to approximately exp(-2) ≈ 0.135 at the kernel edge, a standard rule of thumb for Gaussian kernels.
Integration into the lighting pass
Application (deferred_lighting.frag)
After the full IBL evaluation (SH diffuse + GGX importance-sampled specular), the AO value is applied as a multiplicative factor:
if (ssao.enable) {
float ao = ssao.enableBlur
? texture(blurredssaoMap, TexCoords).r
: texture(ssaoMap, TexCoords).r;
color = color * ao;
}
Multiplicative blending is the standard approach: ao = 1 in fully open areas leaves the IBL result unchanged; ao = 0 in fully enclosed areas drives the output to black. The toggle between raw and blurred maps allows direct comparison at runtime.
Close up look at specific scene sections
At the center of the scene, the statues on the table have ambiguous placement on the surface without SSAO. Enabling it grounds them and brings out the depth of their crevices: the eye socket and nose curvature of the bust, the crevices of the horse statue, and the occluded underside of the marble bust.
The office chair behind the table has many crevices that, without SSAO, carry an odd color tint. SSAO removes that tint by darkening the recesses, which also improves perceived depth. The swivel base changes as well: the curved sections pick up occlusion where they meet the floor, seating the chair on it.
The chairs in front of the table have less floor contact than the office chair, yet SSAO still eliminates their floaty appearance and darkens the crevices where the seat meets the frame.
Moving right to the tea set area, SSAO adds occlusion to the teapot’s curvature, under its lid, between the cups and saucers, and between the teapot and the table. The square depressions in the chair seats nearby are also more clearly defined.
The two potted plants are grounded to the floor as expected. The larger change is on the pots themselves: the engraved details, stacked rims, and surface gravel all become more pronounced with SSAO enabled.
The wooden crate has end-to-end crevices and rope handles. SSAO darkens those crevices strongly, adding depth to what would otherwise read as a flat surface. The rope handles look recessed into the crate instead of pasted on.
On the top storage shelf, SSAO anchors the tools to the surface and brings out depth in the metallic components of the hand tool and tool chest.
The second shelf holds a cash register and a metal pipe lamp. The pipe joints darken cleanly, and the joints and recessed areas of the cash register follow suit. Both objects gain solidity and lose their flat appearance.
On the bottom shelf, a wooden bowl with a wide contact surface and a Chinese lantern with small feet both benefit from SSAO, despite the difference in contact area. In each case, the ambiguity of whether the object is resting on the shelf or floating above it is resolved.
Across all the shelves, the crevice between the shelf boards and the frame consistently darkens.
Per-model inspection
Each model is previewed in isolation with SSAO parameters adjusted for close-up viewing (R ≈ 0.08, scale ≈ 1.7, contrast ≈ 2.2).
Marble bust
The marble bust has a wide variety of surface features: hair crevices, eye sockets, and nose curvature all pick up strong obscurance. Deep cuts and scratches in the stone surface darken as well. The base follows the same pattern, with crevice darkening that reinforces the sense of volume.
Horse statue
The horse’s dense mane benefits the most: without SSAO the strands blend together, while enabling it adds clear definition between them. Body wrinkles and the eye and nose sockets also become more pronounced. The flat, thick base sees minimal change compared to the marble bust’s multi-layered base, which is expected given the reduced geometric complexity there.
Vintage video camera
This is one of the clearer cases in the set. The top metal ridges and lens rims gain depth, and the largest change is on the side plate: without SSAO it reads as a flat surface unless viewed from a specific angle. Enabling it reveals the multi-layered structure, which makes the stacked plates legible from any viewpoint.
Cassette player
The front speaker grille shows the clearest change: the holes darken enough that the grille reads as perforated and recessed, with a speaker cavity behind it. The effect is more pronounced here than on most other models, given the density and regularity of the grille geometry.
Cash register
The cash register has a high density of buttons and recessed elements, the amount display in particular, that benefit from SSAO. Without it the surface reads somewhat flat; enabling it brings out the three-dimensional relief of the controls and panel divisions.
Chinese lantern
The lantern looks good without SSAO, but enabling it adds a soft transition along the edges where the paper panels meet the wood frame, pushing the paper visually inward. The effect is especially clear at the top, where the depth relationship between the paper and the surrounding frame becomes unambiguous.
Hand plane tool
Like the video camera, the hand plane is a strong case for SSAO. Its top surface consists of three stacked layers, but without SSAO the intermediate layer is easy to miss. Enabling it makes the layering apparent: the components read as physically stacked instead of merged into a single surface.
UI and debug controls
SSAO controls
| Control | Range | Default | Effect |
|---|---|---|---|
| Enable SSAO | Toggle | Off | Enables multiplicative AO in the lighting pass |
| Enable SSAO Blur | Toggle | Off | Switches to bilateral-blurred AO map |
| SSAO Range (R) | 0.01 to 0.1 | 0.1 | World-space search radius for occluders |
| SSAO Bias | 0.001 to 0.005 | 0.001 | Offset preventing self-occlusion |
| SSAO Scale | 1.0 to 5.0 | 1.0 | Intensity multiplier on accumulated occlusion |
| SSAO Contrast | 1.0 to 5.0 | 1.0 | Power exponent applied to final AO value |
| Num Sample Points | 10 to 30 | 15 | Sample count n: quality vs. performance |
| Blur Sigma | 0.01 to 0.05 | 0.01 | Depth variance σ for bilateral depth weighting |
| Blur Radius | 1 to 10 | 2 | Half-width of the bilateral blur kernel |
File structure
src/
├── shaders/
│ ├── rawssao.frag # Alchemy SSAO: n spiral samples, solid-angle obscurance
│ ├── blurssao.frag # Bilateral blur: spatial × normal × depth weighting
│ ├── deferred_lighting.frag # Extended: AO multiply after IBL evaluation
│ ├── deferred_lighting_debug.frag # Extended: debug modes 9 (raw AO) and 10 (blurred AO)
│ ├── deferred_lighting.vert # Full-screen quad vertex shader (unchanged)
│ ├── gbuffer.vert / gbuffer.frag # Geometry pass (unchanged)
│ ├── skydome.vert / skydome.frag # HDR sky dome rendering (unchanged)
│ └── ...
│
├── include/
│ ├── Scene.h # SSAO struct, framebuffer members, blur weight array
│ ├── SHIrradiance.h # SH coefficient precomputation (unchanged)
│ ├── HammersleyRandomGenerator.h # Hammersley UBO (unchanged)
│ ├── SkyDome.h # Sky dome (unchanged)
│ └── ...
│
├── Scene.cpp # SSAOPass(), UpdateSSAOBlurWeights(), DrawSSAOSection()
└── Main.cpp # Application entry point (unchanged)
Conclusion
The most consistent improvement is on complex geometry. Surfaces like the horse mane, cassette speaker grille, video camera side plate, and hand plane layers show clear depth that the IBL irradiance integral alone cannot produce. The bilateral blur suppresses spiral banding while keeping AO boundaries sharp at depth discontinuities. Tuning R requires care: values too large introduce false occlusion from distant background geometry, while values too small restrict the effect to only the tightest contact points.