Screen-Space Subsurface Scattering in DirectX

Screen-space subsurface scattering (SSSS) is an efficient rendering technique used in modern real-time graphics to simulate the way light penetrates, scatters within, and exits translucent materials such as skin, wax, and marble. This article explains the fundamental theory behind SSSS, highlights its advantages over traditional brute-force volumetric calculations, and provides a step-by-step breakdown of how to implement the technique within a DirectX pipeline using High-Level Shader Language (HLSL).

Understanding Screen-Space Subsurface Scattering

When light strikes a translucent surface, a portion reflects immediately as specular highlight, while the rest penetrates the surface, bounces among internal microscopic particles, and exits at nearby points. In offline rendering, this is simulated using the Bidirectional Scattering Surface Reflectance Distribution Function (BSSRDF). Because full volumetric path tracing or Monte Carlo sampling is computationally expensive for real-time applications, SSSS approximates this diffusion entirely in screen space.

Instead of tracking light paths through 3D volume, SSSS models light diffusion as a post-processing filter applied to the rendered scene. Based on the observation that the scattering profile of human skin can be represented as a sum of Gaussian functions, the diffusion process can be approximated using a depth-aware blur applied to the diffuse lighting component of an image.

Key Principles of SSSS

  1. Separation of Lighting Components: Only diffuse light scatters beneath the surface. Specular reflections must be kept separate from the blur pass, as blurring highlights destroys the perception of surface detail.
  2. Diffusion Profiles: Different wavelengths of light scatter at different distances. In human skin, red light penetrates and scatters significantly farther than green or blue light. The filter kernel accounts for this by applying per-channel Gaussian weights.
  3. Depth-Aware Filtering: To prevent light from bleeding across discontinuities—such as from a character's nose onto the background wall—the filter uses depth values to scale the blur radius and reject samples that fall outside the local surface geometry.

Implementing SSSS in DirectX

The implementation of SSSS in a DirectX 11 or DirectX 12 pipeline relies on a multi-pass post-processing approach using compute shaders or pixel shaders written in HLSL.

1. Render Target Setup

During the main geometry pass, prepare the following buffers:

  • Diffuse Irradiance Target: Stores only the diffuse lighting on the subsurface material.
  • Specular Target: Stores specular highlights and non-scattering materials.
  • Depth/Stencil Buffer: Provides linear view-space depth for distance-based weighting.
  • Material Mask: Identifies which pixels correspond to subsurface-enabled geometry.

2. Separable Filtering

A standard 2D convolution kernel with radius \(N\) requires \(N^2\) texture samples per pixel. SSSS optimizes this by splitting the 2D filter into two 1D passes:

  • Pass 1: Horizontal blur across the screen.
  • Pass 2: Vertical blur applied to the output of Pass 1.

This reduces the complexity from \(O(N^2)\) to \(O(2N)\), making high-quality scattering practical at high frame rates.

3. HLSL Kernel Implementation

The blur shader computes the final color by taking weighted samples along a 1D axis. Each sample's offset is scaled by the material's scattering radius and inversely proportional to the view-space depth of the center pixel to maintain constant physical size in world space.

Texture2D<float4> gDiffuseTexture : register(t0);
Texture2D<float>  gDepthTexture   : register(t1);
SamplerState      gPointSampler   : register(s0);

cbuffer SSSSConstants : register(b0)
{
    float2 gStepDirection;     // (1.0 / Width, 0) for horizontal, (0, 1.0 / Height) for vertical
    float  gMaxScatteringRadius;
    float  gDepthThreshold;
};

// Precomputed weights for a multi-Gaussian skin profile (RGB)
static const float3 KernelWeights[3] = {
    float3(0.233, 0.455, 0.649),
    float3(0.100, 0.336, 0.344),
    float3(0.118, 0.198, 0.000)
};
static const float KernelOffsets[3] = { 0.0, 1.5, 3.5 };

float4 PS_SSSS(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target
{
    float centerDepth = gDepthTexture.Sample(gPointSampler, uv);
    float3 centerColor = gDiffuseTexture.Sample(gPointSampler, uv).rgb;

    // Calculate real-world scale based on depth
    float2 step = (gStepDirection * gMaxScatteringRadius) / centerDepth;

    float3 totalColor = centerColor * KernelWeights[0];
    float3 totalWeight = KernelWeights[0];

    [unroll]
    for (int i = 1; i < 3; ++i)
    {
        float2 offset = step * KernelOffsets[i];

        // Sample both positive and negative directions
        for (int dir = -1; dir <= 1; dir += 2)
        {
            float2 sampleUV = uv + (offset * dir);
            float sampleDepth = gDepthTexture.Sample(gPointSampler, sampleUV);

            // Reject samples exceeding depth difference threshold
            if (abs(centerDepth - sampleDepth) < gDepthThreshold)
            {
                float3 sampleColor = gDiffuseTexture.Sample(gPointSampler, sampleUV).rgb;
                totalColor += sampleColor * KernelWeights[i];
                totalWeight += KernelWeights[i];
            }
        }
    }

    return float4(totalColor / totalWeight, 1.0);
}

4. Recombination Pass

Once both horizontal and vertical passes are complete, a final composition pass blends the scattered diffuse image with the unblurred specular target, ambient occlusion, and any non-scattering geometry. This restores sharp surface reflections and fine pores over the smoothly diffused underlying tissue.