Karis Averaging for Bloom Downsampling in GLSL

This article examines how Karis averaging effectively eliminates high-energy "firefly" artifacts during bloom downsampling in GLSL. You will learn why intense HDR specular reflections produce temporal instability across lower mip levels, the mathematical intuition behind Brian Karis's luminance-weighted filter, and how to implement the algorithm directly within a real-time post-processing pipeline.

The Origin of Specular Fireflies in Bloom

In High Dynamic Range (HDR) rendering pipelines, specular highlights and direct light sources frequently yield unbounded radiance values. A single texel can easily possess a luminance value of \(100.0\) or \(10,000.0\), while its immediate neighbors hover near \(1.0\).

When downsampling the HDR buffer to construct the bloom mip chain, standard linear or box-filter kernels compute an arithmetic mean of neighbor samples:

\[\text{Color}_{\text{down}} = \frac{1}{N} \sum_{i=1}^{N} \text{Color}_i\]

Because the filter operates linearly, a single isolated HDR texel with massive luminance heavily skews the average of the entire kernel footprint. As camera motion slightly shifts the screen-space position of this subpixel highlight, the energy snaps abruptly between neighboring downsampled texels. This discrepancy causes spatial aliasing and aggressive temporal flickering known as "fireflies." When magnified across progressive downsample passes and then blurred during upsampling, fireflies manifest as distracting, unstable pulses across the final image.

The Mathematics Behind Karis Averaging

Introduced by Brian Karis for Unreal Engine's post-processing pipeline, Karis averaging replaces the standard linear filter with a partial, localized Reinhard-style weighting. Instead of treating every sample with equal linear weight, each sample is dynamically weighted according to the reciprocal of its perceptual luminance:

\[w_i = \frac{1}{1 + \text{Luminance}(\text{Color}_i)}\]

\[\text{Color}_{\text{down}} = \frac{\sum_{i=1}^{N} w_i \cdot \text{Color}_i}{\sum_{i=1}^{N} w_i}\]

When a texel's luminance is modest (\(L \approx 0\) to \(1\)), the weight remains close to \(1.0\). However, when a texel exhibits extreme HDR brightness (\(L \gg 1\)), the denominator inflates, driving the weight toward zero.

This formulation mathematically compresses the contribution of extreme radiance spikes into a bounded range before calculating the spatial average, effectively preventing an isolated spike from overpowering the local neighborhood. Because the normalization factor (\(\sum w_i\)) is included in the denominator, overall energy scale is maintained without universally clipping or muting surrounding diffuse illumination.

Implementing Karis Averaging in GLSL

A standard bloom downsample pass often uses a 13-tap bilinear filter (popularized by Jorge Jimenez for Call of Duty) to avoid boxy sampling artifacts. In this pattern, the kernel is composed of four overlapping \(2 \times 2\) pixel groups plus a central \(2 \times 2\) group. Karis averaging is applied across each of these groups individually to maintain both spatial smoothness and outlier suppression.

Below is an optimized GLSL fragment shader implementation demonstrating Karis averaging applied across four box samples:

#version 450 core

layout(location = 0) in vec2 v_TexCoord;
layout(location = 0) out vec4 out_Color;

layout(binding = 0) uniform sampler2D u_HDRTexture;
uniform vec2 u_TexelSize; // 1.0 / TextureDimensions

// Standard ITU-R BT.709 relative luminance
float CalculateLuminance(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

// Karis weight calculation based on inverse tone-mapping
float KarisWeight(vec3 color) {
    return 1.0 / (1.0 + CalculateLuminance(color));
}

// Downsamples a 2x2 neighborhood using Karis weighting
vec3 KarisAverageBox(vec3 c0, vec3 c1, vec3 c2, vec3 c3) {
    float w0 = KarisWeight(c0);
    float w1 = KarisWeight(c1);
    float w2 = KarisWeight(c2);
    float w3 = KarisWeight(c3);

    float totalWeight = w0 + w1 + w2 + w3;
    
    // Fallback to prevent division by zero in void regions
    if (totalWeight < 1e-5) {
        return (c0 + c1 + c2 + c3) * 0.25;
    }

    return (c0 * w0 + c1 * w1 + c2 * w2 + c3 * w3) / totalWeight;
}

void main() {
    vec2 uv = v_TexCoord;
    vec2 offset = u_TexelSize;

    // Sample a 2x2 grid around the current UV coordinate
    vec3 s00 = texture(u_HDRTexture, uv + vec2(-offset.x, -offset.y)).rgb;
    vec3 s10 = texture(u_HDRTexture, uv + vec2( offset.x, -offset.y)).rgb;
    vec3 s01 = texture(u_HDRTexture, uv + vec2(-offset.x,  offset.y)).rgb;
    vec3 s11 = texture(u_HDRTexture, uv + vec2( offset.x,  offset.y)).rgb;

    // Apply Karis averaging to suppress fireflies
    vec3 result = KarisAverageBox(s00, s10, s01, s11);

    out_Color = vec4(result, 1.0);
}

Pipeline Considerations

Karis averaging should only be executed during the initial downsample pass (Mip 0 to Mip 1).

Applying luminance weighting across multiple downsample passes causes cumulative energy dissipation, artificially dimming wide-radius bloom halos. Because subpixel specular fireflies are fundamentally high-frequency spatial phenomena, dampening them in the very first downsample step stabilizes the entire mip pyramid. All subsequent downsample and upsample passes can then safely revert to standard linear or bilinear arithmetic filtering without generating artifacts.