Tone Mapping – Handling What’s Brighter Than White

A whole blog post on tone-mapping. In a home-brewed renderer.  The target audience may be small, but you’re here and that’s what matters.

In computer graphics, colors have to become numbers. Every pixel will store red, green and blue “channels” with one number assigned to each. That number could be any type: an int, a float or a byte. And that choice will determine how we store our image. If we choose a byte, we have 8 bits, which means we can store numbers from 0 to 255. When it’s time to assign black and white to the number range, it’s natural to choose 0 and 255, the bottom and top of the number’s storage capacity. That would mean:

BLACK = (0, 0, 0) and WHITE = (255, 255, 255)

But the real world is more complicated than 8 bit color channels. What do we do when we encounter a color even brighter than our brightest “white”? Tone mapping is the solution.

Here is a white Cornell box with a familiar glass bunny looking back at us stoically. The sphere-light by the ear is very bright – in fact, it emits sixty times the value we have chosen as our brightest white. Is a brightness like that something typical of the real world? No, but only because the real world has a much, MUCH wider range. For example, a sunlit day is about 10 to 100 times more light than an overcast day. Sunlight is around 2000 times brighter than a typical office. And 100,000 times brighter than a candle (at 1 meter). Even that candle is 4 times brighter than the full moon!

We often end up with rendered pixels well outside the range we consider valid. If we naively try to clamp our pixel values to fit in the range, we can end up with situations like the left image, an overexposed render from DeluxeRender. If we try to slide the values into range, we can end up like the right image, underexposed- too dark and muddy to make out and still not capturing the light. (The image on the right is the exact same render, exposed down four stops. Same rays, same bounces, nothing re-computed, just swapping in a different digital ‘film’ on output.) 

A bright Cornell box. Left: the ceiling light is a blown-out white slab. Right: the same render exposed down four stops, revealing the light's shape and warm gradient.
Left: the “Stoic Bunny” render fresh from the oven.  Right: the same pixels, exposed down four stops at save time.
ScenePixel value
Black wall0.0
White wall1.0
Bright light60.0
Sun100000.0

There is nothing mathematically special about 1.0. It’s just the brightness we eventually decide to call “display white.”

How long can we put this off?

A ray tracer works in floating point with black and white mapped to (0.0, 0.0, 0.0) and (1.0, 1.0, 1.0). When a ray lands on a surface, the light it gathers is just a number – red, green, blue, each a float. There is nothing stopping that number from being 3.0, or 12.0, or, if you point a camera at the sun or a supernova, some truly silly figure. Color values on this scale are called scene referred, because the values are relative to the brightness of the scene, without any thought to the complexities and limitations of storage or display.

A PNG, on the other hand, is 8 bits per channel. Zero to 255. The moment you save a picture, every one of those unbounded floats has to become a small integer between 0 and 255. Anything at or above 1.0 gets clamped to pure white, “min(value, 1.0)", and the result gets mapped to values in 0-255 before saving.

In photography, this clamp was the light sensitive crystals of silver halides becoming saturated in the film, for example. They can physically only register a certain amount of light. With digital film, we have to handle the clamp somehow ourselves.

We could decide to save our image with more bits, in floating point using EXR files, to avoid this clamping. Nothing in the data has to be given up as long as we have the extra disk space for larger files. We can defer our clamping by saving all the information faithfully. Yet we still have to display the image eventually, and at that point we must have decided what “white” is going to be, even if it’s just the brightest color your display can show. We call this image display referred because its values are set and scaled relative to display hardware showing the colors and values the way we want them.

My renderer had been doing that for a long time right in the shading code, the instant a color was computed, one ray sample at a time, I clamped it to white (1.0) and moved on. By the time a final pixel existed, everything brighter than white had already been rounded off and forgotten. Exposing the image down later would not bring brighter information into the visible range. It would just make a dimmer shade of the same truncated white – because the “haircut” had already happened.

This is the difference between a display-referred and a scene-referred image. My renders were display-referred from the first bounce: crushed into “what a monitor can show” before I’d even finished rendering. I wanted them scene-referred – to hold the actual light, unclamped, and only crush it down to a monitor at the very end, where I could see it happening, on the digital version of film.

