Praxis · M3 · ticket #200 · PRs #250 to #256

How measured light becomes an 8-bit pixel

Ticket #200 built the path from the lit pass to the screen. Lights now carry real units; an exposure set like a camera's scales them; an HDR target keeps their range; a weighted MSAA resolve keeps one bright sample from whitening an edge; the tone mapping of Polyphony Digital's Gran Turismo 7, GT7, maps the result to the display; and a blue-noise dither hides the steps of 8-bit output. This guide follows one pixel along that path, with live demos of the exposure, the resolve, the tone curve and the dither.

Provenance. #200 landed in seven parts, all merged on 2026-10-22. Part 1, PR #250, gave the Reference scenes' lights photometric units and each scene an EV100; part 2, PR #251, added the HDR target and a tonemapping pass that only clamped; part 3, PR #252, weights the lit pass's colour for the resolve; part 4, PR #253, ports GT7 under its oracles; and parts 5 to 7, PRs #254 to #256, put GT7 in the pass, encode sRGB in the shader and add the dither. Every number here was verified in the work on #200 on 2026-10-22, and the code it quotes was checked again against main at 69e02dc on 2026-10-24.

Chapter 1 · PR #250

Light in real units

Before #200 the scenes' lights had no units: the sun's illuminance was 1, and every other light was a multiple of it. #200 gives them the units glTF defines for light. A camera-style exposure, the subject of chapter 2, then brings the results back to the range a display shows.

ADR 0023 measures light as glTF does, in photometric units. Three quantities cover every light:

  • Illuminance, light arriving at a surface, in lux: the sun, a DirectionalLight, and the ambient light that every surface receives from all around.
  • Luminous intensity, what a point light sends into each direction, in candela, as KHR_lights_punctual gives a PointLight.
  • Luminance, light leaving a surface towards the eye, in cd/m²: emissive surfaces, the background, and what the lit pass computes for each pixel.

Every Reference scene is lit from one figure, the sun's 100,000 lx, "direct sunlight" (SUN_ILLUMINANCE in src/scenes/mod.rs). The other lights keep the ratios to the sun that they had before they had units (lights added since are given against it too: town's 1,024 lamps, from PR #273, are 2,000,000 cd each; see Town Explained).

LightQuantityValueAgainst the sun
The sun, a DirectionalLightilluminance100,000 lx"direct sunlight"
Ambientilluminance10,000 lxa tenth
Background, R G Bluminance10,000 · 20,000 · 30,000 cd/m²a tenth to three tenths of its figure
Each of the four coloured point lightsluminous intensity900,000 cdthe sun's illuminance 3 m away
minification's lightluminous intensity30,000,000 cdthe sun's illuminance about 17.3 m away
The Reference scenes' lights, as part 1 authored them in src/scenes/mod.rs. A point light of intensity I gives the sun's 100,000 lx at the distance √(I / 100,000).

For lamps, the point lights look extreme. A point light's illuminance falls with the square of the distance, so matching the sun 3 m away takes 900,000 cd: 900,000 / 3² = 100,000 lx. Those are the ratios the scenes had before units, when the sun's illuminance was 1; part 1 wrote them out in lux and candela. minification's light, 300 times the sun's figure, matches the sun about 17.3 m away, since √300 ≈ 17.3.

The lit pass computes each pixel's luminance from these numbers, in f32. The rest of this guide follows that luminance to the screen, through the chain #200 built:

  1. 1 · Lit passLuminance in f32, times the exposure, clamped to 65504 and weighted by 1/(1 + Y)chapters 1 to 4
  2. 2 · 4x MSAA colourRgba16Float, transient: memoryless on Apple GPUschapter 3
  3. 3 · Hardware resolveA box average of each pixel's 4 sampleschapter 4
  4. 4 · HDR targetRgba16Float, one sample: 29,491,200 bytes at 2560 × 1440chapter 3
  5. 5 · Tonemapping passOne full-screen triangle: undo the weighting, GT7, sRGB encoding, blue-noise ditherchapters 4 to 6
  6. 6 · 8-bit target8-bit UNORM, which stores the shader's codes as they are, so the dither lands on its stepschapter 6
