Praxis · M3 · ticket #189 · PR #219

From sunlight to an 8-bit pixel

A field guide to the HDR survey: how bright light is measured, exposed, stored, squeezed onto a screen and dithered, and what each of PR #219's seven decisions chooses between. The demos run the same formulas as the survey's probe, so what you see here is what the probe measured.

Provenance. PR #219 merged on 2026-10-20 as f15a35f. The project's owner took all seven of its recommendations, as the project's map issue records, and U1 became ADR 0023 in the PR itself. #200 then built the chosen pipeline in seven PRs, #250 to #256, all merged on 2026-10-22: photometric lights with an EV100 per Reference scene, an Rgba16Float target with the weighted resolve, and a tonemapping pass that applies GT7, encodes sRGB and dithers into an 8-bit UNORM target. Tonemapping Pass Explained follows that pipeline.

Chapter 1

The problem

When the survey was written, the forward pass wrote linear radiance straight into an 8-bit sRGB target. There was no exposure and no tonemapping operator, so anything brighter than 1.0 clipped, channel by channel. The lights were relative: a sun of about 1, unitless point lights, a constant ambient term.

That worked while scenes stayed dim. The five scenes the probe captured peaked at 855 (helmet) and 499.9 (pbr-sweep), yet no more than 0.56% of any one's pixels passed 1.0. Real light spans about 30 stops, each stop a doubling, from a moonless night to direct sun. A display shows a small slice of that. Getting from one to the other takes a chain of steps, and the survey has one decision for each link.

Lights sun, point lights, emissive, sky in physical units, or relative ones Lit pass radiance × exposure writes a float HDR target 4x MSAA resolve 4 samples per pixel, averaged by the hardware (never stored) Final pass tonemap to 0..1 encode sRGB add dither quantise to 8 bits Display 8-bit sRGB surface, SDR or an HDR colour space U · light units E · exposure R · target format R · resolve O · operator D · dither B · bloom H · display output
The chain from light to pixel, and where each of the seven decisions in PR #219 sits. The letters match the survey's options (U1, E1, R2 and so on). Chapter 10 collects them.
Chapter 2 · decision U

Light in numbers

Physics measures visible light with four photometric units. glTF, the format Praxis imports, already uses three of them:

  • Illuminance, in lux (lx): light arriving on a surface, lumens per square metre. The sun is a directional light in lux.
  • Luminous intensity, in candela (cd): light leaving a point in one direction, lumens per steradian. glTF point and spot lights use candela.
  • Luminance, in cd/m², also called nits: how bright a surface looks. Emissive surfaces, the sky and displays are measured in it.
  • Luminous flux, in lumens (lm): a lamp's total output. Frostbite, Filament and Bevy author point lights in lumens, as Godot does when its physical light units are turned on, so Bevy's glTF loader multiplies candela by 4π.
Illuminance from Bevy's reference table, on a log scale where each step is ten times brighter. From a moonless night to direct sun is 29.9 stops; from a living room to direct sun, 11.

Option U1 puts Praxis's lights in these units, exactly as glTF defines them: directional lights in lux, point lights in candela, emissive and background in cd/m², ambient in lux, exposure as EV100. glTF lights then import unchanged, and the ratios between sun, sky and lamps are real, which is what ray-traced shadows and probe GI (ADR 0020) will expect. Option U2 keeps relative numbers: fewer stops to handle and an exposure near 1, but glTF lights need a scale at import. Frostbite moved to physical units mid-life and called converting its relative lighting rigs "extremely hard". Units are hard to change once worlds are authored, which is why U1 is recorded in an ADR, ADR 0023.

Chapter 3 · decision E

Exposure: choosing which slice to show

A camera does not record light as it is. It picks a slice: aperture, shutter time and sensor sensitivity set how much light becomes a mid-grey photo. Engines copy this with one number, EV100, the exposure value at ISO 100:

EV100 = log2(N² / t) − log2(S / 100) for f-number N, shutter time t in seconds and ISO S. A pixel's exposed value is its luminance times 1 / (1.2 · 2^EV100), so 1.0 lands near display white. The 1.2 comes from how camera sensors are rated. The photographer's Sunny 16 rule lands near EV100 15.

One EV cannot fit everything. Move the slider and watch two 18% grey cards, one in a living room and one in direct sun.

Grey card in direct sun · 100,000 lx

Grey card in a living room · 50 lx