Moving the trim to the last inch of the pipeline

The fix sounds almost too simple: stop clamping during shading, and do the whole clamp-and-grade once, at save time.

The buffer was already there, as it happens. Deluxe Render’s image class had been carrying a float version of every pixel alongside the 8-bit one for ages – I just used to stomp on it with clamped values on the way in. So the change was mostly a matter of not doing something: don’t clamp the sample, don’t gamma it, let the float buffer hold the real, scene-linear number. Then, when it’s time to write a file, run every pixel through a single display transform:

scene-linear  ->  exposure  ->  contrast  ->  offset  ->  tone mapping  ->  gamma  ->  clamp [0,1]

That clamp at the very end is now the only clamp in the whole pipeline. Everything to its left is unbounded floating point. And because exposure is just a multiply that happens before the clamp, dialing it down pulls a blown highlight back into range instead of dimming a white smear. We decide what to keep before throwing anything away.

Below are the pictures to go with those arrows- the same HDR render written to a file with the pipeline cut off after each stage. First, the scene-linear buffer saved naively: nothing between the floats and the file but the clamp, and it shows – the bright half of the room slams into white, and the bunny goes a radioactive orange because the clamp hits each color channel separately, skewing every hue that clips.

Second, the grade – here just a two-stop exposure pull – brings the values down into a range worth encoding; retaining the information, but also looking dim and lifeless, because no one has told these pixels about monitors yet. Third, the ACES tone mapping folds what’s still above 1.0 back under it – the highlights get their shape back, but the image sits dense and heavy, because this is a display-linear signal on a screen that expects gamma. Tone mapping is usually not clamping. Tone mapping is a compression operator. Clamping is merely the simplest, and usually worst, possible tone mapper.

Gamma lifts the midtones and opens the shadows into the final image. That last jump, three to four, is exactly what gamma does. Great, but still, why use gamma?  Gamma compensates for the nonlinear response expected by standard displays while also allocating more code values where human vision is most sensitive, making compression more efficient. 

In our scene, all the math is linear, but our perception of brightness is much closer to logarithmic than linear. What we think of as one “stop” up or down in our perception actually brightens or darkens the image by a factor of 2!  Going four stops down cuts the signal to 1/16 the original value.  This is one reason “18% grey” feels like middle grey to human viewers. We need brighter things even brighter for them to feel bright.

The display pipeline as four images: scene-linear clamped naively, then graded with a two-stop exposure pull, then ACES tone mapped, then gamma encoded into the final image.
The pipeline arrow stages on the same render: (1) scene-linear straight into a file – the clamp alone, blown and hue-skewed; (2) + grade (exposure -2); (3) + ACES tone mapping, still display-linear, so it reads dark; (4) + gamma: the final image. The clamp to [0,1] runs last in every one of them – it’s the one step you can’t opt out of when writing 8-bit pixels.

Now the same arrow on a single row of real pixels. For this figure I squeezed the image vertically to emphasise the horizontal scanline we’re looking at, and dropped a perfect chrome ball into the scene, floating above the bunny, to get us a super bright reflection of the light source.  I ran the scanline straight through the spot where the ball reflects the light.

Read it one chart at a time, top to bottom. The first panel is what the ray tracer actually computed: the walls ride just above 1.0, and the reflection of the light in the ball spikes to exactly sixty – the same radiance as the light itself. Real mirrors lose a certain percentage of what they reflect, but in rendering we can use a perfect mirror, which doesn’t dim what it hands you.  The HDR buffer doesn’t either; the proof is that flat-topped spike, sixty in the light and sixty again one bounce later.

The mirror sphere tells the other half of the story too. This Cornell box has five white walls – the sixth side, the one we’re peering through, is open. And the ball reflects that black emptiness right back at us – the black disc at its centre is the missing wall behind the invisible camera. So one row of pixels runs the scene’s entire range, from a mathematically exact zero to sixty times display white.

Now the fun part: fitting an ocean into a teacup using tone mapping

