ToneMapping
lamps that sum as HDR light in float and are tone-mapped onto the screen instead of clipping, with a flat .normal control for comparison (toneMap(_:), blendMode(.add))
- Run
swift run --package-path Examples Example-Rendering-ToneMapping- Source
- Examples/Rendering/ToneMapping/Sketch.swift
import Ollin
/// Tone-mapping, and the additive blending it exists to serve. The canvas
/// composites in linear floating-point, so light can pile up *past* full
/// brightness. A handful of colored lamps orbit and overlap with
/// `blendMode(.add)`: colors sum as *light* instead of laying over one another,
/// so where the lamps cross, the canvas brightens toward white rather than the
/// topmost shape winning. That is the mode light-accumulation and particle
/// sketches reach for, and because the canvas blends in linear light the sum is
/// physically correct, running well above 1.0 in the overlaps. A tone-map
/// decides what happens to those out-of-range values on an ordinary screen.
///
/// Press any key to cycle the comparison, and watch the bright cores:
///
/// • **flat**: the same lamps under `blendMode(.normal)`, each laid over the
/// last, with no summing, no glow, and nothing past 1.0 to map. The
/// control case: what additive light buys is the difference from here.
/// • **`.clamp`**: summed as light, then the standard look: anything past
/// full clips flat to white, so overlaps turn into a hard, detail-less blob.
/// • **`.reinhard`**: `x / (1 + x)` squeezes every value under 1.0, so the
/// cores keep their hue instead of blowing out, if a touch flat.
/// • **`.aces`**: a film-like S-curve; highlights roll off smoothly with
/// contrast and saturation held in the midtones, the glow look.
/// • **`.neutral`**: the hue-preserving curve; a bright saturated core keeps
/// its own color where the per-channel curves turn it toward a primary,
/// and the roll-off is gentler than film.
///
/// Drag left↔right to change `exposure` (the brightness dial): push it up and even
/// `.aces` saturates; pull it down and the whole scene dims into its falloff. This
/// is the precision the depth-of-field "sandpainting" track relies on: faint light
/// summed in float, then mapped down at the very end.
@main
final class ToneMapping_Example: Sketch {
/// The comparison table: four tone maps over summed light, then the flat
/// `.normal` control where the lamps just stack.
private let modes: [(name: String, additive: Bool, map: ToneMap)] = [
("ACES", true, .aces),
("Neutral", true, .neutral),
("Reinhard", true, .reinhard),
("Clamp (SDR)", true, .clamp),
("flat (.normal)", false, .clamp)]
private var modeIndex = 0
// Six lamps, each a saturated light on its own slow Lissajous orbit.
private struct Lamp { var hue, fx, fy, px, py: Double }
private var lamps: [Lamp] = []
override func setup() {
seed(7)
for i in 0 ..< 6 {
lamps.append(Lamp(hue: Double(i) / 6,
fx: random(0.10, 0.22), fy: random(0.10, 0.22),
px: random(.tau), py: random(.tau)))
}
}
override func keyPressed() {
modeIndex = (modeIndex + 1) % modes.count
}
override func draw() {
background(Color(hex: 0x05060A))
let mode = modes[modeIndex]
// Drag to expose; otherwise sit at a value that pushes overlaps past 1.0.
let exposure = mouseIsPressed ? map(mouseX, 0, width, 0.3, 3.0) : 1.5
toneMap(mode.map, exposure: exposure)
// Sum every lamp as light, or lay them flatly for the control case.
blendMode(mode.additive ? .add : .normal)
noStroke()
let r = shortSide * 0.26
for lamp in lamps {
let cx = width / 2 + sin(time * lamp.fx * .tau + lamp.px) * width * 0.24
let cy = height / 2 + sin(time * lamp.fy * .tau + lamp.py) * height * 0.24
// A bright, saturated core fading to clear. Additively, the rims wash
// together and the cores stack into HDR highlights; flatly, the last
// lamp drawn just covers the ones beneath.
let core = Color(hue: lamp.hue, saturation: 0.85, brightness: 1, alpha: 0.95)
fill(.radial(center: Vector2(cx, cy), radius: r,
Ramp(stops: [(0.0, core),
(0.5, Color(hue: lamp.hue, saturation: 0.9,
brightness: 1, alpha: 0.35)),
(1.0, Color(hue: lamp.hue, saturation: 0.9,
brightness: 1, alpha: 0))])))
drawCircle(cx, cy, r)
}
blendMode(.normal)
let expoNote = mouseIsPressed ? String(format: "exposure %.2f", exposure) : "drag to expose"
drawCaption("tone-map: \(mode.name) · \(expoNote) · press a key to cycle")
}
}