Live demo. An 18% grey card reflects 0.18 × illuminance / π cd/m². The swatches pass the exposed value through GT7, the operator PR #219 recommends (chapter 5). The two cards want EV100 values 11 stops apart.

Three ways to choose the EV:

  • E1, manual: each Reference scene sets its EV100. It is exact every frame, keeps Goldens exact and costs one multiply.
  • E2, histogram adaptation: a compute pass measures the frame's brightness and eases the exposure toward it, as eyes adapt. Its state is one number carried across frames, which ADR 0008 allows, since nothing smears on screen. The probe measured 0.27 ms at 1440p. The catch is lag and flicker: Valve found blinking lights made Source's exposure oscillate.
  • E3, local tone mapping: different exposure per region of the image, for scenes that join a dark interior and sunlight, at the risk of halos. Nothing in M3 needs it.
Chapter 4 · decision R, part one

Storing it: floating-point targets

Exposed values can be 0.001 or 3,000, so the lit pass needs a target that stores floats. A 16-bit float (fp16, Rgba16Float) has 1 sign bit, 5 exponent bits and 10 mantissa bits. Its largest value is 65504, its smallest normal value 6.1×10⁻⁵, and from there up its steps are at most 0.1% of the value. Rg11b10Ufloat packs three unsigned floats into 32 bits, half the memory, with 6-, 6- and 5-bit mantissas and no alpha. The coarse blue is where Frostbite saw a small hue shift.

Bright values still overflow. A glossy non-metal's highlight of the sun at 100,000 lx, at roughness 0.04 and with the sun as a directional light, peaks at 1.24×10⁸ cd/m², 10.9 stops past fp16's maximum. Engines therefore pre-expose: shading runs in f32 and only the exposed result is stored. At EV100 15 that highlight becomes 3,162. A white metal's highlight becomes 79,053, still too big, so every option clamps to 65504 before the write. That clamp matters across backends: the probe measured that the M4 Pro saturates overflow to 65504, a case Metal's specification leaves open, while Vulkan's rules allow infinity, and an infinite sample can turn into black patches.

Type a positive number, such as 0.5, 1000 or 1.24e8.

FormatStoresStep at this sizeNote
Live demo. What each format keeps of a value, rounding to nearest. fp16 needs no feature on any adapter; Rg11b10Ufloat as a render target needs the RG11B10UFLOAT_RENDERABLE feature, a new Capability-floor entry. At 2560 × 1440 one target is 29.5 MB in fp16 against 14.7 MB in 11/11/10.
Chapter 5 · decision O

Tonemapping: squeezing light onto a screen

A tonemapping operator is a curve from exposed values, 0 to infinity, onto the display's 0 to 1. A good one keeps mid-tones close to what the scene had, rolls highlights off smoothly instead of clipping them, and decides how bright colours behave near white.

That last point separates the families. Apply the curve to each channel and a bright saturated colour shifts hue: the brightest channel tops out while the others keep climbing, so a bright blue turns cyan or magenta, or stays a saturated block that never whitens. Apply it to luminance only and the hue holds, but saturated highlights leave the display's range. Film and cameras do something in between: very bright coloured light desaturates toward white. Engines call that the path to white.

Pick an operator below. The sweep is a small copy of the probe's: fully saturated hues across, a neutral strip at the left, and exposure from 0.18 × 2¹⁰ at the top down to 0.18 × 2⁻⁶ at the bottom.

Move over the chart to read a value.

Live demo. Left: the selected operator on the sweep. Right: every operator's grey response, the selected one in blue, as 8-bit output codes over 16 stops of input; the dashed line is mid-grey. The code is a port of the probe's shader, and its grey output matches the probe's images within one code across the whole range. Tony McMapface is left out because it is a lookup table, not a formula.

The probe ran all nine operators over the full sweep and over the scenes it captured, as they were then, at the same exposure. Brightness differences between tiles come from where each operator puts mid-grey; look at how colours behave as they get bright.

Nine tiles of the exposure sweep through each operator: clamp, Reinhard, Hable and Narkowicz keep saturated blocks; Hill whitens through magenta; AgX, PBR Neutral, Tony McMapface and GT7 whiten with little hue changeThe sweep, through every operator
Per-channel curves (clamp, Hable, Narkowicz) and Reinhard on luminance, clamped at 1, turn saturated hues into yellow, cyan and magenta blocks that never whiten. Hill's ACES fit whitens blue through magenta. AgX, PBR Neutral, Tony McMapface and GT7 whiten with little hue change. Images from the probe in PR #219.
The pbr-sweep scene through each operatorpbr-sweep
The helmet scene through each operatorhelmet
Two of the scenes as they were before #200, captured unclamped and run through each operator. These are the images to judge the operator by.