What #200 built, in the order a frame runs it. The tonemapping pass (src/render/tonemapping_pass.rs, src/render/shaders/tonemapping.wgsl) is new: it reads each pixel of the HDR target and writes its display colour.
Chapter 2 · PR #250

Exposure

A camera does not show luminance as it is. Its exposure decides which luminance comes out as white, and a bright scene needs less exposure than a dim one. Praxis sets its exposure as photographers do, as an EV100, and multiplies each pixel's luminance by the resulting scale before storing it.

The scale comes from Lagarde and de Rousiers's course notes, "Moving Frostbite to Physically Based Rendering 3.0" (2014, pp. 85–86). An EV100 gives the exposure:

exposure = 1 / (1.2 · 2^EV100)

The 1.2 is a luminance: 1.2 cd/m² = 78 / (100 · 0.65), the luminance that saturates an ISO 100 sensor at EV100 0, with the notes' lens and vignetting factor q = 0.65. At EV100 0, 1.2 cd/m² therefore exposes to 1. Each EV100 more halves the scale, so the luminance that exposes to 1 doubles. Camera::exposure computes the scale and ev100_for_exposure inverts it (src/camera/mod.rs).

Sunny 16, a sunny day's exposure, is EV100 15, the default of Camera::new; it scales by 1/39,321.6. The Reference scenes set their own: each sets EV100 = log2(100,000 / 1.2) ≈ 16.35, an exposure of 10⁻⁵, one over the sun's illuminance in lux, which was 1 before units. Every value grew by 100,000 and the exposure divides by 100,000, so the lit pass's results came out as before. Part 1 changed no Golden: at most 110 pixels of a scene moved by one 8-bit step.

Presets:
Exposure, 1 / (1.2 · 2^EV100)
Luminance that exposes to 1
SubjectLuminance, cd/m²ExposedOn screen, sRGB codes
A white diffuse surface facing the sun31,831
The background10,000 · 20,000 · 30,000
A sun glint at its peak1.24×10⁸

Live demo. The exposure at an EV100, and what three luminances of the scenes expose to. A white diffuse surface facing the sun reflects 100,000 / π ≈ 31,831 cd/m², by this guide's arithmetic, since an ideal diffuse surface's BRDF is its colour over π; the background is the scenes'; the glint is ADR 0023's peak for a sun glint. Exposed values are clamped to 65504, as the lit pass clamps them (chapter 3). On screen is this page's port of the tonemapping pass, GT7 and sRGB encoding, without the dither (chapters 5 and 6); at 16.35 it shows the background as the codes 88, 123 and 149 that GT7's reference outputs give. Simplified: the page computes in f64, and leaves out the resolve.
Chapter 3 · PR #251

Storing HDR

Exposed values still span a wide range. At the scenes' exposure, a sun glint exposes to 1,240 and a white surface in sunlight to about 0.32, by this guide's arithmetic. So the lit pass stores colour as 16-bit floating-point numbers, which keep that range, and leaves the step down to the display's 0 to 1 to a later pass.

The lit pass computes luminance in f32, and needs to: a sun glint reaches 1.24 × 10⁸ cd/m² before exposure (ADR 0023), far past the largest finite value of fp16, 65504. It multiplies by the exposure first, then clamps to 65504. The clamp settles what the backends would otherwise disagree on: on overflow, Vulkan may store infinity, while the M4 Pro saturates, as measured (ADR 0023); Metal's specification does not say which.

It then weights the clamped colour for the resolve, chapter 4's c / (1 + Y(c)), where Y is luminance by ITU-R BT.709-6's coefficients, and writes it into a 4x MSAA Rgba16Float colour attachment:

exposed = min(luminance × exposure, 65504) stored = exposed / (1 + Y(exposed)) Y(c) = 0.2126 R + 0.7152 G + 0.0722 B

That attachment is transient. The pass resolves it into the HDR target before it ends and discards its samples, so they never need to reach memory; on Apple GPUs it is memoryless. The clear colour, which is the background pre-exposed, is weighted the same way.

The hardware resolve writes the single-sample Rgba16Float HDR target, 8 bytes a pixel: 2560 × 1440 × 8 = 29,491,200 bytes at 1440p. It has the frame's size, so src/render/targets.rs creates it again with the other render targets on every resize. The tonemapping pass then loads it, one texel per pixel.

Chapter 4 · PR #252

