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:

  1. Rotate the sample vector using the TBN matrix and offset it by the sample radius.
  2. Add this offset to the current fragment position to calculate the view-space sample point.
  3. Project this sample point back to screen space using the projection matrix and perspective divide.
  4. Sample the scene depth buffer at this new UV coordinate.
  5. 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.