Exposing down is one way to deal with a highlight brighter than white. But it’s a blunt one – to rescue the light, I had to make the whole room dark. That’s the classic HDR problem in one sentence: your scene holds a luminance of 60, and your monitor stops at 1.0. How do you fit the ocean into the teacup without either overflowing it or draining the ocean? This is the part called tone mapping.

The tone mapping – I’m using ACES here, which we’ll dig into in the next section – gently folds the spike back under 1.0 without flattening it into a dumb white bar; the dotted “linear clip” ghost is what a plain min(x, 1.0) would have thrown away instead. Then gamma lifts the darks so the shadowed corners become readable. Same three channels, three stages, nothing re-rendered.

One scanline through the whole pipeline: the scene-linear R/G/B signal spiking to exactly 60 at the chrome ball's reflection of the light, the same signal squashed back under 1.0 by ACES tone mapping, and the final signal after gamma.
One row of pixels, carried top to bottom through the pipeline. Top: the scene-linear signal the ray tracer computed – the chrome ball’s reflection of the light spikes to exactly 60, the full radiance of the light itself. The row’s first and last pixels read exactly 0.0 – rays that miss the box through its open side – and the ball reflects that same open side as the black disc at its centre. Middle: Tone mapping with ACES folds the spike back under 1.0 (the dotted line is what a hard clip would have kept – a flat-topped white bar). Bottom: gamma lifts the shadows into the final pixel values. 2,309 of the row’s 2,560 pixels sit above 1.0 – nearly the entire image is “brighter than white” until the tone map and gamma do their work.

 

In tone mapping, there is no single right answer – it’s a taste decision as much as a technical one. So I gave the renderer a few options. Here’s the same HDR render put through four tone mapping approaches, all at the same exposure:

One render at two stops down, four tone mapping approaches. Linear clips; Reinhard tone mapping rolls off gently; ACES tone mapping adds filmic contrast; AgX tone mapping keeps the hue stable as it desaturates toward white. Two stops matters: at the render’s own exposure nearly every pixel is above 1.0, so all four operators are just squashing a blown image and they come out indistinguishable. The ACES tile here is the same image as stage 4 of the pipeline figure above – same render, same settings.

And here are those same four tone mapping operators visualised as plain curves – the actual functions, straight out of the code:

Tone-mapping curves: scene-linear input versus display output for Linear, Reinhard, ACES, and AgX.
Horizontal axis: scene brightness (1.0 is display white; the light in this scene reaches 60, and post grade it reaches 12). Vertical axis: what actually lands in the pixel. Linear runs straight up and slams into the ceiling at 1.0 – everything past white is gone. The other three bend that runaway brightness back into range, each with a different shoulder: Reinhard tone mapping is lazy and never quite reaches white, ACES tone mapping has a punchy toe and a firm shoulder, AgX tone mapping lifts the low end and rolls the top off gently.
  • Linear is the identity, meaning it maps every input directly to the output untouched. Later, the pipeline applies gamma and clamps. Linear is the “no tone mapping” reference. The light clips to white; the walls are exactly their computed color. Perfectly valid, and often not what you want.
  • Reinhard is the oldest trick in the book: divide every value by itself-plus-one, x / (1 + x). It’s a soft, cheap curve that squashes [0, ∞) into [0, 1) so nothing ever fully clips. Bright things get gently compressed and a little desaturated. It’s gentle to a fault – highlights can go milky. Tone mapping lite.
  • ACES is many things, but I’m referring here to Krzysztof Narkowicz’s compact fit of the ACES filmic tone curve. It’s an S-shaped curve with a proper shoulder – it rolls the highlights off the way film stock does, holds contrast in the midtones, and generally makes things look “cinematic” without you having to think about it. This is the tone mapping strategy most renderers reach for.
  • AgX is the new kid (Troy Sobotka’s operator, now the default tone mapping in Blender). Its party trick is hue stability. When you push a saturated color way up in brightness, older operators tend to skew the hue and then snap to a neon primary right before they clip – that electric-magenta-highlight look. AgX instead desaturates gracefully toward white as things get bright, the way an over-exposed photograph does. Look at how it handles the bunny’s saturated amber near the light versus ACES.  Side note: I have seen AgX used as a shorthand for silver halides in a photography context. Ag is the symbol for silver, and X stands in for one of the halogens, typically bromine, chlorine, and iodine. Silver halides are the compounds that expose an image in traditional film. My guess is that the name AgX could be a little nod to the compounds that used to handle tone mapping gracefully for us through physical saturation!

