Edge-Preserving Guided Filter Upsampling in GLSL

This article details how to implement an edge-preserving guided filter in GLSL to upsample low-resolution rendering buffers—such as screen-space ambient occlusion (SSAO), depth of field, or volumetric lighting—using a full-resolution guide buffer like depth or luminance. It outlines the core mathematical principles, the standard two-pass GPU architecture required for real-time performance, and concrete shader code for computing local linear coefficients and reconstructing the final high-resolution output.


Core Concept

The guided filter assumes that the filtered output \(q\) is a local linear transformation of a guidance image \(I\) within a local window \(\omega_k\):

\[q_i = a_k I_i + b_k, \quad \forall i \in \omega_k\]

Here, \(I\) is the high-resolution guide (e.g., full-resolution depth or luminance), \(p\) is the low-resolution input buffer, and \(a_k\) and \(b_k\) are linear coefficients that remain constant over the local window.

The coefficients minimize the difference between \(q\) and \(p\) while penalizing large values of \(a\) via a regularization parameter \(\epsilon\):

\[a_k = \frac{\text{cov}_{I,p}}{\sigma^2_I + \epsilon} = \frac{\frac{1}{|\omega|}\sum I_i p_i - \mu_I \bar{p}_k}{\frac{1}{|\omega|}\sum I_i^2 - \mu_I^2 + \epsilon}\]

\[b_k = \bar{p}_k - a_k \mu_I\]

When upsampling, computing \(a\) and \(b\) at low resolution and then bilinearly interpolating them to full resolution drastically reduces cost while preventing edge-bleeding artifacts.


Two-Pass Architecture

Upsampling with a guided filter is typically implemented in two passes:

  1. Coefficient Pass (Low Resolution): Downsample the guide to match the low-resolution input buffer. Compute the local means, variances, and covariances of the low-resolution guide and input buffer over a neighborhood, then output the coefficients \(a\) and \(b\) into an intermediate target (e.g., an RG16F or RGBA16F texture).

  2. Reconstruction Pass (High Resolution): Sample the intermediate coefficient texture using hardware bilinear filtering. Fetch the full-resolution guide value and evaluate \(q = a \cdot I_{\text{full}} + b\).


Pass 1: Coefficient Generation (Fragment Shader)

This shader runs at low resolution. It computes the local statistics over a box window and stores \(a\) and \(b\).

#version 450 core

in vec2 vTexCoord;

uniform sampler2D uLowResInput;  // Buffer to upsample (e.g., SSAO)
uniform sampler2D uLowResGuide;  // Downsampled guide (e.g., linear depth)

uniform vec2 uTexelSize;         // 1.0 / LowResResolution
uniform int uRadius;             // Kernel radius (e.g., 2 or 3)
uniform float uEpsilon;          // Regularization factor (e.g., 1e-4 to 1e-2)

layout(location = 0) out vec2 outAB;

void main() {
    float sumI = 0.0;
    float sumP = 0.0;
    float sumII = 0.0;
    float sumIP = 0.0;
    float sampleCount = 0.0;

    for (int y = -uRadius; y <= uRadius; ++y) {
        for (int x = -uRadius; x <= uRadius; ++x) {
            vec2 offset = vec2(float(x), float(y)) * uTexelSize;
            vec2 uv = vTexCoord + offset;

            float I = texture(uLowResGuide, uv).r;
            float P = texture(uLowResInput, uv).r;

            sumI += I;
            sumP += P;
            sumII += I * I;
            sumIP += I * P;
            sampleCount += 1.0;
        }
    }

    float meanI = sumI / sampleCount;
    float meanP = sumP / sampleCount;
    float varI = (sumII / sampleCount) - (meanI * meanI);
    float covIP = (sumIP / sampleCount) - (meanI * meanP);

    float a = covIP / (varI + uEpsilon);
    float b = meanP - a * meanI;

    outAB = vec2(a, b);
}

Pass 2: Final Reconstruction (Fragment Shader)

This shader runs at full resolution. It leverages the GPU's bilinear filtering unit to smoothly interpolate \(a\) and \(b\), combining them with the sharp full-resolution guide.

#version 450 core

in vec2 vTexCoord;

uniform sampler2D uABTexture;     // Output from Pass 1 (bilinear filtering enabled)
uniform sampler2D uFullResGuide;  // Full-resolution guide buffer

layout(location = 0) out float outColor;

void main() {
    // Hardware bilinear filtering interpolates a and b
    vec2 ab = texture(uABTexture, vTexCoord).rg;
    float a = ab.x;
    float b = ab.y;

    // Full-resolution guidance value
    float I = texture(uFullResGuide, vTexCoord).r;

    // Linear model reconstruction
    float q = a * I + b;

    outColor = clamp(q, 0.0, 1.0);
}

Tuning and Optimizations

  • The \(\epsilon\) Parameter: Controls edge preservation. A smaller \(\epsilon\) (e.g., \(10^{-4}\)) forces the output to match variations in the guidance texture strictly, keeping sharp edges. A larger \(\epsilon\) (e.g., \(10^{-2}\)) behaves more like an edge-ignoring box filter.
  • Separable Box Blurring: For large filter radii, the \(O(R^2)\) brute-force sampling in Pass 1 can be replaced by computing separable 1D horizontal and vertical box-blur passes, reducing the computational complexity to \(O(R)\).
  • Depth Guide Linearization: When using hardware depth as the guide buffer, convert non-linear \(Z\) coordinates to linear view-space depth before variance and covariance calculations to avoid floating-point precision loss at distance.