GLSL Auto-Exposure via Parallel Log-Luminance Reduction
Dynamic auto-exposure simulates how the human eye adapts to changing lighting conditions by calculating average scene brightness and adjusting camera exposure dynamically. Computing this efficiently in real-time graphics requires converting scene colors to logarithmic luminance, reducing that data across the GPU in parallel using compute shaders, exponentiating the result to obtain the geometric mean, and applying a temporal blend to simulate gradual eye accommodation.
1. Log-Luminance Transformation
Standard arithmetic averaging of linear RGB values skews the exposure toward extreme highlights, such as the sun or bright specular glints. To approximate human visual perception, the pipeline evaluates the geometric mean, calculated by averaging the logarithm of luminance:
\[\text{Geometric Mean} = \exp\left( \frac{1}{N} \sum \ln(L_i + \epsilon) \right)\]
In GLSL, a compute or fragment pass converts input HDR color to scalar luminance using standard Rec. 709 coefficients, adds a small epsilon to prevent \(\ln(0)\), and writes the natural or base-2 logarithm to a texture or buffer:
// Convert HDR RGB to log-luminance
float calculateLuminance(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
float getLogLuminance(vec3 color) {
float lum = calculateLuminance(color);
const float EPSILON = 0.0001;
return log(lum + EPSILON);
}2. Parallel Reduction
To compute the sum or average of all log-luminance values without stalling the GPU with CPU readbacks, the system uses parallel reduction. This is typically implemented either through consecutive 2x2 downsampling passes (generating a mipmap chain down to a 1x1 texture) or through a multi-stage compute shader using workgroup shared memory.
A compute shader approach achieves this in fewer dispatches by summing values across local threads:
#version 450
layout(local_size_x = 16, local_size_y = 16) in;
layout(binding = 0) uniform sampler2D u_InputHDR;
layout(std430, binding = 1) buffer OutputBuffer {
float g_AveragedLogLum;
};
shared float sharedLogLum[256];
void main() {
uint localIndex = gl_LocalInvocationIndex;
ivec2 globalCoord = ivec2(gl_GlobalInvocationID.xy);
ivec2 texDim = textureSize(u_InputHDR, 0);
// Read and clamp to bounds
float logLum = 0.0;
if (globalCoord.x < texDim.x && globalCoord.y < texDim.y) {
vec3 color = texelFetch(u_InputHDR, globalCoord, 0).rgb;
logLum = getLogLuminance(color);
}
sharedLogLum[localIndex] = logLum;
barrier();
// Parallel tree reduction within the workgroup
for (uint stride = 128; stride > 0; stride >>= 1) {
if (localIndex < stride) {
sharedLogLum[localIndex] += sharedLogLum[localIndex + stride];
}
barrier();
}
// Write out partial sum or average
if (localIndex == 0) {
// Multi-pass reductions store intermediate results;
// a final 1x1 pass divides by total pixel count.
}
}When reduced down to a single 1x1 value, the final sum is divided by
the total pixel count, and the exponential function exp()
is applied to retrieve the final geometric average luminance (\(L_{avg}\)).
3. Temporal Adaptation
The human eye does not instantaneously adjust to exposure shifts. Rhodopsin regeneration causes dark adaptation to take longer than light adaptation. This physical response is modeled via an exponential decay function between the previous frame's luminance and the current target luminance:
\[L_{adapted} = L_{prev} + (L_{target} - L_{prev}) \times (1.0 - e^{-\Delta t \times \tau})\]
In this equation, \(\Delta t\) represents delta time in seconds, and \(\tau\) represents the adaptation rate constant (using different rates depending on whether \(L_{target} > L_{prev}\)).
In GLSL, this is evaluated in a 1x1 compute pass that reads the current frame's average luminance and updates a persistent 1x1 adaptation buffer:
float adaptLuminance(float currentLum, float previousLum, float deltaTime) {
const float TAU_LIGHT = 2.0; // Faster adaptation to bright areas
const float TAU_DARK = 1.0; // Slower adaptation to dark areas
float tau = (currentLum > previousLum) ? TAU_LIGHT : TAU_DARK;
return previousLum + (currentLum - previousLum) * (1.0 - exp(-deltaTime * tau));
}4. Exposure Application
The final adapted luminance is used to map dynamic scene values to displayable ranges during the tone mapping pass. The exposure scale factor is determined using the middle-gray key value:
\[\text{Exposure} = \frac{\text{KeyValue}}{L_{adapted}}\]
// Tone mapping fragment shader
uniform sampler2D u_SceneColor;
uniform float u_AdaptedLuminance;
uniform float u_MiddleGray; // Typical values: 0.18 to 0.40
in vec2 v_TexCoord;
out vec4 fragColor;
void main() {
vec3 hdrColor = texture(u_SceneColor, v_TexCoord).rgb;
// Calculate final exposure factor
float exposure = u_MiddleGray / max(u_AdaptedLuminance, 0.001);
vec3 exposedColor = hdrColor * exposure;
// Apply tone mapping operator (e.g., Reinhard or ACES)
vec3 ldrColor = exposedColor / (exposedColor + vec3(1.0));
fragColor = vec4(ldrColor, 1.0);
}This completes the pipeline: high-frequency details are compressed via log transformations, parallelized across shader cores, smoothed over time via exponential blending, and mapped back to the tone mapping stage without inducing CPU-GPU synchronization stalls.