HDR Bloom Threshold and Knee Curve Formulation in GLSL

This article provides an overview and technical breakdown of the thresholding and soft knee curve formulation used in High Dynamic Range (HDR) bloom extraction passes. In real-time rendering, extracting highlights using a naive step function causes harsh boundaries and severe temporal flickering on specular edges. By implementing a quadratic soft knee curve in GLSL, shaders can smoothly transition pixels into the bloom buffer, maintaining visual stability and energy balance across varying exposure levels.

The Purpose of the Knee Curve

In HDR post-processing, bloom simulates the light scattering within the human eye or camera lens caused by intense light sources. A bright-pass filter isolates regions of the screen exceeding a chosen luminance threshold.

A hard threshold discards all values below a cutoff \(T\):

\[\text{Output} = \max(0.0, L - T)\]

This abrupt cutoff creates edge artifacts and temporal instability when specular highlights oscillate across the threshold boundary. A soft knee introduces a quadratic curve around the threshold, parameterized by a knee width \(K\), to smoothly blend between zero contribution and the fully linear bright region.

Mathematical Formulation

The standard soft knee formulation—widely adopted from implementations by Epic Games and Activision—segments luminance extraction into three distinct response zones based on pixel luminance \(L\):

  1. Below the Knee (\(L \le T - K\)): The contribution is zero.
  2. Inside the Knee (\(T - K < L \le T + K\)): A quadratic ease-in curve smoothly ramps up brightness.
  3. Above the Knee (\(L > T + K\)): The response becomes linear (\(L - T\)).

The quadratic function within the knee is defined as:

\[\text{Soft}(L) = \frac{(L - T + K)^2}{4K}\]

To avoid branching and preserve color ratios without tint shifts, the response factor is computed as a scalar multiplier against the original RGB color vector:

\[\text{Weight} = \frac{\max(\text{Soft}(L), L - T)}{\max(L, \epsilon)}\]

\[\text{Final Color} = \text{Color} \times \text{Weight}\]

Where \(\epsilon\) is a small non-zero value to prevent division by zero.

GLSL Implementation

The following GLSL snippet demonstrates how to precalculate curve parameters on the CPU and perform branchless bloom extraction in the fragment shader.

Uniform Setup

To optimize shader performance, precompute the knee vector vec4(T, T - K, 2.0 * K, 0.25 / max(K, 1e-5)) and pass it as a uniform:

// Precomputed CPU-side parameters:
// curve.x = threshold
// curve.y = threshold - knee
// curve.z = 2.0 * knee
// curve.w = 0.25 / max(knee, 1e-5)
uniform vec4 u_FilterParameters; 

Fragment Shader

#version 330 core

in vec2 v_TexCoord;
out vec4 FragColor;

uniform sampler2D u_SceneColor;
uniform vec4 u_FilterParameters;

// Standard Rec. 709 luminance weights
float getLuminance(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {
    vec3 color = texture(u_SceneColor, v_TexCoord).rgb;
    
    // Pixel luminance
    float luma = getLuminance(color);
    
    // Unpack precomputed parameters
    float threshold = u_FilterParameters.x;
    float knee      = u_FilterParameters.z * 0.5; // Reconstructed K
    
    // Branchless soft-knee calculation
    float rq = clamp(luma - u_FilterParameters.y, 0.0, u_FilterParameters.z);
    rq = u_FilterParameters.w * rq * rq;
    
    // Take the maximum between the soft knee and the linear threshold
    float bloomLuma = max(rq, luma - threshold);
    
    // Compute normalization factor to preserve original color hues
    float factor = bloomLuma / max(luma, 1e-5);
    
    FragColor = vec4(color * factor, 1.0);
}

Considerations for Implementation

  • Luminance Metrics: Using Rec. 709 or Rec. 2020 luma coefficients ensures extraction responds accurately to perceived human brightness. Alternatively, taking \(\max(R, G, B)\) can better preserve intense, highly saturated primary colors that have low perceptual luma weights.
  • Knee Clamping: The knee parameter \(K\) should be clamped between \(0.0\) and \(T\). Setting \(K = 0.0\) degenerates the formulation into a standard hard threshold, while \(K = T\) extends the smoothing curve all the way down to complete black.