Calculating SSAO with Depth Buffers in GLSL
Screen-Space Ambient Occlusion (SSAO) approximates the soft contact shadows that occur when geometry blocks ambient light. In real-time rendering, this is calculated entirely in screen space by taking a depth buffer, reconstructing 3D view-space coordinates, and casting sample points inside a normal-aligned hemisphere around each fragment. By transforming these samples back into screen space and comparing their depth against the depth buffer, an occlusion factor is calculated to simulate how exposed a surface point is to ambient lighting.
1. Reconstructing View-Space Positions
SSAO relies heavily on geometric data stored in view space. To begin, retrieve the non-linear depth value from the depth buffer and reconstruct the fragment’s view-space position:
vec3 getViewPos(vec2 uv, mat4 invProj) {
float depth = texture(depthTexture, uv).r;
vec4 clipSpacePos = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 viewSpacePos = invProj * clipSpacePos;
return viewSpacePos.xyz / viewSpacePos.w;
}Surface normals must also be accessible, either by decoding them from
a normal G-Buffer in view space or by deriving them geometrically using
GLSL screen derivatives:
normalize(cross(dFdx(fragPos), dFdy(fragPos))).
2. Generating the Hemisphere Kernel and Rotation Noise
A sample kernel consists of multiple 3D vectors distributed within a hemisphere oriented along the Z-axis (\(z \ge 0\)). Samples are typically pushed closer to the origin using quadratic or cubic falloff to prioritize nearby occluders:
- Kernel Generation (CPU): Random unit vectors are generated with \(z > 0\), scaled by \(scale = \text{mix}(0.1, 1.0, (i / N)^2)\), and passed to the shader via an array uniform.
- Random Rotation Texture (CPU/GPU): To disguise banding artifacts without requiring hundreds of samples, a small tiled texture (e.g., 4x4 pixels) filled with random vectors oriented around the Z-axis is used to rotate the kernel randomly per pixel.
3. Constructing the Tangent-to-View-Space Matrix (TBN)
In the fragment shader, use the normal vector and the random rotation vector to construct a Tangent-Bitangent-Normal (TBN) basis. This rotates the Z-up sample hemisphere so that its base aligns with the surface normal:
vec2 noiseScale = screenResolution / 4.0; // Tiling factor for 4x4 noise
vec3 randomVec = texture(noiseTexture, TexCoords * noiseScale).xyz;
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);4. Sampling and Depth Comparison
For each sample in the kernel:
- Rotate the sample vector using the TBN matrix and offset it by the sample radius.
- Add this offset to the current fragment position to calculate the view-space sample point.
- Project this sample point back to screen space using the projection matrix and perspective divide.
- Sample the scene depth buffer at this new UV coordinate.
- Apply a range check to prevent far-away geometry from creating false occlusion (halo artifacts).
uniform vec3 samples[64];
uniform mat4 projection;
uniform float radius = 0.5;
uniform float bias = 0.025;
float occlusion = 0.0;
for(int i = 0; i < 64; ++i) {
// 1. Transform sample from tangent to view space
vec3 samplePos = TBN * samples[i];
samplePos = fragPos + samplePos * radius;
// 2. Project sample to screen space
vec4 offset = projection * vec4(samplePos, 1.0);
offset.xyz /= offset.w; // Perspective divide
offset.xyz = offset.xyz * 0.5 + 0.5; // Map to [0, 1] range
// 3. Get reconstructed depth at projected sample point
float sampleDepth = getViewPos(offset.xy, invProjection).z;
// 4. Range check to prevent bleeding across large distance gaps
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
// 5. Accumulate occlusion if sample is behind geometry
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
}
occlusion = 1.0 - (occlusion / 64.0);5. Blurring the Output
Because the kernel rotation creates high-frequency noise, the calculated occlusion factor must be passed through a bilateral blur pass. A bilateral filter uses depth and normal thresholds so that the blur does not cross surface edges, smoothing the noise while preserving hard contact shadow boundaries.