Filtering Volumetric Shadows with Blue Noise in GLSL
Rendering volumetric shadows efficiently requires raymarching through participating media with a minimal number of steps. To avoid uniform stepping artifacts like slice banding, ray origins are offset along the view ray using blue noise. The standard algorithm used to resolve and filter these jittered samples into a smooth result is Temporal Reprojection with Spatial Cross-Bilateral Filtering (commonly integrated as part of a Spatio-Temporal Variance-Guided Filter, or SVGF). This article explains how this spatio-temporal filtering pipeline works in GLSL to eliminate blue noise dither while preserving geometric and volumetric edges.
The Problem: Banding vs. High-Frequency Noise
Volumetric shadow rendering calculates in-scattering along light paths using raymarching. Using fixed step lengths causes severe aliasing and banding artifacts where steps intersect light boundaries uniformly.
To eliminate banding, developers apply a phase offset to each ray using a blue noise texture:
// Offset the ray starting position using blue noise
float blueNoise = texture(u_BlueNoiseTex, gl_FragCoord.xy / u_BlueNoiseResolution).r;
vec3 rayStart = cameraPosition + rayDir * (stepSize * blueNoise);While blue noise breaks up coherent banding by distributing errors into high-frequency, stochastic noise, this noise must be filtered out before final composition.
Step 1: Temporal Reprojection and Accumulation
Because blue noise has minimal low-frequency energy, it resolves rapidly when integrated across multiple frames. The temporal pass reprojects the current pixel’s world position back into the previous frame's screen coordinates:
Calculate the current world position of the volumetric sample.
Reproject it using the previous frame's View-Projection matrix:
vec4 prevClip = u_PrevViewProj * vec4(worldPos, 1.0); vec2 prevUV = (prevClip.xy / prevClip.w) * 0.5 + 0.5;Sample the previous frame's accumulated volumetric buffer.
Blend the current noisy sample with the history buffer using an exponential moving average:
float blendFactor = 0.05; // 95% history, 5% new sample vec4 finalVolumetric = mix(prevHistory, currentSample, blendFactor);
To prevent ghosting when camera movement or lighting changes rapidly, the history is clamped to a neighborhood bounding box (derived from minimum and maximum values of adjacent pixels in the current frame).
Step 2: Edge-Aware Cross-Bilateral Filtering
Temporal accumulation handles stationary noise, but fast camera motion reduces the temporal sample count, revealing underlying blue noise grain. A spatial Cross-Bilateral Filter (or Guided Bilateral Filter) is used to smooth remaining spatial noise without blurring hard shadow boundaries or depth discontinuities.
In GLSL, the bilateral filter calculates weights based on two metrics:
- Spatial distance: Standard Gaussian distribution based on pixel distance.
- Depth/Range similarity: Discards neighbor samples that lie across geometry edges using the linear depth buffer.
vec4 filteredColor = vec4(0.0);
float totalWeight = 0.0;
float centerDepth = texture(u_DepthTex, v_UV).r;
for (int x = -2; x <= 2; ++x) {
for (int y = -2; y <= 2; ++y) {
vec2 offset = vec2(x, y) * u_TexelSize;
vec2 sampleUV = v_UV + offset;
float sampleDepth = texture(u_DepthTex, sampleUV).r;
vec4 sampleColor = texture(u_VolumetricTex, sampleUV);
// Compute weights
float spatialWeight = exp(-dot(offset, offset) / (2.0 * sigmaSpatial * sigmaSpatial));
float depthDiff = abs(centerDepth - sampleDepth);
float depthWeight = exp(-depthDiff / sigmaDepth);
float weight = spatialWeight * depthWeight;
filteredColor += sampleColor * weight;
totalWeight += weight;
}
}
vec4 finalFiltered = filteredColor / max(totalWeight, 0.0001);Separable Implementation for Performance
Full 2D kernel bilateral filtering can be computationally expensive at higher resolutions. Modern real-time engines split the spatial filter into two passes:
- Horizontal 1D bilateral blur written to an intermediate buffer.
- Vertical 1D bilateral blur sampling from the intermediate buffer.
By combining Temporal Anti-Aliasing (TAA) accumulation with a separable cross-bilateral pass, blue noise jittered volumetric shadows converge into a smooth, temporal-stable volumetric fog at a fraction of the computational cost of high-sample raymarching.