The resolve

A pixel on an edge holds four samples that can see different things, and the hardware averages them. If one sample catches a glint far brighter than the rest, the plain average is mostly glint, and once tone-mapped the whole pixel shows white: edges and highlights smaller than a pixel whiten. Averaging with weights that shrink as a sample gets brighter keeps the edge looking as it should.

The hardware's MSAA resolve is a box average: each of the four samples weighs a quarter. Over exposed HDR values, one sample of 100 over three of 0.05 averages to 25.04, which GT7 shows as white, as does a clamp to 1. PR #251, which resolved HDR that way, measured the effect: sub-pixel highlights whitened, and one pbr-sweep pixel went from sRGB code 228 to 255.

Karis's blog post "Tone mapping" (2013) gives a way to get a weighted average out of the hardware's plain one, a reversible weighting. The lit pass stores w = c / (1 + Y(c)) (chapter 3), the hardware averages the stored values, and the tonemapping pass undoes the weighting, c = w / (1 − Y(w)), clamped to 65504 (resolve_weighted and resolve_unweighted). What comes out is the samples' average weighted by 1 / (1 + luminance):

resolved = Σ wᵢ cᵢ / Σ wᵢ, wᵢ = 1 / (1 + Yᵢ)

The proof takes one line. Y is linear, so Y(avg of c/(1+Y)) = avg of Y/(1+Y), and 1 − Y(avg) = avg of 1/(1+Y); dividing the stored average by that leaves Σ w c / Σ w. A stored luminance, Y/(1 + Y), stays below 1, so the attachment never holds a large value. It still needs fp16: a saturated colour's channels can pass 1, and every bright sample's luminance is stored just below 1, where only fine steps tell one from another.

The same four samples resolve to 0.395 weighted, against 25.04 boxed. In the scenes, part 3's weighting pulls partially covered highlight pixels back down, for example from code 200 to 180.

SampleExposedStoredOn screen
1
2
3
4
Box average of exposed values, as PR #251 resolved
Weighted, as part 3 resolves

Live demo. One pixel's four samples, as exposed greys, so each sample's luminance Y equals its value. Type any values; each is clamped to 65504 as the lit pass clamps it. Stored is what the lit pass writes, c / (1 + Y). The box average is the hardware's resolve of exposed values; the weighted resolve is the box average of the stored values, undone. On screen is this page's port of the tonemapping pass, GT7 and sRGB encoding, without the dither. Simplified: the page computes in f64 where the attachment stores fp16, and uses greys where the shader weights each sample by its luminance.

The weighting is cheap. Ticket #189's HDR survey, which HDR Pipeline Explained follows, timed it with its probe on the M4 Pro at 1440p: at most 0.009 ms in a lit pass and 0.018 ms in the final pass. Part 3 measured +4.6 to +7.6 µs a frame (chapter 7).

It has an image cost, which the survey's row 18 records. Sub-pixel highlights lose energy: a glint that covers one sample counts for less than its quarter of the pixel. And blending in the weighted space darkens 50% blends, which will matter for M6's transparency.

Chapter 5 · PRs #253 and #254

GT7

Exposed colour runs from 0 into the thousands; an SDR display shows 0 to 1. A tone curve maps the one onto the other. GT7's darkens the darks along a toe, leaves the mid-tones as the exposure set them and rolls the brights over a smooth shoulder towards white. It also handles colour: a very bright colour fades towards white instead of staying a pure primary.

Units and paper white

Polyphony Digital presented GT7's tone mapping in the SIGGRAPH 2025 Physically Based Shading course, with a sample implementation, gt7_tone_mapping.cpp 1.0, under the MIT licence; Praxis ports its SDR path. GT7 works in frame-buffer units, 1.0 = 100 cd/m². It takes an SDR display's paper white to be 250 cd/m², 2.5 units, and its SDR output is 0.4 · min(blended, 2.5), which runs from 0 to 1.

The curve

The tone curve is the GT tone curve v2, gt7_curve, at GT7's parameters: peak 2.5, alpha 0.25, mid point 0.538, linear section 0.444 and toe strength 1.28. The linear section ends at 0.444 × 2.5 = 1.11, and the curve has three parts:

