Skip to content

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 dial ColorProfileHdr.GAMUT_UI_WIDEN. Current rule: docs/architecture/rendering-and-performance.md § Display P3 output and docs/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.

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”.

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]:
    1. tonemap.glsl:253 runs color = max(vec3(0.0), color) before every non-Linear tonemapper.
    2. tonemap.glsl:424 runs glow.rgb = clamp(glow.rgb, 0.0, white) in the glow composite.
    3. blit.glsl:169 runs clamp(color.rgb, 0, output_max_value) on the COLOR_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.
  • New runtime property Viewport.output_gamut: an enum with OUTPUT_GAMUT_SRGB (default) and OUTPUT_GAMUT_P3.
    • OUTPUT_GAMUT_P3_WORKING is 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 no class_has_property). Stock, web and Android templates lack it, so they report “not capable”.
  • Effective only when P3 is set AND HDR output is engaged (the blit target is COLOR_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.
  • 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, the REC709_LINEAR branch): clamp in P3 space, i.e. to P3, then clamp to [0, M], then back. The output stays tagged extended linear sRGB, so the CAMetalLayer colour space does not change on any platform.
  • The matrices are the standard linear sRGB↔Display P3 (D65) pair, shared through one .glsl include.
  • 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.
  • 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/.v on 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 AND ColorProfileHdr.GAMUT_P3 is on. This one function drives BOTH the engine property (get_viewport().output_gamut, set from main._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 (memory bullet-heaven-hdr-output, Design).
  • Tunables (HDR profile only; SDR has no equivalents, so it cannot change):
    • ColorProfileHdr.GAMUT_P3: bool = true is the kill switch.
    • ColorProfileHdr.GAMUT_WIDEN: float = 1.0 is the dial.
    • Both are registered tunables, with tools/tunable_docs/docs.d labels, 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 across render/ and ui/ today; the ui/ 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_HOT family);
    • backdrop accent colours;
    • ShaderGlobals colour 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 .gdshader files contain vec3(...) 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/ calls ColorGamut.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.
  • Telemetry: none. The setting is not a new kind of device data.
  • 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.sh proves it.
  • Engine:
    • A windowed shader compile check under mobile and gl_compatibility for every edited shader on the chosen base (the headless gate never compiles shaders).
    • A probe that sets output_gamut and 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.
  • 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 sweeps GAMUT_WIDEN 0 / 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).
  1. Engine patch on the chosen base, plus the swatch scene and device proof.
  2. ColorGamut, the tunables, active() coupling and the palette chokepoints.
  3. Looks and backdrop.
  4. Shader-literal audit and conversion.
  5. Device A/B and perf ABBA, then persist the values.
  6. bh-reviewer, then merge hdr-colour-space to 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.

Rec.2020 targets, changing the layer colour space tag, UI/HUD colours, SDR/web/Android, and approach C.