PR #219's short list for O, all analytic, permissively licensed and taking saturated light to white (the survey note itself lists five options, Tony McMapface and an ACES 1 fit among them, and recommends none):

  • GT7 (recommended): Gran Turismo 7's operator, published at SIGGRAPH 2025 with MIT code. Nearly linear mid-tones, hue held there, saturated light to white near the peak. Of these three, it is the only one built for both SDR and any HDR peak, so it would carry over if Praxis ever outputs HDR. The most expensive, at 0.29 ms; it can be baked into a lookup table when grading arrives.
  • AgX: Blender 4.0's default. A softer, desaturated filmic look; the three.js form the probe ran is SDR only, and Blender 5.0 added an HDR view with a 1000-nit peak.
  • PBR Neutral: Khronos's operator for product rendering. Base colours come through most faithfully, with highlights squeezed into the top codes. SDR only.

Not recommended: the ACES 1 fits (Hill's whitens blue through magenta, and Narkowicz's over-saturates brights), Tony McMapface (a lookup table with no HDR variant), and Reinhard and Hable (saturated colours never reach white).

Chapter 6 · decision R, part two

Bright edges and the 4x MSAA resolve

With 4x MSAA every pixel has four samples, and the hardware averages them into one value: the resolve. ADR 0019 keeps those samples in fast on-chip memory and never writes them out (the attachment is memoryless), so the hardware average is the only resolve on offer.

Averaging is right for light but wrong for what you see. Say a thin wire of brightness 100 covers one of a pixel's four samples and the background is 0.05. The average is 25: still far above white, so the edge pixel turns fully white, as if the wire covered all of it. Bright sub-pixel detail becomes thick white stair steps.

The fix (Karis, 2013) is reversible weighting. Each shader in the lit pass writes c / (1 + luminance), which keeps every sample's luminance under 1, though a saturated colour's channels can stay above 1: pure blue of 100 is stored as 12.2. The hardware averages those, and the final pass undoes the squeeze with c / (1 − luminance). The result behaves like averaging after tonemapping: the same pixel resolves to 0.40.

Samples the wire covers:
Live demo. One pixel's four samples: the wire's, and the 0.05 background's. Swatches are shown through AgX, like the probe's images. With weighting, the edge pixel stays between background and wire, so edges look smooth. The price: the pixel shows much less light than truly reached it, so sub-pixel highlights lose energy.
Bright wires and a disc over grey: box resolve, weighted resolve, and two 64-sample referencesFull frame, 2×
Zoom: the box resolve's wires are thick white steps; the weighted resolve's are thin and smoothZoom, 4×
The probe's comparison, through AgX. The box resolve matches 64 samples averaged in linear light: thick white steps. The weighted resolve matches 64 samples averaged after the operator.

The options for R:

  • R2 (recommended): Rgba16Float with the weighting. It measured 0.009 ms extra in the lit pass and 0.018 ms in the final pass, and keeps ADR 0019's memoryless 4x. Two costs: sub-pixel highlights lose energy, and M6's transparent passes would blend in the weighted space, which a fog line records. fp16 is required, since the inverse of the largest value under 1 caps at 2047 in fp16 but at 127 in 11-bit floats.
  • R1: Rgba16Float with the plain box resolve. Physically averaged, stair-stepped at bright edges.
  • R3: store the samples and resolve them in a shader. 117,964,800 bytes at 1440p, and it reopens ADR 0019.
Chapter 7 · decision D

Banding and dither

An 8-bit surface has 256 levels per channel. A smooth, dim gradient covers only a few of them, so it shows as bands. Dither adds a little noise before rounding, so neighbouring pixels round up or down in the right proportion and the average comes out right. The eye sees fine grain instead of bands.

The details matter. Triangular noise spanning 2 steps peak to peak is the least noise that keeps the error's strength the same whatever the signal, so the grain does not itself vary with brightness (Lipshitz et al.). It must be added after the sRGB encode, in 8-bit steps: added in linear light it is 12.92 times too strong near black. And the pattern must stay fixed on screen. ADR 0008 rules out averaging frames, and a fixed pattern keeps Goldens exact.

Blue noise is noise without low frequencies: its dots are spread evenly, so it reads as finer grain than white noise. Ordered (Bayer) patterns are cheap but show a regular grid. The recommended mask is Christoph Peters's free 64 × 64 blue-noise texture (CC0), the same one used below.

