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:
- 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.
- 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
- Thickness Threshold: Since screen-space techniques
lack geometric thickness, background surfaces can be falsely identified
as immediate occluders. The range check (
smoothstepcombined withmaxDistance) prevents objects far behind the sample from casting incorrect shadows or bleeding color. - 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. - 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.