None of these is “the correct tone mapping.” They’re all able to take the full range values the renderer maintains. Tone mapping is subjective.

A thermal camera for your render

While I was adding this feature, I added one more tone mapping operator, a diagnostic I call Heat. It throws away scene color entirely and maps luminance to a false-color ramp, with one deliberate cliff: the ramp changes hue exactly at 1.0.

The bright Cornell rendered normally next to a Heat false-colour view. The room climbs from cool blue up through yellow and red toward the white-hot light.
With the light cranked up, a big slice of the room pushes past 1.0 – and Heat shows exactly which slice. Cool blues are safe; the hue flips at 1.0; yellow-orange-red climbs the ceiling toward the light, one, two, three-plus stops over. All of that color is data your monitor can’t actually show – and it was there in the buffer the whole time.

Cool blues and greens are comfortably below white. Yellow is riding right on the 1.0 clip line. And red-to-magenta-to-white flags things that are one, two, three-plus stops over – detail your monitor physically cannot show. It’s a thermal camera for exposure. One glance tells me where a scene is blowing out and by how much.

The little knobs before the crush

The three steps between “scene-linear” and “tone map” are a tiny grading stack – the same exposure / contrast / offset you’d find at the top of any color-correction tool, just living inside the renderer now.

The same render at four grades: neutral, punchy high contrast, crushed blacks via negative offset, and a lifted faded-film look.
Grading the scene-linear signal before the tone mapping – same render all four times, only the grade changes. Top left: neutral (exposure -3, the reference). Top right: punchy – contrast 2.0 snaps the midtones around the 18% grey pivot which really punches up saturation. Bottom left: crushed blacks – contrast 1.6 with offset -0.04 pulls the floor below zero, so the shadows go genuinely black and the room is almost too dark to see. Bottom right: faded film – offset +0.10 lifts the floor so nothing ever reaches black, with contrast 1.3 to keep the mids dreamy.

Contrast pivots around 18% grey (the old photographer’s mid-tone), so turning it up snaps the blacks down and the brights up around that anchor; turning it down flattens everything toward grey. Offset just lifts or crushes the whole floor – a faded-film “lift,” basically. None of it is exotic. What’s new is only that it happens on the unclamped signal, before anything gets thrown away, so it behaves the way a colorist would expect instead of fighting an image that’s already been crushed.

What it’s all for

Here is the renderer doing the thing the pipeline exists to serve — a glass bunny in a white room with tone mapping.  Rendered with radiosity caustic in less than a minute.  More on this later:

An amber glass Stanford bunny in a white Cornell box, lit by a single sphere light. Warm caustic light pools on the floor beneath it.
Amber glass, Beer’s-Law absorption, a caustic pooling on the floor under the
bunny. Using Radiosity?!

 

What’s next

There’s more on the shelf in this area now that tone mapping is working and the clamping is at the end. Bloom: take the regions that blew past white, blur them with a big, soft Gaussian, and add that back over the image very lightly – it fakes the way real lenses and eyes bleed light around anything genuinely bright, and it sells “this light is intense” in a way tone mapping alone can’t. More operators are cheap to add behind the same dial – Uncharted 2 / Hable, the Khronos PBR Neutral map, or a Tony McMapface 3D LUT would all just slot in.

And there’s a subtle color bug that deserves its own demo someday: when you clamp red, green and blue independently, a strongly colored highlight clips one channel before the others, so its hue skews as it brightens and a thin, over-saturated ring appears around the blown core. Holding the render in the unclamped HDR buffer is exactly what makes it possible to fix that honestly – compress the brightness while keeping the ratio between the channels intact.

All made possible by Deluxe Render turning over a new leaf, and not throwing away everything brighter than white until the very end.

Leave a Reply

Your email address will not be published. Required fields are marked *