Dither:
Added:
View:
Live demo. Linear values from 0.004 to 0.016, the probe's dim gradient, through the sRGB encode to 8 bits: codes 13 to 34, 22 levels across 640 pixels. "Levels × 8" stretches the result so bands and grain are easy to see. Blue noise is Peters's mask remapped to triangular noise; white noise is the probe's hash.
Left: a dim gradient in visible bands; right: the same gradient with static triangular dither, smooth with fine grainThe probe's gradient through PBR Neutral, levels stretched about 8×

The options for D: D1 (recommended), static triangular dither from Peters's mask, added in the encoded domain through a non-sRGB view of the surface; the mask joins assets/manifest.json. An integer hash gives white noise without adding an asset. D2: none, and dark gradients band. The probe measured the dither at a few hundredths of a millisecond on top of the operator.

Chapter 8 · decision H

Colour spaces and displays

A colour space is three things: which red, green and blue its primaries are, which white they balance to, and a transfer function between stored numbers and light. sRGB uses the Rec. 709 primaries, the D65 white, and a curve that spends more codes on dark tones, where eyes are sensitive.

Shading and blending must happen in linear light, where numbers add like light does. Encoding happens once, at the end. The …Srgb texture formats do the encode in hardware on write, which is why the dither step must write to a format that does not: it encodes itself, adds noise in 8-bit steps, then writes the codes as they are. The survey proposed a non-sRGB view of the sRGB surface; since #255 the final pass writes a Bgra8Unorm or Rgba8Unorm surface instead, because that view needs a wgpu downlevel flag outside the Capability floor.

Left: the sRGB curve. Half of the light (0.5) is code 188, and mid-grey (0.18) is code 118; near black the curve is a straight line of slope 12.92. Right: gamuts on the CIE 1931 chromaticity diagram. Every colour the eye can see lies inside the curved outline; each triangle is what one set of primaries can show. The outline is computed from Wyman, Sloan and Shirley's fit to the CIE colour-matching functions.

HDR displays

SDR output is relative: 1.0 is the display's white, whatever its brightness. HDR output adds room above that white, and each platform names it differently:

  • EDR (Apple): linear values where 1.0 is SDR white and brighter values go up to the display's current headroom. A Pro Display XDR at 1,600 nits peak and 500 nits SDR white has 3.2. Above it, values clip.
  • scRGB (Windows): linear fp16 with sRGB primaries, where 1.0 means 80 nits.
  • PQ (BT.2100, HDR10): an absolute curve up to 10,000 nits with Rec. 2020 primaries. BT.2100 maps fp16 1.0 to 203 cd/m², and televisions clip long before 10,000. HLG is its relative broadcast sibling.

wgpu 30.0.1 can already configure all of these, through SurfaceConfiguration::color_space, and reports the display's headroom. It writes no HDR10 metadata. The M4 Pro's built-in display is an XDR panel, so HDR output could be tried on the reference machine. But an HDR path needs an operator that takes a display peak, as GT7 does, and its own testing.

The options for H: H1 (recommended), SDR only in M3, with HDR output a fog line. H2: EDR on Metal now, a second output path, with Goldens staying SDR.

Chapter 9 · decision B

Bloom

Bright light seems to glow, because lenses and eyes scatter a little of it into its surroundings. A display cannot show a light brighter than white, so bloom adds that glow to say "brighter than white". The usual form (Call of Duty: Advanced Warfare, 2014) blurs the image down a chain of ever smaller copies and adds the result back. It uses no brightness threshold: the eye's scattering has none, and a threshold breaks energy conservation. The probe measured that chain at 0.27 ms at 1440p.

The options for B: B1 (recommended), none in M3, with the measured cost kept in a fog line. #200's size ceiling, about 400 changed lines, would make bloom a ticket of its own anyway. B2, the Call of Duty chain, and B3, physically based glare by FFT convolution, are later polish.

Chapter 10

The seven decisions

PR #219 put these seven questions to the project's owner, who took every recommendation on 2026-10-20. The answers are recorded on the project's map, U1 became the light-units ADR, ADR 0023, before the PR merged, and #200 carried out the rest, with one change to D1: the final pass writes an 8-bit UNORM surface in place of a non-sRGB view (chapter 8). Each card gives PR #219's recommendation first, which is also what was chosen.

1 · Light units

U1 · photometric, as glTF defines them