x < 1.11: (1 − s) · 0.538 · (x / 0.538)^1.28 + s · x s = smoothstep(0, 0.538, x) x ≥ 1.11: k_A + k_B · e^(k_C · x) k = (0.444 − 1) / (0.25 − 1) = 0.7413 k_A = 2.5 · 0.444 + 2.5 · k = 2.963, 1.19 × the peak k_B = −2.5 · k · e^(0.444 / k) k_C = −1 / (2.5 · k)

Below the mid point, a power toe, x1.28 scaled by the mid point, blends by smoothstep into the identity. From 0.538 to 1.11 the curve is the identity itself. Above 1.11 an exponential shoulder rises towards k_A = 2.963, and the final min clamps the result to the peak. k_B and k_C make the shoulder meet the identity at 1.11 with the same value and slope, by this guide's arithmetic.

gt7_curvethe identity, and k_Athe peak, 2.5
Frame buffer, 2.5 × exposed
gt7_curve there
Shown, display-linear and sRGB code

Live demo. gt7_curve as the shader writes it, from frame-buffer 0 to 6, with the identity, the peak and k_A. The blue band is the toe's blend, below the mid point; the warm band is the shoulder. Drag across the plot, or use the slider, to read a grey: its frame-buffer value, the curve there and what the pass shows, through this page's port of the whole operator. A grey has no chroma, so the colour path changes nothing for it, and what it shows is min(curve, 2.5) / 2.5. Simplified: the page computes in f64, where the shader uses f32.

Colour

The curve applied to each channel alone gives what the sample calls the skewed result. GT7 blends it 40/60 with a version that keeps the input's chroma in ICtCp, a colour space from ITU-R BT.2100, which GT7 computes through SMPTE ST 2084's PQ, and takes the skewed result's intensity. That chroma fades to white as the input's intensity goes from 0.98 to 1.16 of the paper white's, along a smoothstep:

skewed = gt7_curve(R), gt7_curve(G), gt7_curve(B) kept = in ICtCp: the intensity of skewed, the chroma of the input × fade fade = 1 − smoothstep(0.98, 1.16, I(input) / I(paper white)) output = 0.4 · min(0.4 · skewed + 0.6 · kept, 2.5)

How Praxis wraps it

Praxis reads exposed 1.0 as the paper white, so the frame-buffer value is 2.5 × exposed. GT7 works in Rec. 2020's primaries and the scene's colour is in Rec. 709's, so tonemap converts linear Rec. 709 to Rec. 2020 before GT7 and back after, with ITU-R BT.2087-0's matrix (R2020 = 0.6274 R709 + 0.3293 G709 + 0.0433 B709, and so on), and clips to [0, 1], as the survey's probe did.

Reading exposed 1 as paper white puts GT7's linear section, frame-buffer 0.538 to 1.11, at exposed 0.2152 to 0.444, and there the operator maps a grey to itself: mid-tones keep their exposure, which the decision calls "keeps today's mid-tones". Exposed 1 itself shows at 0.835, on the shoulder.

Exposed colour, Rec. 709Display-linear outputShown
grey 1.0, the paper white0.835
grey 0.60.585
grey 0.32, in the linear section0.320
grey 41.0
red (1, 0, 0)(0.948, 0, 0)
green (0, 1, 0)(0.027, 0.875, 0)
blue (0, 0, 1)(0, 0, 0.855)
red (8, 0, 0), which whitens(1.0, 0.503, 0.233)
the background (0.1, 0.2, 0.3)(0.0967, 0.1993, 0.2988)88, 123, 149
GT7's reference outputs, from Polyphony's sample compiled unmodified, with the Rec. 709 wrapper in f64: exposed colour in, display-linear colour out, and for the background its sRGB codes. The swatches show each output sRGB-encoded. This page's port, which the demos use, agrees with every output to within a unit in its last digit.
The path to white. Exposed grey, red, green and blue from four stops below exposed 1 to five stops above it, through this page's port of the operator, without the dither. Red and blue hold their hue to about exposed 1.4, while green picks up some red from about exposed 0.5, as green (0, 1, 0) shows in the table. Brighter, each moves towards white, as red (8, 0, 0) does in the table: red through orange, green through yellow, and blue through violet; blue, with the least luminance of the three, gets there last.

