GLSL Separable Subsurface Scattering Blur Kernel

This article details how to implement and evaluate a separable subsurface scattering (SSSS) blur kernel across screen pixels using GLSL fragment shaders. Subsurface scattering simulates the diffusion of light penetrating translucent materials, such as skin, wax, or foliage, before exiting at different surface locations. By decomposing an expensive two-dimensional radial diffusion profile into two sequential one-dimensional filtering passes (horizontal and vertical), real-time applications can simulate this complex light transport with high performance while relying on depth-aware bilateral weighting to prevent haloing and preserve sharp silhouette edges.

The Separable Diffusion Profile

Physical subsurface light transport is traditionally defined by a non-linear diffusion profile (such as a dipole or standard Burley profile) that maps radial distance to scattered radiance. A 2D convolution over a screen-space pixel neighborhood requires \(O(N^2)\) texture fetches, which is prohibitive for real-time rendering.

Jorge Jimenez's separable approximation models the diffusion profile as a linear combination of multiple Gaussian distributions or directly fits a discrete, 1D kernel that maintains mathematical separability:

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

Because the 2D Gaussian function is separable into horizontal and vertical components:

\[G(x, y) = G(x) \cdot G(y)\]

The screen-space blur is split into two independent 1D passes:

  1. Horizontal Pass: Filters the direct diffuse lighting buffer along the screen X-axis into an intermediate render target.
  2. Vertical Pass: Filters the intermediate buffer along the screen Y-axis into the final radiance buffer.

Projecting Kernel Extents to Screen Space

Because scattering occurs in 3D world space, the blur kernel must scale inversely with the distance to the camera. If an object is far away, its world-space scattering radius corresponds to fewer screen-space pixels.

To project a world-space scattering distance \(R\) into screen coordinates:

\[\Delta\text{uv} = \frac{R \cdot \text{FOV\_Scale}}{\text{Depth}} \cdot \mathbf{D}\]

Where:

  • \(\text{Depth}\) is the view-space linear depth at the center pixel.
  • \(\text{FOV\_Scale}\) is a projection constant derived from the camera projection matrix, typically \(0.5 \cdot \text{proj}[1][1]\) (for vertical FOV) scaled by viewport dimensions.
  • \(\mathbf{D}\) is the normalized screen direction unit vector: vec2(1.0 / screenWidth, 0.0) for the horizontal pass and vec2(0.0, 1.0 / screenHeight) for the vertical pass.

Depth-Aware Bilateral Weighting

A standard blur over the diffuse lighting buffer causes light to leak across geometry boundaries, leading to soft outlines on silhouettes and light bleeding into the background. To enforce edge preservation, the weight of each sample must be modulated by the depth difference between the center pixel and the offset sample.

If the depth variation between the center pixel \(z_0\) and the tapped pixel \(z_i\) exceeds a material-defined threshold relative to distance, the sample weight is attenuated:

\[W_i = \text{kernelWeight}_i \cdot \max\left(0.0, 1.0 - \frac{|z_0 - z_i|}{\text{threshold}}\right)\]

GLSL Evaluation Pipeline

The filter executes across fullscreen fragment invocations. The kernel offsets and weights are precomputed on the CPU and stored either as uniform arrays or hardcoded constants in the shader.

Below is the GLSL fragment shader implementation for evaluating a single 1D pass of the separable subsurface scattering filter:

#version 330 core

in vec2 v_texCoord;
out vec4 fragColor;

uniform sampler2D u_diffuseTex;
uniform sampler2D u_linearDepthTex;

uniform vec2 u_blurDirection; // (1.0 / width, 0.0) or (0.0, 1.0 / height)
uniform float u_sssDistance;   // World-space max scattering distance
uniform float u_cameraFovScale; // Derived from projection matrix
uniform float u_depthCorrection;

// Precomputed weights and offsets (e.g., 7-sample symmetric kernel)
const int SAMPLE_COUNT = 7;
const float kernelOffsets[SAMPLE_COUNT] = float[](
    -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0
);

// Chromatic weights accounting for varying diffusion rates (e.g., red scatters farther in human skin)
const vec3 kernelWeights[SAMPLE_COUNT] = vec3[](
    vec3(0.015, 0.005, 0.002),
    vec3(0.060, 0.030, 0.015),
    vec3(0.240, 0.180, 0.100),
    vec3(0.370, 0.570, 0.766),
    vec3(0.240, 0.180, 0.100),
    vec3(0.060, 0.030, 0.015),
    vec3(0.015, 0.005, 0.002)
);

void main()
{
    vec4 centerColor = texture(u_diffuseTex, v_texCoord);
    float centerDepth = texture(u_linearDepthTex, v_texCoord).r;

    // Discard background/skybox pixels that do not undergo scattering
    if (centerDepth <= 0.0 || centerDepth >= 1000.0) {
        fragColor = centerColor;
        return;
    }

    // Determine screen-space step scale based on depth
    vec2 stepScale = (u_sssDistance * u_cameraFovScale / centerDepth) * u_blurDirection;

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

    for (int i = 0; i < SAMPLE_COUNT; ++i) {
        vec2 sampleUV = v_texCoord + kernelOffsets[i] * stepScale;
        
        vec3 sampleColor = texture(u_diffuseTex, sampleUV).rgb;
        float sampleDepth = texture(u_linearDepthTex, sampleUV).r;

        // Bilateral depth weighting to preserve boundaries
        float depthDiff = abs(centerDepth - sampleDepth);
        float depthFactor = clamp(1.0 - (depthDiff * u_depthCorrection), 0.0, 1.0);

        vec3 weight = kernelWeights[i] * depthFactor;
        
        accumulatedColor += sampleColor * weight;
        totalWeight += weight;
    }

    // Normalize final color to prevent energy loss around depth discontinuities
    vec3 finalScattered = accumulatedColor / max(totalWeight, vec3(0.0001));

    fragColor = vec4(finalScattered, centerColor.a);
}

Key Execution Considerations

  • Color Channels (Chromatic Scattering): Biological tissues like skin scatter red light substantially further than green or blue light. Evaluating the kernel using per-channel vector weights (vec3) allows realistic chromatic diffusion without running separate passes per color channel.
  • Diffuse Isolation: Only the direct diffuse component must be blurred. Specular highlights and ambient occlusion must be applied after or outside of the SSSS blur passes to avoid muddy, overly soft surfaces that look plastic or out of focus.
  • Translucency Maps: Materials often contain varied density. Multiplying the stepScale by a material-specific subsurface scattering mask texture allows artists to selectively clamp or widen the blur kernel across intricate surface features like nostrils or earlobes.