Everything HDR
I’ve been putting this post off because it’s one of those changes that doesn’t have a flashy screenshot to open with. The “before” and “after” look almost identical if you point a camera at a Phong sphere and squint. What changed is the ceiling of what the pipeline can represent, and that ceiling had been quietly broken since the first 3D post.
This is the HDR pipeline post. It’s short, it’s structural, and it’s the reason the next two posts can exist.
The old contract
Until this session, the default texture format in the Lux render stack was Rgba8Unorm. The RenderScene color target was Rgba8UnormSrgb. The fragment shader in the mesh pipeline output linear RGB, and the hardware sRGB encoder on the surface flipped it to gamma space on store.
That works fine as long as every single linear value you produce is in [0, 1]. The moment you exceed that (a bright highlight on a Phong sphere with an intensity-2 light, an emissive material, the center pixel of an SDF raymarch that happened to multiply two unit vectors into a scalar above 1), the value gets silently clamped to 1.0 at the store. No error. No log. Just a flat white pixel where there should have been information. I think that’s how it was breaking, anyway. It’s also possible some of those flat white pixels were just… flat white pixels. Hard to tell without instrumenting it.
The reason this didn’t matter for ten months is that the demo patches weren’t producing values above 1. Lambert on a unit-intensity directional light caps at 1.0. Phong with a specular coefficient of 0.5 caps at 1.5, but only in a pixel or two, and those pixels looked “bright” and I didn’t think twice about it.
The reason it started to matter is the next three posts. Bloom needs to sample values above 1 to know which pixels to glow from. TAA needs a linear signal, not a gamma-encoded one, to reproject correctly. Tonemap operators need headroom to map. You cannot run any of those techniques against a framebuffer that clipped at 1.0 three passes ago.
The new default
TextureFormat::default() now returns Rgba16Float. TextureDesc::default() likewise gives you a 16-bit-per-channel HDR descriptor. Every node that allocates a texture without explicitly specifying a format now allocates 16-bit float.
This is a breaking change and it’s tagged as one in the commit. In practice, the in-tree audit found zero plugins relying on the default format, because the handful of nodes that really needed 8-bit (the ones that feed vello for 2D layer output) were already passing TextureFormat::Rgba8 explicitly. So nothing broke. But plugins that land in the future need to opt in to 8-bit if they want it, and the docstring on TextureFormat spells out exactly why.
The reason to flip the default rather than introduce a new variant is that “creative coding” is, by default, a thing that produces HDR values. A Shadertoy paste-and-go exceeds 1.0 constantly. A fractal flame accumulator spends its life above 1.0. A raymarched SDF lit by two lights is above 1.0 on the highlights. Setting the default to 8-bit was a bet that most shaders live in [0, 1], and that bet turned out to be wrong for the kind of shaders people actually write.
A texture you allocate today holds the full range of IEEE-754 half-floats, which is roughly [-65504, 65504] with precision decreasing as values grow. Plenty of headroom for anything a creative coder will ever produce.
The SceneOutput pass
The second change is more architectural. RenderScene used to be a single render pass: mesh vertex → fragment → sRGB store → done. That pass was both “draw 3D geometry” and “produce final display pixels.” Two responsibilities, one shader.
Now it’s two passes:
- Render3D — the mesh pass. Targets an internal
Rgba16FloatHDR buffer. Lighting, IBL (next post), emissive materials all write linear HDR values with no cap. The buffer lives for the duration of the frame and then the pool reclaims it. - SceneTonemap — a dedicated fullscreen pass that samples the HDR buffer, applies a tonemap operator, and writes the result to an
Rgba8UnormSrgbtarget that the outside world can see.
The tonemap pass is mandatory. Not an opt-in post-FX node that you have to remember to wire. It runs every time RenderScene runs, because the HDR contract has to be upheld by construction. If I made it an optional node, half of all demo patches would render with a floating-point HDR buffer going into a 2D vello layer that expects 8-bit sRGB, and every artist would quietly wonder why their scenes looked washed out. Not-having-to-remember-to-wire-things is the kind of design choice I’m pretty sure is right and am keeping an eye on in case it turns out to be wrong.
RenderScene::process() pushes a pair of ops every frame: Render3D(hdr_handle, depth_handle) then SceneTonemap(hdr_handle → color_handle). The color output pin exposes color_handle, which is the tonemapped 8-bit sRGB texture. The hdr output pin exposes the raw HDR buffer, which is the one post-FX nodes want to sample. The depth output pin is unchanged.
Between-frame state: RenderScene caches all three handles across frames using the same lifecycle contract from the 10k-cubes post. Mark-in-use every frame, re-allocate if the format or size changes, free the previous allocation before the new one. Zero GPU allocations in steady state.
Four operators, one shader
The tonemap shader (scene_tonemap.wgsl) carries four operators behind a mode: u32 uniform:
- Linear — clamp-to-[0,1] only. This is the stub I shipped in the first cut so no existing golden regressed. It produces visibly identical output to the old 8-bit pipeline.
- ACES fitted — the standard Academy Color Encoding System curve. Filmic rolloff, handles highlights gracefully, saturated colors don’t clip to white.
- AgX Punchy — Troy Sobotka’s AgX with the Punchy look. More neutral in the midtones than ACES, better for non-photographic content.
- Reinhard extended — classic Reinhard with a white-point knob. Cheap, mathematically predictable, good baseline.
The operator is selected per-scene via a tonemap pin on RenderScene. Default is ACES. Exposure, white point, saturation, and contrast are all pins on the same node, all feed into the shader as uniforms.
Switching between operators is live. Drag the slider and the image grades in real time, because the Render3D output is cached in the HDR buffer and only the tonemap pass re-runs on pin change.
The activation commit
The ACES activation was its own commit (a few days after the plumbing landed) because it needed the golden reference PNGs to be regenerated for every existing 3D demo patch. Activating ACES changes the colours of every 3D render by a small but real amount: highlights roll off softer, midtones stay roughly where they were, shadows get a touch more contrast.
For the phase9 demo patches (the boxes, the sphere, the 10k cubes, the SDF blob), the reference PNGs were regenerated by hand, eyeballed for plausibility, and committed in a single change. If you git diff the before and after on the cubes demo, it’s a subtle warming and a softening of the brightest highlights. Nothing dramatic. Just correct.
The BREAKING CHANGE
One note for anyone tracking the release log. TextureFormat::default() flipping is a breaking change even though no in-tree callsite relied on it. Plugins written against an older version of the API that passed TextureDesc::default() to run_shader or alloc_texture will now get a 16-bit float texture instead of an 8-bit one. If their shader writes 8-bit RGBA out, the types won’t match and compilation will fail at pipeline build time.
The migration is one line: change TextureDesc::default() to TextureDesc::rgba8(w, h) explicitly, or pass TextureFormat::Rgba8 to the alloc call.
This is the kind of breaking change that a pre-1.0 release can absorb. At 1.0 it would be a SemVer major, and the migration path would have to be soft-flagged with a deprecation shim. For now it’s a tag on the commit and a line in CHANGELOG.md.
What this unblocks
Nothing visible. Every demo patch still renders. The sphere still looks like a sphere. The cubes still instance.
But under the hood, every fragment the 3D pipeline produces is now a half-float that lives in a proper HDR buffer, gets tonemapped by a real operator, and only hits sRGB at the edge of the render. The next post is the framegraph that turns this pair-of-passes idea into a general data-driven render graph. The one after is the post-FX stack — bloom, TAA, CAS, grain — that each individually depends on the HDR buffer being there.
None of them could ship against the old 8-bit pipeline. All of them ship now.
I have no idea what I’m doing or if any of this is right, but it’s fun. Follow along.