Lux, candela and cd/m², exposure as EV100, in a new ADR. glTF lights import unchanged and light ratios are real.

Or U2, relative units: fewer stops, a scale at glTF import, hard to convert later.

Chapter 2

2 · Exposure

E1 · manual EV100 per scene

Exact every frame, Goldens exact, free. Histogram adaptation becomes a fog line, revived by the first scene that crosses interiors and sun or changes time of day.

Or E2, histogram adaptation (0.27 ms), or E3, local tone mapping.

Chapter 3

3 · Operator

GT7 · analytic in #200

Linear mid-tones, hue held, white near the peak, and one path for SDR and any HDR peak. 0.29 ms, bakeable into a lookup table later.

Or AgX, softer and desaturated, or PBR Neutral, most faithful base colours. Judge them on the probe's images.

Chapter 5

4 · Target and resolve

R2 · Rgba16Float with reversible weighting

The only fix for bright-edge aliasing that keeps 4x MSAA memoryless, for 0.009 ms plus 0.018 ms. Sub-pixel highlights lose energy.

Or R1, the box resolve and its white stair steps, or R3, stored samples (118 MB, reopens ADR 0019).

Chapter 6

5 · Dither

D1 · static triangular, Peters's CC0 mask

Added after the sRGB encode through a non-sRGB view; the mask joins the asset manifest. A hash gives white noise without an asset.

Or D2, no dither: dark gradients band.

Chapter 7

6 · Bloom

B1 · none in M3

A fog line keeps the measured 0.27 ms chain for later; it would be a ticket of its own.

Or B2, the Call of Duty mip chain now, or B3, FFT glare.

Chapter 9

7 · Display

H1 · SDR only in M3

HDR output becomes a fog line: wgpu 30 can already configure EDR and PQ, and the M4 Pro has an XDR display.

Or H2, EDR on Metal now, as a second output path.

Chapter 8
Reference

Reading list

Grouped by topic. The survey itself checked every external link below except Wyman, Sloan and Shirley's paper, which this guide adds for its chromaticity diagram; page numbers point at the parts it relies on.

The survey and its context

In the repo

Books

  • Real-Time Rendering, 4th ed.: ch. 8 Light and Color, on light quantities, display encoding and tone mapping
  • Birn, Digital Lighting & Rendering: exposure and dynamic range from a lighting artist's side
  • Yot, Light for Visual Artists: how bright and coloured light looks
Reference

Glossary

Stop
A doubling or halving of light; log2 of a ratio.
Lux, candela, nit
Light on a surface, light from a point in one direction, and how bright a surface looks (cd/m²).
EV100
Exposure value at ISO 100; one step is one stop.
Exposed value
Luminance times the exposure; 1.0 is about display white.
Pre-exposure
Applying the exposure before storing, so values fit in fp16.
fp16
A 16-bit float: 10-bit mantissa, largest value 65504.
Tonemapping operator
A curve from exposed values onto the display's 0 to 1.
Path to white
How bright saturated colours desaturate toward white.
Per-channel, hue-preserving
A curve applied to R, G and B separately, or to brightness alone.
Resolve
Averaging a pixel's MSAA samples into one value.
Memoryless
An attachment kept only in on-chip tile memory, never written out.
Reversible weighting
Writing c/(1 + luminance) before the resolve and undoing it after.
Banding
Visible steps where a smooth gradient has too few output levels.
Dither, blue noise
Noise added before rounding; blue noise has no low frequencies.
Colour space
Primaries, a white point and a transfer function.
Gamut
The range of colours a set of primaries can show.
Linear, encoded
Numbers proportional to light, or squeezed by a transfer curve for storage.
Headroom
On an HDR display, peak brightness over SDR white.
Paper white
The brightness given to SDR white inside an HDR signal.
Bloom
A glow added around very bright light, to signal brighter than white.
Built from PR #219 and its survey note at f15a35f, with images from its probe. The operator demos port the probe's shader (research/hdr-probe/src/post.wgsl at a2f7302, on the never-merged research/hdr-captures branch): Hill's fit (MIT), three.js's AgX (MIT), Khronos PBR Neutral (Apache-2.0) and Polyphony's GT7 (MIT), whose notices are in the page's script; Narkowicz's fit (CC0 or MIT); and Reinhard's and Hable's published formulas. The white-noise hash is Jarzynski and Olano's pcg2d, from Hash Functions for GPU Rendering (JCGT, 2020). The blue-noise mask is Christoph Peters's 64 × 64 LDR texture, CC0.