The shader matches the sample within 7 × 10⁻⁵ for GT7 itself and within 2.3 × 10⁻⁴ for the Rec. 709 wrapper, on the M4 Pro (src/render/shader_function_tests.rs). The wrapper's largest error is in a saturated green's small red channel, where converting back to Rec. 709 subtracts near-equal terms; sRGB encoding is steep there, which makes it about a fifth of an 8-bit step.

Why GT7

The decision on map #17 chose GT7 over AgX for three reasons: of the five operators the survey offered, it is the only one with a path to any HDR peak, it keeps today's mid-tones, and it has MIT reference outputs. There is no operator switch; grading, when it comes, would bake the operator into a LUT. The survey's other rows cover the alternatives: Reinhard, Hable, ACES, AgX, PBR Neutral and Tony McMapface.

The Goldens see the shoulder. Clamping exposed colour to 1 before GT7 makes them fail: pbr-sweep by 376 pixels, up to 235 steps; normal-tangent by 159 pixels, up to 240; and minification by 1,038 pixels, up to 19. Exposed values above 1 reach the images, and the Goldens would catch an operator that clipped them.

Chapter 6 · PRs #255 and #256

sRGB and dither

The window takes 8-bit codes, 256 per channel. A dark gradient that spans only a few codes rounds into visible bands. Adding a little noise before rounding turns the bands into fine grain whose average follows the gradient. Praxis takes its noise from a fixed blue-noise pattern, so the grain is even and the same in every frame.

Encoding once, in the shader

sRGB encoding (IEC 61966-2-1) turns display-linear values into the codes a display expects:

encoded = 12.92 x for x ≤ 0.0031308 = 1.055 x^(1/2.4) − 0.055 above

The pass encodes in the shader, once, and writes an 8-bit UNORM target: the window picks a Bgra8Unorm or Rgba8Unorm surface, and the capture target is Rgba8Unorm. An sRGB surface written through a non-sRGB view would do the same, but it needs a downlevel flag outside the Capability floor, which Vulkan reports only with an extension. The target stores what the shader writes, rounded to 8 bits, so the noise the shader adds lands on 8-bit steps. Without the dither, the shader's encoding matches the hardware's sRGB view within one step: by scene, 0.8 to 9.7% of pixels differ by one.

Triangular noise of two steps

The dither is triangular noise on (−1, 1) 8-bit steps, added to the encoded value. Two steps peak to peak is the least that removes noise modulation (Lipshitz, Wannamaker and Vanderkooy 1992, p. 372): with it, the rounding error's power no longer depends on the signal. The noise goes in the encoded domain because that is where the rounding happens. Added in linear light instead, it would be 12.92 times too large near black, where the encoding multiplies by 12.92 (the survey's row 19).

The mask

The noise comes from Christoph Peters's free blue-noise textures (CC0): Data/64_64/LDR_LLL1_0.png, a 64 × 64 image that holds each of its 256 levels exactly 16 times, alike in red, green and blue. It is committed as assets/BlueNoise/LDR_LLL1_0.png with its manifest entry, and compiled into the binary from src/render/dither_mask.bin, since a build without cook links no PNG decoder; a test pins one to the other.

The mask's levels are uniform, and the dither needs triangular noise. Peters's "Dithering" remaps the mask's values through the inverse CDF of the triangular distribution, which changes the values and keeps their arrangement. Praxis reads each level at the centre of its bin, where Peters's listing uses the stored value, level/255:

u = (level + 0.5) / 256 c = 2u − 1 noise = sign(c) · (1 − √(1 − |c|)) = −1 + √(2u) below u = ½ = 1 − √(2 − 2u) above

Over the 256 bin centres, the noise's mean is 0 and its variance 1/6, within 10⁻⁴; its extremes are ±0.9375.

The mask is fixed to the screen and never animates, so the Goldens stay exact and ADR 0008's stance against temporal tricks holds. The same noise goes to all three channels. Each channel moves by a step in about a third of pixels, since E|noise| = 1/3, and the channels tend to move together: on average, a grey moves in a third of pixels, and a colour whose channels sit at unrelated points between steps moves in 1 − E[(1 − |noise|)³] = 3/5 of them, by this guide's arithmetic; measured by scene, 48 to 75% of pixels move.

