Dynamic Tone Mapping Exposure via GLSL Compute
Dynamic tone mapping exposure simulates how the human eye or a physical camera adapts to changing lighting conditions by continuously calculating the average luminance of a rendered scene and modulating the final display exposure. In modern rendering pipelines, this is achieved using GLSL compute shaders to perform parallel reductions—converting scene color buffers into log-luminance values, hierarchically downsampling the data across GPU workgroups, temporally smoothing the result to prevent sudden exposure jumps, and applying the calculated exposure value to a tone mapping curve.
1. Calculating Log Luminance
Direct arithmetic averages of raw RGB values can be disproportionately skewed by small, intensely bright specular highlights or direct light sources. To ensure a stable metric representing perceived brightness, the scene’s linear RGB color is converted to scalar luminance (\(Y\)), transformed into log space, and clamped to a valid operational range.
The standard photometric luminance conversion is defined as:
\[Y = 0.2126R + 0.7152G + 0.0722B\]
To prevent taking the logarithm of zero, a small epsilon value is added:
\[\text{LogLuminance} = \log(Y + \epsilon)\]
2. Parallel Reduction via Compute Shaders
Calculating the average of millions of pixels on the CPU introduces severe latency. Instead, a GLSL compute shader executes a parallel reduction directly on the GPU using thread group shared memory.
A typical compute reduction pattern involves:
- Thread Coalescing: Threads read multiple texels
from the high-dynamic-range (HDR) color buffer, calculate their
log-luminance, and store the partial results into
sharedmemory. - In-Group Reduction: Threads synchronize via
barrier()and hierarchically sum values in shared memory using a binary tree structure (halving active threads each iteration). - Atomic or Multi-Pass Output: The first thread in each workgroup writes the group’s accumulated value to an intermediate buffer.
#version 450 core
layout(local_size_x = 16, local_size_y = 16) in;
layout(binding = 0) uniform sampler2D u_HDRScene;
layout(std430, binding = 1) writeonly buffer LuminanceBuffer {
float g_WorkgroupLuminance[];
};
shared float s_Luminance[256];
void main() {
uint localIndex = gl_LocalInvocationIndex;
ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
ivec2 texSize = textureSize(u_HDRScene, 0);
float logLum = 0.0;
if (texelCoord.x < texSize.x && texelCoord.y < texSize.y) {
vec3 color = texelFetch(u_HDRScene, texelCoord, 0).rgb;
float lum = dot(color, vec3(0.2126, 0.7152, 0.0722));
logLum = log(max(lum, 0.0001));
}
s_Luminance[localIndex] = logLum;
barrier();
// Parallel tree reduction within shared memory
for (uint stride = 128; stride > 0; stride >>= 1) {
if (localIndex < stride) {
s_Luminance[localIndex] += s_Luminance[localIndex + stride];
}
barrier();
}
if (localIndex == 0) {
uint groupIndex = gl_WorkGroupID.y * gl_NumWorkGroups.x + gl_WorkGroupID.x;
g_WorkgroupLuminance[groupIndex] = s_Luminance[0];
}
}Depending on the resolution, subsequent compute dispatches reduce the intermediate buffer down to a single \(1 \times 1\) scalar value containing the sum of all log-luminance samples. Dividing this sum by the total pixel count and evaluating the exponential function yields the geometric mean:
\[L_{\text{avg}} = \exp\left(\frac{1}{N} \sum \text{LogLuminance}\right)\]
3. Temporal Adaptation
Instantaneous exposure adjustments cause harsh flickering during rapid lighting transitions. To simulate retinal or mechanical adaptation, the current frame’s average luminance is blended into the previous frame’s adapted luminance using an exponential decay filter driven by frame delta time (\(\Delta t\)).
The adaptation formula adjusts differently for bright-to-dark versus dark-to-bright conditions:
\[L_{\text{adapted}}(t) = L_{\text{adapted}}(t-1) + \left(L_{\text{avg}} - L_{\text{adapted}}(t-1)\right) \cdot \left(1.0 - \exp(-\Delta t \cdot \tau)\right)\]
Where \(\tau\) represents the adaptation speed parameter. A dedicated \(1 \times 1\) compute shader pass reads the previous frame's luminance from a persistent buffer, performs this integration, and stores the updated scalar.
#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer AdaptedLuminanceBuffer {
float g_AdaptedLuminance;
};
uniform float u_CurrentAverageLuminance;
uniform float u_DeltaTime;
uniform float u_Tau; // Adaptation rate
void main() {
float lastLum = g_AdaptedLuminance;
float currentLum = u_CurrentAverageLuminance;
// Exponential smoothing
float factor = 1.0 - exp(-u_DeltaTime * u_Tau);
g_AdaptedLuminance = lastLum + (currentLum - lastLum) * factor;
}4. Applying Exposure to Tone Mapping
Once \(L_{\text{adapted}}\) is determined, the exposure multiplier (\(E\)) is computed to map the scene's average luminance to a target middle-gray value (\(K\), typically \(0.18\)):
\[E = \frac{K}{\max(L_{\text{adapted}}, \epsilon)}\]
This multiplier is bound as a uniform or uniform buffer object (UBO) for the post-processing fragment or compute pass. The incoming HDR color is scaled by \(E\) before being compressed into standard dynamic range (SDR) via a tone mapping operator such as ACES, Reinhard, or Khronos PBR Neutral:
#version 450 core
in vec2 v_TexCoords;
out vec4 FragColor;
layout(binding = 0) uniform sampler2D u_HDRScene;
layout(binding = 1) uniform sampler2D u_AdaptedLum;
vec3 ACESFilm(vec3 x) {
float a = 2.51;
float b = 0.03;
float c = 2.43;
float d = 0.59;
float e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
void main() {
vec3 hdrColor = texture(u_HDRScene, v_TexCoords).rgb;
float adaptedLum = texelFetch(u_AdaptedLum, ivec2(0, 0), 0).r;
// Calculate final dynamic exposure
float middleGray = 0.18;
float exposure = middleGray / max(adaptedLum, 0.001);
vec3 exposedColor = hdrColor * exposure;
vec3 mappedColor = ACESFilm(exposedColor);
FragColor = vec4(mappedColor, 1.0);
}By decoupling luminance reduction into shared-memory compute shaders and running temporal updates in a single dispatch step, dynamic exposure operates with minimal GPU overhead and avoids CPU-GPU pipeline stalls.