How Bilateral Blur Preserves Edges in DirectX
A bilateral blur filter is an edge-preserving smoothing technique used in DirectX post-processing to eliminate noise while maintaining high-contrast structural boundaries. Unlike a standard Gaussian blur that weights neighbor pixels based purely on their spatial distance, a bilateral filter computes a dual-weight kernel combining spatial proximity with photometric or depth similarity. In real-time DirectX rendering pipelines, this dual-weight formulation prevents sample values from bleeding across steep luminance gradients, depth discontinuities, or surface normal variations, keeping silhouettes and fine geometry sharp during post-processing passes such as screen-space ambient occlusion (SSAO) and depth-of-field denoising.
The Mathematical Dual-Weight Mechanism
In standard image filtering, a Gaussian blur applies a spatial weight:
\[\text{Weight}_{\text{spatial}} = \exp\left(-\frac{\|\mathbf{p} - \mathbf{q}\|^2}{2\sigma_s^2}\right)\]
where \(\mathbf{p}\) is the target pixel coordinate and \(\mathbf{q}\) is the neighboring pixel coordinate within the kernel footprint. This causes every pixel within radius \(\sigma_s\) to blend uniformly, inevitably softening sharp object borders.
The bilateral filter counteracts this by introducing a range (photometric) weight:
\[\text{Weight}_{\text{range}} = \exp\left(-\frac{\|I(\mathbf{p}) - I(\mathbf{q})\|^2}{2\sigma_r^2}\right)\]
where \(I(\mathbf{p})\) and \(I(\mathbf{q})\) represent pixel values, such as color luminance, linear scene depth, or normal vectors.
The composite weight used to sample each neighbor is the product of both terms:
\[\text{Weight}_{\text{total}} = \text{Weight}_{\text{spatial}} \times \text{Weight}_{\text{range}}\]
When the filter samples across an edge, the range difference \(\|I(\mathbf{p}) - I(\mathbf{q})\|\) becomes large. The exponential function rapidly drives the range weight toward zero. Even if a neighbor pixel is spatially adjacent to the center pixel, its contribution to the final accumulator is suppressed. As a result, only pixels that are both physically near and structurally similar are averaged together.
Implementation in DirectX Shaders
In a DirectX High-Level Shading Language (HLSL) post-process pass,
the bilateral filter runs inside a Pixel Shader (SV_Target)
or a Compute Shader (CSMain).
- Kernel Sampling: The shader loops through a 1D or
2D grid around the target pixel, fetching texture samples using
Texture2D.SampleLevelor direct integer indexing viaTexture2D.Load. - Range Metric Computation: In real-time graphics,
relying solely on color values for the range term can be problematic due
to lighting noise. DirectX pipelines frequently use a Joint
Bilateral Filter (or Cross-Bilateral Filter), where the range
weight is evaluated using G-buffer auxiliary textures—specifically
linear depth from
SV_Depthand camera-space normals:- Depth delta: If \(|\text{Depth}_p - \text{Depth}_q| > \text{Threshold}\), the weight drops to zero, preventing background bleeding into foreground geometry silhouettes.
- Normal delta: Evaluating the dot product \((N_p \cdot N_q)\) ensures that sharp creases, planar corners, and distinct surface faces do not blur into one another.
- Normalization: The accumulated color or effect data is divided by the sum of all evaluated composite weights to ensure energy preservation across uneven weight distributions.
Real-Time Performance Optimizations
The standard bilateral filter is non-separable, requiring \(\mathcal{O}(R^2)\) texture fetches per pixel for a kernel of radius \(R\). In DirectX 11 and 12, developers optimize this overhead using two primary approaches:
- Separable Approximations: The filter is split into horizontal and vertical 1D passes (\(\mathcal{O}(2R)\)). While mathematically imperfect for range weights, the visual difference is negligible for small radii.
- Compute Shader Group Shared Memory: Compute shaders
load pixel values and their associated depth/normals into tile-based
shared memory (
groupshared). This avoids redundant texture fetches from high-bandwidth device memory, allowing threads within a thread group to compute bilateral weights directly from low-latency on-chip registers.