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).

  1. Kernel Sampling: The shader loops through a 1D or 2D grid around the target pixel, fetching texture samples using Texture2D.SampleLevel or direct integer indexing via Texture2D.Load.
  2. 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_Depth and 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.
  3. 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.