Implementing SSDO with Light Bounce in GLSL

Screen-Space Directional Occlusion (SSDO) improves upon standard Screen-Space Ambient Occlusion (SSAO) by accounting for the directionality of incoming light and sampling the radiance of nearby occluders to approximate one-bounce indirect diffuse lighting. This article outlines the mathematical formulation of SSDO, explains how screen-space geometry acts as light senders for color bleeding, and provides a clear, practical implementation using modern GLSL fragment shaders.


Conceptual Formulation

Traditional SSAO treats occlusion as a uniform scalar value derived from the percentage of occluded samples within a sphere or hemisphere. SSDO replaces this uniform approximation with directional visibility and local transfer:

  1. Direct Component (Occlusion): Incoming light from the direct environment or light sources is blocked if a sample in the hemisphere lies behind geometry in the depth buffer.
  2. Indirect Component (Bounce): When a sample position falls beneath the depth buffer, that sample represents a solid surface (an occluder). Instead of merely discarding it or subtracting from an ambient term, SSDO treats that occluding point as a secondary directional emitter that reflects light toward the current fragment.

The light bounce equation evaluated per fragment \(p\) with surface normal \(\mathbf{n}_p\) integrates the radiance \(L(s)\) reflected from the occluding sample \(s\):

\[L_{bounce}(p) = \frac{1}{N} \sum_{i=1}^{N} L(s_i) \cdot \frac{\max(0, \mathbf{n}_{s_i} \cdot (-\mathbf{\omega}_i)) \cdot \max(0, \mathbf{n}_p \cdot \mathbf{\omega}_i)}{\|\mathbf{x}_{s_i} - \mathbf{x}_p\|^2 + \epsilon} \cdot V_{sample}\]

Where:

  • \(\mathbf{\omega}_i\) is the normalized vector from \(p\) to the sample point \(s_i\).
  • \(\mathbf{n}_{s_i}\) is the surface normal of the occluding point.
  • \(L(s_i)\) is the radiance/color retrieved from the scene's color buffer at sample \(s_i\).
  • \(V_{sample}\) is a visibility condition that validates if the occluder is within an acceptable distance threshold (thickness heuristic).

GLSL Implementation

SSDO requires access to view-space positions, view-space normals, and the lit color buffer from the geometry pass.

#version 450 core

in vec2 vTexCoord;
layout(location = 0) out vec4 FragColor;

// G-Buffer samplers
uniform sampler2D gPosition;   // View-space positions (RGB)
uniform sampler2D gNormal;     // View-space normals (RGB)
uniform sampler2D gColor;      // Direct lit scene color (RGB)
uniform sampler2D texNoise;    // 4x4 tiling noise for kernel rotation

// Uniforms
uniform mat4 projection;
uniform vec3 samples[64];      // Hemisphere sample vectors
uniform int kernelSize;        // e.g., 32 or 64
uniform float radius;          // Sampling hemisphere radius
uniform float maxDistance;     // Maximum depth rejection distance

const vec2 noiseScale = vec2(1920.0 / 4.0, 1080.0 / 4.0);

void main()
{
    vec3 fragPos = texture(gPosition, vTexCoord).xyz;
    vec3 normal  = normalize(texture(gNormal, vTexCoord).xyz);
    vec3 randomVec = texture(texNoise, vTexCoord * noiseScale).xyz;

    // Construct TBN frame aligned to surface normal
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);

    float directOcclusion = 0.0;
    vec3 indirectBounce = vec3(0.0);

    for (int i = 0; i < kernelSize; ++i)
    {
        // 1. Generate sample point in view-space
        vec3 sampleDir = TBN * samples[i];
        vec3 samplePos = fragPos + sampleDir * radius;

        // 2. Project sample position to screen-space UV
        vec4 offset = projection * vec4(samplePos, 1.0);
        offset.xyz /= offset.w;
        offset.xyz = offset.xyz * 0.5 + 0.5;

        // 3. Retrieve actual depth and attributes of geometry at sample UV
        vec3 occluderPos = texture(gPosition, offset.xy).xyz;
        vec3 occluderNormal = normalize(texture(gNormal, offset.xy).xyz);
        vec3 occluderColor = texture(gColor, offset.xy).rgb;

        // Vector pointing from fragment to actual geometry
        vec3 dirToOccluder = occluderPos - fragPos;
        float dist = length(dirToOccluder);
        vec3 omega = dirToOccluder / dist;

        // Check if sample is occluded
        bool isOccluded = (occluderPos.z >= samplePos.z + 0.02);

        // Discard invalid occluders beyond the influence radius
        float rangeCheck = smoothstep(0.0, 1.0, radius / dist);
        
        if (isOccluded && dist < maxDistance)
        {
            directOcclusion += 1.0 * rangeCheck;

            // Compute geometric alignment factors
            float cosReceiver = max(dot(normal, omega), 0.0);
            float cosSender   = max(dot(occluderNormal, -omega), 0.0);
            
            // Attenuation over distance
            float attenuation = 1.0 / (1.0 + (dist * dist));

            // Accumulate bounce light from the occluder
            indirectBounce += occluderColor * cosReceiver * cosSender * attenuation * rangeCheck;
        }
    }

    directOcclusion = 1.0 - (directOcclusion / float(kernelSize));
    indirectBounce /= float(kernelSize);

    // Output: RGB contains indirect color bounce, Alpha contains direct visibility
    FragColor = vec4(indirectBounce, directOcclusion);
}

Key Technical Considerations

  1. Thickness Threshold: Since screen-space techniques lack geometric thickness, background surfaces can be falsely identified as immediate occluders. The range check (smoothstep combined with maxDistance) prevents objects far behind the sample from casting incorrect shadows or bleeding color.
  2. Sender Visibility: The sender surface normal (occluderNormal) must point toward the receiver point (dot(occluderNormal, -omega) > 0.0). Samples facing away from the receiver are backfaces and must not transfer light.
  3. Filtering and Blurring: Like SSAO, the output buffer from SSDO contains high-frequency noise from the rotated sampling pattern. A depth- and normal-aware bilateral blur must be applied before compositing the indirect bounce additively and multiplying the visibility factor with ambient illumination.