Ollin

HDR & tone mapping

Ollin composites every frame in a linear, high-precision floating-point buffer, then maps it to the screen in a final pass. That gives you two things:

  • Color can exceed full brightness. Additive light that builds up through blendMode(.add), a bright gradient, or overlapping glows all push past 1.0. The intermediate buffer holds those values instead of clipping them during the render. You use toneMap(_:) to choose what happens to them when the frame reaches an ordinary 8-bit screen.
  • No 8-bit banding. Smooth gradients and the many translucent layers a busy sketch stacks all composite at float precision. So a gradient that an 8-bit intermediate would break into visible bands stays smooth. This happens automatically, with nothing to turn on.

By default a sketch behaves exactly like a standard renderer, so anything brighter than full clips to white. You can instead opt into a curve that rolls highlights off, which is what the rest of this page covers.

Contents

toneMap(_:exposure:)

Choose how Ollin brings out-of-range color (brighter than 1.0) back into the range the screen can show.

override func setup() {
    toneMap(.aces)          // roll highlights off with a film-like curve
}

The tone map is a frame-wide setting, not per-shape state. One mapping applies to the finished frame, so unlike fill or blendMode it is not saved by withState { }. Set it once in setup(), and it stays in force until you change it. If you call it with no arguments, it uses .reinhard:

toneMap()                   // == toneMap(.reinhard, exposure: 1)

Combine it with additive light accumulation to get the glow look:

override func draw() {
    background(.black)
    toneMap(.aces, exposure: 1.6)
    blendMode(.add)                 // lights sum past 1.0…
    // …draw bright overlapping shapes; ACES rolls the hot cores off smoothly
}

exposure

exposure scales the whole image before the tone map, like the brightness dial on a camera. It defaults to 1 whichever curve you name, so toneMap(.aces) leaves the brightness where it was. Turn it up to bring faint accumulation into view. Turn it down when a scene saturates:

toneMap(.aces, exposure: 2.0)   // brighter, more of the scene pushed up the curve
toneMap(.aces, exposure: 0.5)   // dimmer

It applies under every mode, including .clamp, where it only brightens the image before the clip.

The modes

ModeCurveLook
.clampclip to [0, 1]The default. Standard dynamic range, so anything past full saturates to white, exactly as before. An in-range frame renders identically.
.reinhardx / (1 + x)Compresses every value to under 1.0, so highlights never fully clip. Cheap and gentle, but it tends to desaturate bright areas.
.acesACES filmic S-curveRolls highlights off while keeping contrast and saturation in the midtones. Use it for glow and light-accumulation looks.
.neutralthe Khronos PBR Neutral curveKeeps a bright color's hue. .reinhard and .aces bend each channel on its own, so a bright saturated color drifts toward the nearest primary: an orange of linear (4, 1, 0.2) comes out yellow. This one compresses the three channels by one factor and only eases the color toward white as its peak nears the ceiling, so the orange stays orange. Below about 0.76 of full a color keeps its hue exactly and loses only a small toe (0.04 of linear light at most), and the roll-off above is gentler than film: the choice when the colors are the point.
Three overlapping tinted lamps under the default clamp tone map: the entire overlapping middle blows out to a flat white slab with hard seams The same three lamps through the ACES film curve: the middle stays bright but keeps its warm, mint, and blue tints, rolling off softly like film The same three lamps through the hue-preserving curve: each lamp's core keeps its own warm, mint, or blue hue where the film curve turned them toward a primary, and the roll-off is gentler

See the Rendering/ToneMapping example. Colored lamps orbit and overlap additively, and a key cycles through the four mappings. Watch the bright cores clip, compress, roll off, or keep their hue as the mapping changes.

Notes

  • .clamp is byte-for-byte the old look. The float pipeline does not change a standard-dynamic-range sketch. With the default .clamp and exposure: 1, an in-range frame renders the same as it always did, so tone mapping is opt-in.
  • The sandpainting look needs a divide, not only a curve. Faint samples summed additively need float precision, or they quantize away before they accumulate. A tone map then rolls the built-up light onto the screen. But a sum on a noClear surface keeps growing, so the tone map only postpones the moment the picture goes white. The look is built on the running mean instead, which is the sum divided by the number of passes drawn. An Accumulator keeps that mean. Filter.develop then applies exposure, a Reinhard roll-off, and a ground added after the curve. The depth-of-field particle rendering is built that way.
  • Output is 8-bit unless you ask otherwise. The screen and exported PNGs are 8-bit sRGB by default. The float precision lives in the intermediate buffer. That is why banding disappears and HDR values survive compositing, even though the final image is 8-bit. At that 8-bit step, a triangular dither keeps gradients smooth. You can widen the last step through colorOutput. Set it to .wide and Ollin presents through Display P3 in float. Under .extended, values above 1.0 stay as highlights brighter than white instead of being mapped down.
  • Exports tone-map identically. The headless still --export, the sequence, video, and GIF exports, and live frame sharing all run the same present pass. Frame sharing covers Syphon and the virtual camera, so what you export and share matches what you see.

↑↓ move ↩ open