Because §60a says so, in one sentence: "the output transform — tone mapping
(§68) followed by sRGB encoding — is the final render-graph pass (§63)".
The alternative — an encode appended to the unlit, lit, standard, sprite and
particle fragment stages — would encode five times into one framebuffer,
make every blend between two draws happen in the wrong space, and multiply
the backend's shader variants by two. As a pass it runs exactly once, after
everything has been composited in linear light, which is the property that
makes the transform correct rather than merely present.
Opt-in, and what that costs (the dated deviation)
§60a describes the transform as the final pass, i.e. always present. Here
it is a pass an application adds, and a scene that does not add it renders
exactly the frame it rendered before this existed — byte-identical GL,
byte-identical pixels. That is deliberate (R-15, 2026-08-08): every scene,
example and pixel golden in this repository was authored against an untagged
pipeline whose output happened to be its working space, and an
on-by-default encode would relight all of them at once. Making it default is
an owner decision, and the honest sequence is: ship the transform, let a
scene opt in, move the goldens deliberately rather than as a side effect.
Tone mapping is the half that is still staged
§60a's transform is tone mapping then encoding, and this effect performs
the encoding only. The reason is unchanged from R-6 and is a resource, not a
preference: an operator compresses an HDR range onto 0…1, every surface in
this tier is rgba8 (R-4's one-member RenderTargetFormat), and there
is no range to compress. When float targets land, tone mapping arrives as a
field on this effect — the two halves belong to one pass — rather than as
a sixth member of the union.
What an rgba8 intermediate costs, stated rather than hidden
The source of this pass is a linear-light rgba8 surface, so the frame is
quantized to 256 linear steps before it is encoded. sRGB spends more of its
code space on darks than linear does, so re-encoding an 8-bit linear buffer
can band in shadow gradients in a way a directly-encoded framebuffer would
not. The fix is a wider intermediate (rgba16f), which is the same staged
RenderTargetFormat widening tone mapping waits on — not a change to
this pass. Named here because it is the one visible difference between this
tier and a complete §60a pipeline.
The arithmetic, exactly
Per colour channel, @fourjs/math's linearToSrgb: the IEC 61966-2-1
piecewise curve, odd-extended below zero. Alpha is not encoded — it is a
coverage fraction, not a light quantity — which is the same rule
srgbToLinearRGBA follows on the way in. Nothing is clamped by the effect;
the rgba8 destination saturates on write, as it does for a grade.
§60a's output transform: the linear-light source encoded as sRGB on the way to a presentable surface (R-15, 2026-08-08).
Why this is a pass and not a step in every shader
Because §60a says so, in one sentence: "the output transform — tone mapping (§68) followed by sRGB encoding — is the final render-graph pass (§63)". The alternative — an encode appended to the unlit, lit, standard, sprite and particle fragment stages — would encode five times into one framebuffer, make every blend between two draws happen in the wrong space, and multiply the backend's shader variants by two. As a pass it runs exactly once, after everything has been composited in linear light, which is the property that makes the transform correct rather than merely present.
Opt-in, and what that costs (the dated deviation)
§60a describes the transform as the final pass, i.e. always present. Here it is a pass an application adds, and a scene that does not add it renders exactly the frame it rendered before this existed — byte-identical GL, byte-identical pixels. That is deliberate (R-15, 2026-08-08): every scene, example and pixel golden in this repository was authored against an untagged pipeline whose output happened to be its working space, and an on-by-default encode would relight all of them at once. Making it default is an owner decision, and the honest sequence is: ship the transform, let a scene opt in, move the goldens deliberately rather than as a side effect.
Tone mapping is the half that is still staged
§60a's transform is tone mapping then encoding, and this effect performs the encoding only. The reason is unchanged from R-6 and is a resource, not a preference: an operator compresses an HDR range onto 0…1, every surface in this tier is
rgba8(R-4's one-member RenderTargetFormat), and there is no range to compress. When float targets land, tone mapping arrives as a field on this effect — the two halves belong to one pass — rather than as a sixth member of the union.What an
rgba8intermediate costs, stated rather than hiddenThe source of this pass is a linear-light
rgba8surface, so the frame is quantized to 256 linear steps before it is encoded. sRGB spends more of its code space on darks than linear does, so re-encoding an 8-bit linear buffer can band in shadow gradients in a way a directly-encoded framebuffer would not. The fix is a wider intermediate (rgba16f), which is the same staged RenderTargetFormat widening tone mapping waits on — not a change to this pass. Named here because it is the one visible difference between this tier and a complete §60a pipeline.The arithmetic, exactly
Per colour channel,
@fourjs/math'slinearToSrgb: the IEC 61966-2-1 piecewise curve, odd-extended below zero. Alpha is not encoded — it is a coverage fraction, not a light quantity — which is the same rulesrgbToLinearRGBAfollows on the way in. Nothing is clamped by the effect; thergba8destination saturates on write, as it does for a grade.