Rounded to 8 bits
Dithered with the mask, then rounded
Each column's mean code
the gradient, encodedroundeddithered, then rounded
Magnify:
Codes:
Pixels the dither moves off plain rounding
Largest column error, rounded
Largest column error, dithered
Live demo. A dark gradient, display-linear 0.0006 to 0.0045, which is about codes 2 to 14, sRGB-encoded as the shader encodes it and rounded to 8 bits as the target stores it: first plainly, then after adding Peters's mask, remapped to triangular noise and tiled every 64 pixels, as the pass adds it. The square is one tile of the mask, its levels drawn as greys. Each frame pixel is drawn as a square of 2, 3 or 6 screen pixels. The codes are dark, so the strips first draw each code times 8, which makes the steps easier to see; "as they are" draws the codes themselves. The plot gives each column's mean over the mask's 64 rows, and the errors are against the encoded gradient, in steps: rounding alone steps, while the dithered mean follows the gradient. The mask is embedded as the 4,096 bytes of src/render/dither_mask.bin (CC0, Christoph Peters). Simplified: one channel, in f64.
Chapter 7

What it costs

Nearly all of #200's GPU time is the new tonemapping pass, and most of that is GT7's arithmetic: about 0.33 to 0.35 ms a frame in all, at 1440p on the M4 Pro. pbr-sweep's GPU frame went from about 700 µs to 1,033 µs. The lit pass costs 5 to 9 µs more than before.

Each step was measured on the M4 Pro at 2560 × 1440 against the step before, in interleaved rounds:

StepAgainstFrame total, µsWhere it goes
Part 2: the HDR target and a clamp-only tonemapping passmain+56.5 far-coplanar
+72.7 helmet
+65.7 minification
+62.3 pbr-sweep
the lit pass +2 to 3 µs for 16-bit colour, so the tonemapping pass about 55 to 70 µs
Part 3: the weighted resolvepart 2+4.6 to +7.6the lit pass +3 to 4 µs, the inverse in the tonemapping pass the rest
GT7 in the tonemapping passpart 3+221.6 to +232.3the survey's probe: 0.289 ms against 0.048 ms clamp-only, +0.241 ms
The shader's sRGB encodingGT7 alone+22.0 to +26.2three pow, one per channel
The ditherthe encoding+16.0 to +20.4with the mask's wrap a constant power of two
#200 as a wholemain before itabout +330 to +350almost all the tonemapping pass, GT7 about 225 µs of it; the lit pass +5 to +9 µs, for 16-bit colour and the weighting
GPU time of the whole frame, each step against the one before it, as the work on #200 measured it on the M4 Pro at 2560 × 1440. The rows from part 2 to the sRGB encoding come from four interleaved rounds of far-coplanar, helmet, minification and pbr-sweep.
  • The dither first wrapped the mask with a modulo by the texture's size, at which it cost about 38 µs; a wrap by a constant power of two halved that.
  • Part 2 cost the CPU 14 to 27 µs and 17 more heap allocations a frame, for one more wgpu render pass.

Why frame totals

The tonemapping pass's own timestamps do not measure it. On Apple's tile-based GPU, its first timestamp lands while the lit pass's fragments still run, so the span between its timestamps includes the lit pass. Its cost is read from frame totals instead: the change in the whole frame's GPU time between two builds.

Chapter 8

What is not in M3

Some of what the survey covered stays out of M3 on purpose. Each item below was decided, and each that may return waits on the project's roadmap issue, #17, with the condition that would bring it back.

  • Automatic exposure. Each scene keeps a manual EV100. Histogram exposure adaptation and local tone mapping return together, with the first scene that joins an interior and sunlight (M5) or changes the time of day (M6).
  • Bloom. None, by decision B1; the survey measured a bloom chain at 0.27 ms.
  • HDR display output. SDR only, by decision H1, though GT7 has a path to any HDR peak.
  • An operator switch. None: GT7 is the operator, and grading, when it comes, goes into a LUT.
Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to main at 69e02dc, and the links to the course notes, papers and talks are the ones the survey cites.

This ticket and its PRs

