HDR Display P3: palette-authored wide gamut (design)
HDR Display P3: palette-authored wide gamut
Section titled “HDR Display P3: palette-authored wide gamut”Amended 2026-10-12 (Chris): the UI now widens to P3 too, reversing the “HUD/UI stays sRGB” decision below. It goes through one sanctioned path (
ColorGamut.ui_out, the LiveTokens output filter) with its own HDR-only dialColorProfileHdr.GAMUT_UI_WIDEN. Current rule:docs/architecture/rendering-and-performance.md§ Display P3 output anddocs/architecture/ui-tokens.md§ HDR Display P3. The rest of this spec is the 2026-10-11 design as approved.
Status: approved design (Chris, 2026-10-11). It supersedes the blit-reinterpretation plan
docs/superpowers/plans/2026-10-11-hdr-p3-gamut.md. Follow-up (approach C, a wide-gamut working space) is
stubbed in 2026-10-11-wide-gamut-working-space-design.md.
With HDR output on, saturated gameplay colours reach the Display P3 gamut edge on the Apple TV and the iPhone. Hue stays the same, and HUD/UI never changes. SDR, web and Android stay byte-identical to today. A live tunable dials the effect from 0 (today’s look) to 1 (the full P3 edge). The console can A/B it mid-run.
Why not the earlier plan
Section titled “Why not the earlier plan”The superseded plan re-read finished sRGB pixels as P3 in the final blit. That skews hues (for example, orange moves toward red). It also hits everything on screen, UI included, and it decides on the image instead of the palette. Chris rejected it as a hack: “do it properly”.
Decisions (Chris, 2026-10-11)
Section titled “Decisions (Chris, 2026-10-11)”| Question | Decision |
|---|---|
| What widens | Gameplay colour sources only: element palette, weapon tints, enemy/boss/FX hot cores + halos, backdrop accents. HUD/UI stays sRGB. |
| Target gamut | Display P3, not Rec.2020. It is what Apple EDR and current TVs and iPhone panels actually cover. |
| Approach | A: widen at the colour sources (game-side), with the engine carrying P3 through tonemap, glow and output. B (per-pixel widen in the engine) was rejected. C (wide-gamut working space) is planned for later. |
| Engine base | Whichever base the Godot 4.8 A/B picks (memory bullet-heaven-godot-48-tvos-ab). The patch is the same shader code on the 4.7.dev tvOS fork, on tvos-4.8dev, and in the iOS template built from source. |
Background (verified in the engine source, 4.8 master = 4.7 fork here)
Section titled “Background (verified in the engine source, 4.8 master = 4.7 fork here)”- Canvas vertex/instance colours are packed half floats (
canvas_uniforms_inc.glsl,uint colors[6]), and 2D HDR targets are RGBA16F. So negative extended-sRGB components survive drawing and additive blending [Likely; proved by the Task 1 swatch scene]. - Three places then cut them to the sRGB gamut [Certain]:
tonemap.glsl:253runscolor = max(vec3(0.0), color)before every non-Linear tonemapper.tonemap.glsl:424runsglow.rgb = clamp(glow.rgb, 0.0, white)in the glow composite.blit.glsl:169runsclamp(color.rgb, 0, output_max_value)on theCOLOR_SPACE_REC709_LINEAR(HDR) path.
- The Apple layer is extended linear sRGB, where negatives are legal and encode colours outside sRGB. For example, P3 red is about (1.358, −0.074, −0.012) [Certain, Apple forum 116865].
- Layers above
canvas_max_layer(HUD) skip the tonemap pass entirely.
Design
Section titled “Design”1. Engine (fork)
Section titled “1. Engine (fork)”- New runtime property
Viewport.output_gamut: an enum withOUTPUT_GAMUT_SRGB(default) andOUTPUT_GAMUT_P3.OUTPUT_GAMUT_P3_WORKINGis reserved for approach C and not implemented.- It is live-settable from GDScript and pushed into the tonemap and blit params for that viewport’s render target.
- The game detects capability with
ClassDB.class_has_method("Viewport", "set_output_gamut")(ClassDB has noclass_has_property). Stock, web and Android templates lack it, so they report “not capable”.
- Effective only when
P3is set AND HDR output is engaged (the blit target isCOLOR_SPACE_REC709_LINEAR). Otherwise every pass is byte-identical to today. - Tonemap (
tonemap.glsl):- Convert linear sRGB to linear P3 immediately before the
max(0)at :253. - Run the tonemapper (Linear, Reinhard and AgX alike) in P3.
- Convert back to linear sRGB after it.
- In-gamut P3 colours are positive in P3, so they survive. Anything outside P3 is clamped at the P3 edge.
- AgX’s matrices were tuned for Rec.709. Running it on P3 primaries is a deliberate, tunable trade-off. The Linear tonemapper gives the clean comparison.
- Convert linear sRGB to linear P3 immediately before the
- Glow composite (
tonemap.glsl:424): clamp in P3 space, i.e. to P3, then clamp to [0, white], then back. - Final blit (
blit.glsl:169, theREC709_LINEARbranch): clamp in P3 space, i.e. to P3, then clamp to [0, M], then back. The output stays tagged extended linear sRGB, so theCAMetalLayercolour space does not change on any platform. - The matrices are the standard linear sRGB↔Display P3 (D65) pair, shared through one
.glslinclude. - Platforms:
- tvOS: the fork.
- iOS: the template built from the same patched source. The iOS/macOS link source is re-checked before Task 1 (the old plan assumed stock 4.7.1 + patch).
- macOS dev builds get it for free if built from the fork.
- Web and Android stay stock.
2. Game
Section titled “2. Game”render/color_gamut.gd(new,RefCounted, pure, headless-testable):- OKLab/OKLCH conversions, linear sRGB↔P3 matrices, and a gamut-boundary search.
widen(c: Color, amount: float, target: int = P3) -> Color:- keeps OKLCH hue and lightness;
- finds the target gamut’s max chroma at that L and hue by binary search;
- moves chroma from the source’s position relative to the sRGB boundary to the same relative
position on the target boundary, blended by
amount. So sRGB-edge colours land on the P3 edge, mid-chroma colours scale proportionally, and neutrals don’t move; - returns extended linear sRGB (negatives allowed); the alpha channel is untouched.
- Inputs and outputs follow the existing Color conventions of each call site. Conversion to linear for the maths and back is internal.
- Never call
Color.h/.s/.von a widened result (negative channels make HSV meaningless). Widening is always the LAST step at a source.
ColorGamut.active() -> bool: true when HDR output is engaged AND the engine is capable ANDColorProfileHdr.GAMUT_P3is on. This one function drives BOTH the engine property (get_viewport().output_gamut, set frommain._sync_hdr_output) and the palette. So the game can never emit P3 colours that the engine would clip back to sRGB. This repeats the HDR request/hold coupling lesson (memorybullet-heaven-hdr-output, Design).- Tunables (HDR profile only; SDR has no equivalents, so it cannot change):
ColorProfileHdr.GAMUT_P3: bool = trueis the kill switch.ColorProfileHdr.GAMUT_WIDEN: float = 1.0is the dial.- Both are registered tunables, with
tools/tunable_docs/docs.dlabels, a LiveScope hook, and the on-device console change note. - A Debug Settings row gives on/off parity (memory
bullet-heaven-debug-settings-console-parity).
- Application points (sources only, cached):
ElementPalette.color_for/vivid/weapon_color(8 caller files acrossrender/andui/today; theui/ones move to the sRGB accessor);- the looks’ hot-core and halo constants (the
ENEMY_HOT_CORE/SWARM_NOVA_HOT/WFX_*/BOSS_HOT/FX_WHITE_CORE/NOVA_EMBER/AURA_HOTfamily); - backdrop accent colours;
ShaderGlobalscolour uniforms.- Widened values are cached per source colour and rebuilt when
HdrLook.update()reports a profile change, or when either tunable changes. No per-frame colour maths.
- Shader literals: about 58
.gdshaderfiles containvec3(...)literals, mostly not colours. The plan audits them. Gameplay-colour literals become global uniforms fed from the profile; UI shaders are left alone. - UI exclusion is structural:
- Nothing under
ui/callsColorGamut.widen, and a GUT grep test enforces it. - UI code that shows an element colour (Codex, shop cards) uses an explicit sRGB accessor
(
ElementPalette.color_for_srgb), never the widened one.
- Nothing under
- Telemetry: none. The setting is not a new kind of device data.
3. Testing and verification
Section titled “3. Testing and verification”- GUT:
ColorGamut: OKLab round-trip within 1e-5.- The P3-red anchor.
- Hue preserved within 0.5° on a 360° sweep.
widen(c, 0) == c; neutrals unchanged.- Results inside P3 and outside sRGB for edge inputs.
- The
active()truth table, including “engine not capable”. - Cache rebuild on profile and tunable change.
- The UI grep ratchet.
- Determinism: render-only. The three seed-1234 baselines must not move, and
scripts/gate.shproves it. - Engine:
- A windowed shader compile check under
mobileandgl_compatibilityfor every edited shader on the chosen base (the headless gate never compiles shaders). - A probe that sets
output_gamutand reads it back. - Task 1 swatch scene: a P3-red patch beside an sRGB-red patch with HDR on. They must differ on iPhone and ATV with P3 on, and match with it off. This is also the proof that canvas negatives survive.
- A windowed shader compile check under
- Device:
- A BAKED iPhone Dev install, because shader edits can re-trigger the A17 baker draw bug (godot#124088).
- The ATV Dev install.
bh-fresh-install-stall-check.- Perf ABBA, P3 on/off, on tick_ms/render_cpu_ms. Budget: ≤0.1 ms render_cpu.
- Chris’s eyeball A/B:
- Console toggles
GAMUT_P3, then sweepsGAMUT_WIDEN0 / 0.5 / 1. - Hold each state 15-20 s in a real run, one variable at a time.
- Trust the on-device change note, not
/status. - Judge by eye only: iOS screenshots clip to SDR.
- Persist the picked values via code seeds plus a remote-config patch (the patch needs Chris’s OK).
- Console toggles
Sequencing
Section titled “Sequencing”- Engine patch on the chosen base, plus the swatch scene and device proof.
ColorGamut, the tunables,active()coupling and the palette chokepoints.- Looks and backdrop.
- Shader-literal audit and conversion.
- Device A/B and perf ABBA, then persist the values.
- bh-reviewer, then merge
hdr-colour-spaceto main (Chris’s OK).
- AgX on P3 primaries: it was tuned for Rec.709 and may shift the look. Mitigation: the Linear
tonemapper comparison, plus
GAMUT_WIDEN< 1. - TV gamut coverage below P3: the TV compresses colours the game can’t see. This is low risk, since most current TVs cover roughly 95% of P3.
- A source missed in the audit stays sRGB, which is safe and only a smaller gain. Mitigation: the vfx_library roster pass.
- The A17 baker bug on the new shader code. Mitigation: a baked iPhone check before merge.
Out of scope
Section titled “Out of scope”Rec.2020 targets, changing the layer colour space tag, UI/HUD colours, SDR/web/Android, and approach C.