Co-Authored-By: Claude Opus 5.5 <[email protected]>
21 KiB
Linear light for HDR and shader packs (shader API 0.2)
A design note, before any code: what a colour means to a shader pack from API 0.2 on, how the frame stores the picture, and how HDR output stops reading light from the frame's alpha. Part of #3125 and #1441. The shader API is experimental (0.1), which needs a design note for a new line (api/stability.md); this is that note.
Why
The frame can't hold light brighter than white. The world is drawn in vanilla's gamma space into the swapchain's
8-bit format, or with HDR on into Rgba16Unorm, which clips at white after every pass as the 8-bit frame does
(gfx.rs WIDE_FRAME). HDR output (hdr.wgsl) rebuilds the light above white at the end, from marks in the frame's
alpha:
| Alpha | Means |
|---|---|
| below 0.75 | a light source: its tier and how much it is lifted |
| 0.76 to 1 | HDR lighting's gain on a surface |
| 1/255 | the first-person hand |
| what the HUD and menus leave | how much they cover the world |
To tell the world from the HUD, Hdr::keep_world copies the whole frame every frame and shows_world compares
the two within a tolerance. Anything that filters or blends alpha can break a mark: FXAA and TAA return the centre
pixel's alpha with smoothed colour (world_post.wgsl 54 and 132), and the resolution-scale copy (fs_copy) samples
alpha bilinearly, so a pixel between a torch and a wall can land in another tier's band. (Found in the code, not yet
measured on screen.)
Packs can't make HDR. A pack's last post pass draws onto that clipped frame, HDR lighting turns off while a pack's
pack runs, and post passes must return input.color.a so the marks survive. A pack's HDR would only
be possible by guessing highlights back from an SDR picture (inverse tone mapping).
The API doesn't say what a colour is. The reference gives colours as "0..1" and never says gamma or linear, or
whether more than 1 is allowed. The example pack reads them as gamma, capped at 1: Golden Hour's glow picks pixels by
smoothstep(0.8, 1.0, luminance(c)) and its grade clamps to 0..1. Every pack written that way makes the meaning
harder to change.
Vanilla is going the other way. Mojang plans Vibrant Visuals for Java Edition (the render thread and the move to Vulkan are its groundwork). On Bedrock it lights in HDR, in physical units, with auto exposure, bloom and a filmic tone curve down to SDR at the end. A pack API capped at gamma 0..1 would have to break again for it.
Goals
- HDR off and no pack: the picture is today's, bit for bit, pinned by a screenshot test (below). Players without HDR or a pack run the code they run today.
- 0.1 packs keep working until 0.2 is on by default: unchanged in SDR, through a documented shim (below).
- Packs write light above white, on SDR and HDR alike. SDR is HDR with no headroom: one pack, one tone curve, both displays.
- No light in alpha for HDR without a pack and for 0.2 packs: the light-source and HDR-lighting marks give way to real values. Alpha keeps only how much the HUD covers, and 0.1 packs keep today's path (below).
Not goals: changing vanilla's look; physically based lighting without a pack; HDR for shaders/-format packs (see
below); where HDR's settings live (a later note).
The colour rule (shader API 0.2)
- Every colour a 0.2 stage reads or returns is linear light: BT.709 (sRGB) primaries, scene-referred, 1.0 is vanilla's white at paper white, more is brighter, up to half float's range. Horizon replaces negative values and NaN with 0 before the display.
- Decoding is vanilla's 2.2 power, as
hdr.wgsldecodes today, not the piecewise sRGB curve. Because a power is multiplicative, vanilla's lit colouralbedo * tint * lightmapis the same in linear: decoding the product is the product of the decoded terms. - What converts:
MaterialInputalbedo,tint,lightmapandcolored_light;SkyInput.color;WeatherInput.color;Framesky_colorandfog_color;block_textureandblock_texture_level; every colour texture a stage samples (scene_color,previous,history, pack targets,opaque_color,reflection,shadow_color,block_light_colors). Data stays data: depth, normals,lightlevels,emission, and every channel of the LabPBR maps (block_normal_map,block_specular_mapand their_levelforms). - Alpha is coverage only. The rule to return
input.color.agoes; a 0.2 post pass's alpha is ignored. - Display info in
Frame, appended aftershadow_center_texel:
| Field | Type | Meaning |
|---|---|---|
hdr |
u32 |
1 while the output is HDR, else 0. |
headroom |
f32 |
The peak over paper white: the brightest value the display shows (Apple's EDR headroom, Android's HDR/SDR ratio). 1 in SDR. It changes while the game runs (the system's SDR white, a calibration, another display), so read it every frame. |
paper_white |
f32 |
Paper white in nits; a nominal 80 in SDR (see the decisions at the end). |
peak |
f32 |
The peak brightness in nits; a nominal 80 in SDR, as paper_white. |
black_level |
f32 |
The display's black in nits (HDR's Black Level); 0 in SDR. |
- A
[tonemap]stage,fn tonemap(input: TonemapInput) -> vec3<f32>: the post chain's linear picture in, the picture for the display out, linear, 1.0 at paper white and at mostframe.headroom. It runs once per pixel at the window's size, after the post chain and before the camera overlays and the HUD, and owns the look of highlights. A pack that leaves it out gets Horizon's: in HDR a roll-off into the headroom (ITU-R BT.2390's EETF in PQ; HDR's black level already uses the same recommendation's lift), in SDR a clip at white, as vanilla. A pack that only grades keeps Horizon's tone curve; one that only tone maps needs no post chain. - Horizon's display pass is always last and can't be replaced: it clamps to the headroom, converts to BT.2020 for HDR10, lifts the black level, encodes (PQ, scRGB, or 2.2 for SDR) and shows what the HUD and menus cover at UI Brightness, as today.
How the frame stores the picture
The frame depends on what draws the picture, not on the display:
| Picture | Frame | Stored as |
|---|---|---|
| HDR off, no pack | the swapchain's 8-bit format, as today | vanilla's gamma, clipped |
| HDR on, no pack | Rgba16Float |
vanilla's gamma, values above 1 kept |
| a 0.2 pack (SDR or HDR) | Rgba16Float |
vanilla's gamma, values above 1 kept; linear light with [frame] blend = "linear" |
| a 0.1 pack | as today | as today (see 0.1 packs) |
a shaders/-format pack |
as today | as today, with nothing lifted in HDR (see shaders/-format packs) |
When it changes. Today the frame's format is chosen when the renderer is made and then kept: HDR turned off keeps the 16-bit frame until the next start, and dropping a pack rebuilds nothing. A float frame can't stay behind like that, because it doesn't clip as the 8-bit one does. Whenever the row above changes (HDR on or off, a 0.2 pack picked or dropped, a 0.2 pack swapped for a 0.1 one), the frame and the pipelines that draw into it are rebuilt before the next frame. What that costs is measured in the series.
Why gamma storage. Vanilla blends glass, water, clouds, particles and weather by fixed-function blending in
gamma space. Keeping that encoding keeps every blend of values up to 1 as vanilla's, and solid surfaces and lighting
are the same in either encoding. With HDR on and HDR Lighting off the picture is SDR's except where vanilla would have
clipped at white before a later blend: the sun and moon add onto the sky (RenderPipelines.CELESTIAL, src alpha and
one), so clouds or glass over a bright sun come out a little brighter than vanilla's. Goal 1 covers HDR off only;
this case is held to the existing HDR-versus-SDR check (below).
Linear blending, if a pack asks. [frame] blend = "linear" in the manifest stores linear light instead, for a
physically based pack that wants glass, water and smoke to blend as light does. A 0.2 stage sees the same values
either way; what changes is where Horizon converts:
blend = "gamma" (the default) |
blend = "linear" |
|
|---|---|---|
| The frame holds | vanilla's gamma, values above 1 kept | linear light |
| A 0.2 stage's result | encoded before the frame stores and blends it | stored as it is |
| Horizon's own draws (entities, particles, clouds, weather, stages the pack leaves out) | as today | decoded at the end of their shaders: solid ones look as with gamma, see-through ones blend in linear |
Horizon's multiplying draws (block breaking, CRUMBLING_BLEND) |
as today | their factor given in linear terms, so they darken as vanilla's |
| Horizon's fog | mixed in gamma, as vanilla's | mixed in gamma too: encoded, mixed, decoded |
What a 0.2 stage samples from the frame (scene_color, opaque_color) |
decoded: once before the post chain (a full-screen pass), and in the copy opaque_color already is |
as stored |
The water mirror and shadow_color draw no blends, so they store linear in both.
Cost and support.
Rgba16Floatis 8 bytes a pixel, asRgba16Unormis. HDR on keeps two targets at the frame's size, as today: the frame, and the HUD's target in place ofkeep_world's copy, plus the HUD's one-byte coverage.- A 0.2 pack in SDR moves the frame from 4 to 8 bytes a pixel (about 15 MB more at 1440p), only while it runs. Its
post chain already uses
Rgba16Float. - WebGPU renders to, blends and filters
Rgba16Floatwithout optional features, unlikeRgba16Unorm(draws_wide_frame); the 8-bit fallback stays only for the HUD's target. The GL backend is checked in the series. - Up to 1, half float steps at least 8 times finer than 8-bit, and far finer in the darks, so Smooth Gradients' gain stays without its setting.
Light above white without marks
Each shader that marks the frame today writes the light instead:
- Light sources (
mesherEMISSION_BITS,hdr_tier,gfx/hdr.rsTIERS): the face's colour times its tier's gain, in units of paper white. Easing into the peak moves to the tone curve. - HDR lighting (light levels 11 to 15, up to 4 times): the lightmap going past 1, which is what its alpha means now.
- Where: terrain and fluids (
terrain.wgsl,mesher/fluid.rs, distant terrain inlod/mesh.rs, the visibility buffer), particles (particles.rs), the sun and moon (fs_sky_celestial), entities, dropped and held items (entity.wgsl,dynamic_light.rs) and lightning (gfx/lightning.rs). - The hand draws at gain 1, as it stays at the world's brightness today, and before
keep_world(see the HUD below), so it needs no mark.
The target look for HDR on without a pack is today's, within the offscreen checks' tolerance, at Auto (203 and 1000 nits). Any change of look is a separate pull request, with screenshots.
Under a 0.2 pack Horizon lifts nothing: the pack gets vanilla's picture in linear light whatever the display, reads
emission in its material stage, and writes what shines. (Telling post stages which pixels are light sources is a
likely 0.2 addition, not designed here.)
The HUD and menus
The HUD always draws in gamma, with vanilla's blends, onto a picture clipped to white, as today:
- HDR off without a pack, and any 0.1 pack: onto the frame, as today.
- A 0.2 pack in SDR: the display pass writes the tone curve's result to the swapchain in gamma, and the camera overlays and the HUD draw on it, as they draw after a pack's post chain today.
- HDR on, with or without a 0.2 pack, as below.
With HDR on:
- Camera overlays belong to the world.
hud_overlays::camera_overlays(the vignette, the pumpkin and powder-snow overlays and the rest) and the hand draw onto the tone-mapped world beforekeep_world(today just after it), converted as Horizon's own draws are underblend = "linear", so they darken or cover light above white too, the world border's red vignette included. keep_worldbecomes a pass instead of a copy. The frame keeps the world, light above white included; the pass writes it clipped to white, in gamma, with alpha 0, into the HUD's target in today's HDR frame format (Rgba16Unormwhere the device draws it, else 8-bit). That backdrop is what the HUD draws on, so it clips as today.- The HUD and menus keep today's blends, alpha included, so the crosshair and
GUI_INVERT(which invert what is under them), the text highlight, the menu blur and CIT glint layers (whose blends may read the destination's alpha,hud/cit_glint.rs) see what they see today; the backdrop's alpha 0 is the alphakeep_worldleaves today. - Coverage has a target of its own, one 8-bit channel at the same size, cleared to 0, which every HUD draw writes
as a second output with a blend of its own, only with HDR on: how much of the backdrop is hidden. Draws that lay a
layer over what is under it (vanilla's translucent GUI blend, and the depth-tested one) add their alpha over it
(
One, OneMinusSrcon their alpha); additive draws (the text highlight) leave it, since they hide nothing; the rest (the inversion, the End portal fill, CIT glint layers) count as covering what they draw, by their texel's alpha, the same way. - The display pass composites in gamma, the space the HUD blends in. With
cthe coverage,Bthe backdrop andPthe HUD's target, the HUD's own part isH = P - (1 - c) * B, exact for the layering blends. The output isHat UI Brightness (H * ui^(1/2.2), so a fully covered pixel is today's covered pixel, scaled by UI Brightness in linear light) plus(1 - c)of the kept world in gamma, decoded for the display. At the default 100%, wherever the world is at or below white, that isP, today's picture. Behind see-through HUD the world keeps its light above white, weighted as vanilla weights the HUD over it, and doesn't dim with UI Brightness. Inverted pixels count as covered, so the crosshair's edges are exact where its texels are opaque. - The menu blur keeps light above white. It blurs the composite so far, instead of the clipped picture, writes the result as the new kept world, resets the HUD's target to it, clipped, with alpha 0, and clears coverage to 0. The menu's own darkening then layers over it as any HUD does: a bright sun behind a paused game stays bright, dimmed with the rest, so pausing doesn't change the picture's brightness.
shows_worldand the hand's mark go.
0.1 packs: the shim
Horizon serves both lines (manifest.rs API becomes a list). A 0.1 pack runs on today's path, in SDR and in HDR:
- today's frame (8-bit, or
Rgba16Unormwith HDR on), inputs and gamma values; - its post chain through half-float targets that keep values above 1 between passes, onto the frame;
- the marks its stages pass on (
input.color.a), and the HDR pass lifting light sources on the pack's final colour, with HDR lighting off.
That path stays, as a variant of the pipelines that write marks, while 0.1 is served. Golden Hour stays at 0.1 as its test pack until then; a 0.2 port sits beside it.
0.1 ends at step 5. The shader API is experimental: it may change in any release without notice, with no deprecation window (api/stability.md). Serving 0.1 beside 0.2 for longer would keep this shim, the pipeline variants that write marks and the HDR pass's lift from them, only for 0.1 packs, which can move to 0.2. So when step 5 turns 0.2 on by default, Horizon stops serving 0.1:
- a 0.1 pack is refused with the message stability.md asks for (
needs API 0.1; this Horizon serves 0.2); - the registry lists 0.2 packs instead of 0.1 ones;
- Golden Hour's 0.2 port replaces it as the example and test pack;
- the changelog says so.
Later lines follow the same rule while the API is experimental: a line is served until the next one is on by default. From preview on, stability.md's deprecation windows apply.
shaders/-format packs
They keep their own buffers and tone map to SDR in their last program, and their alpha is theirs. Today their
final program writes all four channels onto the frame (gfx/pack_post.rs target), and keep_world copies that
alpha into the kept world. With HDR and its HDR Lighting setting on, the HDR pass reads it as marks: below 0.75 it
lifts as a light-source tier, from 0.75 up nothing (the lighting gain is off under a pack). From this note on their
picture is shown at paper white with nothing lifted, as #3125 suggests. HDR values they could read (hdr, headroom,
paper white) are a later, optional addition for pack authors.
Upscaling, anti-aliasing, frame generation
They read the frame as it is stored and, outside the 0.1 path, have no marks to keep, which fixes the alpha problems above. Native upscaling (#1403) should take the float frame. TAA over values above 1 may need a tone-weighted resolve so bright pixels don't sparkle. Frame generation stays off with HDR output, as today.
Tests
- HDR off, no pack, bit for bit. The frame format and pipelines for this case don't change (unit tests on
frame_formatand the pipelines' targets). A screenshot test pins the picture: pictures of the game hold Minecraft's textures and can't be committed, so it is differential. A runner with a copy of Minecraft and Mesa's software Vulkan builds main and the change, captures fixed scenes offscreen withHORIZON_STILL=1(repeatable frames, #9398, in review), and requires identical PNGs, also after turning HDR on and off, picking and dropping a 0.2 pack, and, while 0.1 is served, swapping it for a 0.1 one. - Golden Hour (0.1) against main, while 0.1 is served: the same capture in SDR, and the offscreen HDR capture in HDR, identical.
- HDR on, HDR Lighting off, against SDR: the offscreen HDR capture (#8947), within one 8-bit step outside the spots vanilla clips.
- HDR look against today: the same captures within a tolerance, with screenshots in the pull request, including the vignette and the world border's red warning over bright light, the crosshair, chat over a torch, a blurred menu, and translucent black and coloured HUD at UI Brightness 50% and 100%.
- Cost: GPU time and memory, HDR on, against today at 1080p and 1440p, on a desktop GPU and an integrated one.
- Linear blending: a 0.2 test pack with
blend = "linear"draws solid surfaces as with gamma, and glass over them as linear blending gives.
Steps
Behind HORIZON_HDR_FRAME=float until the last step, each a small pull request:
- This note.
- Shader API 0.2 in
horizon-shader-api: the rule, theFramefields,[tonemap],[frame] blend, both lines served, the reference regenerated, Golden Hour's 0.2 port. - The float frame with HDR on: the frame, the display pass decoding it, and light written instead of marked, one group of shaders a pull request (terrain and fluids, particles and the sky, entities and items, the rest).
- The tone curve, camera overlays and the hand before
keep_world, the HUD's target and coverage, the display pass's composite, the blur, the rebuild when the frame's case changes, and the 0.1 path split off; marks no longer written outside it. - On by default and the switch removed, and 0.1 no longer served (see 0.1 packs). The shim goes with it: the pipeline variants that write marks and the HDR pass's lift from them, in a pull request of its own.
Later, each its own note or pull request: HDR's light-source lift as a built-in preset on 0.2, values for
shaders/ packs, and which settings stay on the HDR page and which move to the shader menu. Additions to 0.2 that
break no pack, so they can wait: telling post stages which pixels are light sources, and a full-screen peak for
displays that dim large bright areas below peak (Windows reports it, DXGI's MaxFullFrameLuminance; other systems
mostly don't).
Decisions for Ludovic
Each has a recommendation; the step it blocks says when it's needed.
| Decision | Options | Recommended | Why | Needed before |
|---|---|---|---|---|
| Where the HDR-off screenshot test runs | A. The parity runners: build main and the change, capture fixed scenes with HORIZON_STILL=1 on Mesa's software Vulkan, compare PNGs. B. A synthetic scene with generated textures and a committed digest. |
A | It tests the real game with real resource packs; B's digest changes with every Mesa update and covers less. A costs a second build a run, and needs #9398. | Step 3, the first change to the frame |
| How tone mapping is written | A. A [tonemap] stage of its own. B. The last post pass, with a flag. |
A | A pack that only grades keeps Horizon's curve, and one that only tone maps needs no post chain; the check can hold the stage to its range (0 to headroom); upscaling and anti-aliasing can run between the post chain and the curve. |
Step 2, the API |
paper_white and peak in SDR |
A. 80 nits, sRGB's reference white (scRGB's 1.0). B. 0, for unknown. | A | A pack that divides by them never gets infinity or NaN; hdr = 0 already says they're nominal, and headroom is 1 either way. |
Step 2, the API |