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