In the repo

  • Ticket #200 and its split comment the ask, and how it splits into parts
  • PR #250 part 1: photometric lights and an EV100 per scene
  • PR #251 part 2: the HDR target and the clamp-only pass; the box resolve's whitening
  • PR #252 part 3: the lit pass's colour weighted for the resolve
  • PR #253 part 4: GT7 ported under its oracles
  • PR #254 part 5: GT7 in the tonemapping pass
  • PR #255 part 6: sRGB encoded in the shader, into 8-bit UNORM targets
  • PR #256 part 7: the blue-noise dither
  • The decision on map #17 GT7 over AgX, the dither (D1), no bloom (B1), SDR only (H1)
  • ADR 0023 photometric units, the fp16 clamp, the glint's peak
  • The survey, docs/research/hdr-exposure-and-tonemapping.md (#189) rows 18 and 19, the probe, and the other operators: Reinhard, Hable, ACES, AgX, PBR Neutral, Tony McMapface

Exposure, resolve and tone mapping

Standards

  • ITU-R BT.2100-3 ICtCp, Table 7 (Polyphony's sample cites "ITU-T T.302" for it)
  • SMPTE ST 2084 PQ
  • ITU-R BT.709-6 Rec. 709's primaries and the luminance coefficients
  • ITU-R BT.2020-2 Rec. 2020's primaries
  • ITU-R BT.2087-0 the matrix from Rec. 709 to Rec. 2020
  • IEC 61966-2-1 sRGB encoding
Reference

Glossary

Photometric
Measured as the eye weights light: lux, candela and cd/m².
Illuminance
Light arriving at a surface, in lux (lm/m²).
Luminous intensity
Light a point source sends per steradian, in candela.
Luminance
Light leaving a surface in one direction, in cd/m²: what a pixel shows.
EV100
Exposure value at ISO 100; one more halves the exposure.
Exposure
1/(1.2 · 2^EV100), the scale the lit pass multiplies luminance by.
Exposed colour
Luminance times the exposure; Praxis reads 1 as the paper white.
Sunny 16
A sunny day's exposure: EV100 15.
fp16
A 16-bit float, whose largest finite value is 65504.
MSAA resolve
Averaging a pixel's 4 samples into one; the hardware's is a box average.
Transient attachment
An attachment discarded when its pass ends; memoryless on Apple GPUs.
HDR target
The single-sample Rgba16Float texture of resolved, weighted exposed colour.
Reversible weighting
c/(1 + Y) before the resolve and w/(1 − Y) after: an average weighted by 1/(1 + luminance).
Frame-buffer units
GT7's: 1.0 = 100 cd/m².
Paper white
The luminance given to SDR white: 250 cd/m², 2.5 units, in GT7.
Tone curve
A map from scene to display values: toe, linear section, shoulder.
ICtCp
ITU-R BT.2100's colour space: intensity I, chroma Ct and Cp; GT7 computes it through PQ.
PQ
SMPTE ST 2084's perceptual quantiser, a transfer function for HDR displays.
Rec. 709, Rec. 2020
Two sets of RGB primaries: the scene's colour is in Rec. 709's, GT7 works in Rec. 2020's.
sRGB encoding
IEC 61966-2-1's curve from display-linear values to display codes.
Dither
Noise added before rounding, so bands become grain whose average follows the signal.
Triangular noise
Noise on (−1, 1) whose density falls linearly from 0 to the ends.
Blue noise
Noise without low frequencies: even grain, no clumps.
Noise modulation
Noise whose power changes with the signal.
Golden
A committed image that a Reference scene must reproduce within tolerance.
Reference scene
A Scene with a fixed camera path and injected time, the same frames every run.
Interleaved rounds
Two builds run alternately on one machine, so load and temperature affect both alike.
Tile-based GPU
A GPU that shades the screen in small tiles held on chip, as Apple's do.
Built from ticket #200, PRs #250, #251, #252, #253, #254, #255 and #256, as the work on #200 verified them on 2026-10-22, with ADR 0023, the survey (#189) and the decision on map #17. Code was read on the #200 branch and checked again against main at 69e02dc on 2026-10-24. Where the guide does its own arithmetic, it says so. The demos run the shader's formulas in f64, not f32, and say where they simplify. Their GT7 follows Polyphony Digital's gt7_tone_mapping.cpp 1.0 (MIT, notice in the page's script), and the dither mask is Christoph Peters's LDR_LLL1_0.png (CC0), embedded.