HDR & tone mapping
Keep light brighter than 1 through the whole frame, then compress it into what a screen can show.
How it works
Real light covers an enormous range. Sunlit snow is tens of thousands of times brighter than the inside of a room at night, and a lamp filament is brighter still. A screen shows a range of maybe a thousand to one, and an 8-bit image stores 0 to 1. Shading math doesn’t care about that limit: a strong light on a white surface easily gives 4.0, and an emissive sign might be 20.0.
If those values go straight into an RGBA8 target, everything above 1 is clipped to 1. Highlights turn into flat white
patches with no shape. Bright colors also change hue as they clip: a strong orange (3.0, 1.2, 0.2) becomes
(1.0, 1.0, 0.2), a yellow, because red and green both hit the ceiling.
High dynamic range (HDR) rendering is the fix in two parts: keep the real values for as long as possible, then map them to the screen on purpose, once, at the end.
Keep the values: float targets
The scene renders into an Render target A texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them.RGBA16F render targetA texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them.EXT_color_buffer_float
extension.
“HDR rendering” here means the internal range. It is a different thing from an HDR monitor; on an ordinary screen the result still has to fit 0–1.
Exposure
A camera picks how much light reaches the sensor; a renderer does the same with one multiply. Exposure is often given in stops, where each stop doubles the light:
color *= exp2(exposureStops); // +1 is twice as bright, -1 half
Tone mapping
A tone mapper is a curve that takes 0 to infinity and returns 0 to 1. It leaves dark and middle values almost alone and bends the bright ones so they approach 1 without ever reaching it. The simplest is Reinhard’s:
vec3 reinhard(vec3 c) {
return c / (1.0 + c);
}
0.1 stays about 0.09, 1.0 becomes 0.5 and 10.0 becomes 0.91: highlights keep a gradient instead of clipping. Its weaknesses show quickly, though. Mid-tones get darker (1.0 halves), so scenes look flat and gray, and applied to each channel separately it pulls saturated colors toward white at different speeds per channel.
Production curves work harder:
- Filmic curves add a “toe” that deepens the blacks and a “shoulder” that rolls highlights off gently, like film stock.
- ACES comes from the film industry: strong contrast, and very bright values lose their saturation.
- AgX keeps the hue of colors steadier as they brighten, desaturating them toward white in a controlled way.
- Khronos PBR Neutral is built for product renders: base colors between about 0.08 and 0.8 come through nearly unchanged, and only highlights are compressed, without hue shifts.
There is no single right curve. It is an artistic choice, made once for the whole frame.
Where it sits
Tone mapping belongs late in the post-process chainFull-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here. Post-process chain Full-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here.
- Shade the scene in linear HDR.
- Run effects that need real brightness: bloom, lens flares, motion blur.
- Apply exposure, then the tone mapper. The result is linear again, but now 0–1.
- Color-grade the mapped image.
- Encode to sRGBThe curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end.Rendering
sRGB encoding
The curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end.
c ≤ 0.0031308 ? 12.92c : 1.055c^(1/2.4) − 0.055Sourcesand write to the screen.- sRGB (opens in a new tab)Reference
- Gamma Correction (opens in a new tab)Article
Putting it together in the last pass:
vec3 hdr = texture(uScene, vUv).rgb;
vec3 mapped = reinhard(hdr * exp2(uExposure));
fragColor = vec4(linearToSrgb(mapped), 1.0);
Tone mapping before bloom leaves bloom with nothing above 1 to work with. Encoding before tone mapping applies the curve to the wrong numbers.
In Godot
The Environment resource sets the tone mapper: Linear (no curve: values above 1 clip), Reinhard, Filmic, ACES and
AgX, with exposure and white-point settings. Glow, Godot’s bloom, runs on the HDR image before it. A spatial shader
can write ALBEDO or EMISSION above 1 and the engine takes care of the rest.
On this site
The preview frame does no tone mapping: it clamps the final color to 0–1 and encodes it. A card that makes values above 1, like Neon bloom, tone-maps in its own last pass, so the curve is part of the code you copy.