Skip to content
Rendering · OutputIntermediate

HDR & tone mapping

Keep light brighter than 1 through the whole frame, then compress it into what a screen can show.

Live demo coming soon

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 RGBA16F render target, which holds values up to about 65 000. Bloom can then find what’s truly bright (anything above 1), lens effects can work with real intensities, and nothing has been thrown away yet. In WebGL2, rendering into a float target needs the 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 chain, and the order around it is strict:

  1. Shade the scene in linear HDR.
  2. Run effects that need real brightness: bloom, lens flares, motion blur.
  3. Apply exposure, then the tone mapper. The result is linear again, but now 0–1.
  4. Color-grade the mapped image.
  5. Encode to sRGB and write to the screen.

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.

Further reading