Screen-Space Subsurface Scattering in GLSL

Screen-space subsurface scattering (SSSS) approximates the soft diffusion of light beneath translucent surfaces—such as skin, wax, or marble—by applying a depth-aware spatial blur to the diffuse lighting component in screen space. Instead of simulating complex volumetric photon scattering within geometry, this technique renders the diffuse lighting to a texture and blurs it using a separable filter that mimics an optical diffusion profile. This article explains the underlying mechanism, the mathematical approximation via Gaussian filtering, and how to implement the effect using GLSL fragment shaders.

The Core Concept

Realistic subsurface scattering occurs when light enters a translucent material, bounces off internal particles, and exits at a different point. In 2009, Jorge Jimenez and colleagues demonstrated that this diffusion can be modeled in post-processing by blurring the rendered diffuse light across adjacent pixels.

To prevent unrealistic bleeding across boundaries, the blur must be depth-aware (bilateral). It scales kernel offsets inversely with screen depth so that distant objects receive smaller blurs, while ignoring samples that exhibit large depth discrepancies to preserve crisp object silhouettes.

The Pipeline Architecture

The screen-space approximation separates lighting into distinct phases:

  1. Diffuse Pre-Pass: Render the scene's diffuse irradiance and linear camera-space depth into an off-screen framebuffer object (FBO). Specular highlights must be excluded from this pass because specular reflection occurs at the surface boundary and does not scatter.
  2. Two-Pass Separable Filter: To maximize performance, a two-dimensional blur is broken down into two one-dimensional passes: horizontal (X-axis) and vertical (Y-axis).
  3. Recombination: Combine the scattered diffuse result with specular highlights, emissive components, and ambient lighting in the final frame.

The Diffusion Profile

Subsurface scattering is physically characterized by a diffusion profile \(R(r)\), representing the probability of light exiting at a radial distance \(r\). A common practice is fitting \(R(r)\) to a sum of Gaussians:

\[R(r) = \sum_{i=1}^{k} w_i G(r, \sigma_i)\]

Each Gaussian component uses varying variance (\(\sigma_i\)) and color weights (\(w_i\)). For human skin, red wavelengths penetrate deeper than green and blue wavelengths. Consequently, the red channel requires broader blur widths, giving skin its characteristic red-tinted falloff around shadows and silhouettes.

GLSL Implementation

The following GLSL fragment shader demonstrates a one-dimensional, depth-aware blur pass. The uniform u_Direction specifies the blur direction (vec2(1.0, 0.0) for horizontal, vec2(0.0, 1.0) for vertical).

#version 330 core

in vec2 v_TexCoord;
out vec4 FragColor;

uniform sampler2D u_DiffuseTexture; // Diffuse lighting pass
uniform sampler2D u_DepthTexture;   // Linear camera-space depth
uniform vec2 u_Direction;           // Blur axis (normalized)
uniform vec2 u_TexelSize;           // 1.0 / ScreenResolution
uniform float u_SSSWidth;           // Global scattering scale

// Precomputed Gaussian weights and offsets for a 7-tap filter
const int SAMPLES = 7;
const float offsets[7] = float[]( -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0 );

// Chromatic weights (R, G, B scatter differently in skin)
const vec3 weights[7] = vec3[](
    vec3(0.006, 0.000, 0.000),
    vec3(0.061, 0.010, 0.002),
    vec3(0.242, 0.133, 0.045),
    vec3(0.382, 0.714, 0.906),
    vec3(0.242, 0.133, 0.045),
    vec3(0.061, 0.010, 0.002),
    vec3(0.006, 0.000, 0.000)
);

void main()
{
    vec4 centerColor = texture(u_DiffuseTexture, v_TexCoord);
    float centerDepth = texture(u_DepthTexture, v_TexCoord).r;

    // Scale blur kernel by distance to maintain world-space size
    vec2 step = (u_Direction * u_TexelSize * u_SSSWidth) / centerDepth;

    vec3 accumulatedColor = vec3(0.0);
    vec3 totalWeight = vec3(0.0);

    for (int i = 0; i < SAMPLES; ++i)
    {
        vec2 sampleCoord = v_TexCoord + step * offsets[i];
        vec3 sampleColor = texture(u_DiffuseTexture, sampleCoord).rgb;
        float sampleDepth = texture(u_DepthTexture, sampleCoord).r;

        // Bilateral depth rejection: suppress sample if depth delta is large
        float depthDiff = abs(centerDepth - sampleDepth);
        float depthFactor = exp(-depthDiff * 100.0); 

        vec3 sampleWeight = weights[i] * depthFactor;
        accumulatedColor += sampleColor * sampleWeight;
        totalWeight += sampleWeight;
    }

    // Normalize result to preserve energy
    FragColor = vec4(accumulatedColor / max(totalWeight, vec3(0.001)), centerColor.a);
}

Depth and Silhouette Preservation

Without bilateral depth testing, blurring dark backgrounds into foreground silhouettes causes halos. The shader controls this via depthFactor, which attenuates the Gaussian sample weight using the depth difference between the center pixel and the offset sample. If the depth difference exceeds a small threshold, the sample contribution drops to zero. Normalizing by totalWeight at the end prevents darkening near edges where neighbor samples are rejected.

Trade-Offs and Limitations

Screen-space texture blurring runs in constant time relative to geometric complexity, making it practical for real-time rendering. However, it cannot gather lighting from surfaces hidden from the camera view, such as back-lit ears or thick occluded geometry. To address light transmitted through thin geometric features from behind, screen-space blurring is typically paired with a shadow-map-based thickness transmission pass.