Skip to content

Two full colour systems: HDR and SDR (design)

Design spec, 2026-10-08. Approved section by section by Chris in brainstorming. Research inputs (read these first):

  • docs/research/2026-10-08-hdr-colour-best-practice.md: how Godot 4.7 maps scene values to the HDR swap chain, tonemapper behaviour, neon-HDR best practice, P3, Dolby Vision.
  • docs/research/2026-10-08-tvos-edr-hdr-output.md: the tvOS fork work that makes HDR output reach the Apple TV at all.
  • docs/research/2026-10-08-atmos-spatial-multichannel-audio.md: the audio sidebar (not part of this build; recorded so the question is answered).

Every player gets the best look we can produce on their screen. HDR screens (Apple TV on an HDR TV, HDR iPhones/iPads, EDR Macs) use the headroom above paper white properly. SDR screens (older phones, SDR TVs, the web build) get their own tuned look, not a leftover. Chris reviews both modes on every look change. No corners cut on either.

  1. Two full colour systems. Every colour source has an independent SDR value set and an HDR value set. HDR values are SEEDED from a headroom mapping once, then tuned independently. Nothing derives one mode’s values from the other at runtime. Chris checks both modes on every change.
  2. Every Godot tonemapper selectable in each mode (Linear, Reinhard, Filmic, ACES, AgX), each with its own parameters, so they can all be compared. The phase-2 fork shoulder is added as a sixth.
  3. HDR output ON by default wherever the screen supports it, with a player-facing toggle and a peak-brightness calibration.
  4. SDR may change too: lessons from the HDR work improve SDR. SDR changes get their own A/B and their own verdict.
  5. All HDR-capable platforms: tvOS (fork, done), iOS/iPadOS (stock EDR path, to be confirmed on Chris’s iPhone), macOS (EDR, works). Web (gl_compatibility) is SDR only.
  6. Out of scope: Dolby Vision (no tvOS API; DV-for-games is a Dolby SDK/licence route; we output HDR10/PQ), Atmos/multichannel audio (see the audio note), telemetry of HDR state (a new device-data kind would require the privacy page + ASC label; add only if Chris wants adoption numbers).

Key facts the design rests on (from the research)

Section titled “Key facts the design rests on (from the research)”
  • [Certain] With HDR output on, the Linear tonemapper passes values through and the final blit hard-clips per channel at the headroom (max_linear, ≈4.93 assumed on the ATV) and clamps negatives to 0. Per-channel clipping shifts hue and flattens overlapping halos.
  • [Certain] AgX adapts to the headroom but desaturates toward white; Reinhard equals Linear unless tonemap_white > headroom, then dims midtones; ACES/Filmic cap near 1.0 (no HDR gain). They stay selectable for comparison, labelled with that behaviour.
  • [Certain] Glow is added before the tonemap; adjustment_contrast/saturation run after it in sRGB-encoded space, so today’s 1.08 contrast / 1.14 saturation push more values past the clip.
  • [Certain] The HUD CanvasLayer is drawn after the tonemap with no glow/grade, so UI 1.0 = UI white.
  • [Certain] Colours via modulate/draw_*/source_color are linearised (authored 2.0 becomes ≈4.95 linear); MultiMesh instance COLOR and plain vec3/vec4 uniforms are NOT (2.0 stays 2.0). The codebase mixes both conventions.
  • [Certain] glow_hdr_scale is the width of the threshold soft ramp, not bloom spread or pass count; the comment at render/environment_look.gd:21 is wrong and gets fixed.
  • [Certain] Godot on Apple ignores game-side reference/peak luminance settings; a peak control has to be ours (it scales the HDR budget in game code).

1. Colour profiles: render/color_profile.gd (+ render/hdr_look.gd)

Section titled “1. Colour profiles: render/color_profile.gd (+ render/hdr_look.gd)”
  • HdrLook (render-only, never read by /sim) answers: is HDR output engaged on this window, and what is the usable headroom = window.get_output_max_linear_value() × the player’s peak calibration (1.0 whenever HDR is off). It owns the active mode: SDR or HDR, plus a dev/console override (auto / force SDR / force HDR-values-on-SDR-screen for previews).
  • Two profiles, ColorProfile.SDR and ColorProfile.HDR, each a full set of registered static var tunables (registry + tools/tunable_docs/docs.d/*.json labels + LiveScope hook, per docs/architecture/remote-config.md). Each profile holds:
    • Tonemap: mode (Linear/Reinhard/Filmic/ACES/AgX, later Shoulder), exposure, tonemap_white, and AgX’s own tonemap_agx_white + tonemap_agx_contrast (verified on 4.7.1: AgX ignores tonemap_white; effective white = max(2, agx_white) × output max).
    • Grade: brightness, contrast, saturation (HDR seeds: 1.0/1.0/1.0).
    • Bloom: intensity, strength, bloom, hdr_threshold, hdr_scale, blend mode, levels 1-7. HDR seeds the threshold relative to headroom and glow_bloom 0, so wide bloomed areas don’t trigger TV auto-dimming.
    • Brightness tiers: UI cap, body/zone/background level, highlight level, flash level.
    • Per-source values: every boost and white-hot constant listed in the inventory below.
  • EnvironmentLook.apply() becomes profile-driven: it reads the active profile. The current EnvironmentLook statics ARE the SDR profile’s bundled values (no SDR change in step 1). The old LDR_* web path stays the web build’s own values, part of the SDR profile.
  • Profile switches (HDR engages/disengages, console flip) re-apply the environment and push the shader globals (below) in the same frame. main._sync_hdr_output() is the single trigger, next to the existing HdrOutput decision, so the request, the root use_hdr_2d hold and the profile can never disagree.

2. Shader constants: global shader uniforms

Section titled “2. Shader constants: global shader uniforms”
  • White-hot multipliers and HDR gains that are hard-coded in shaders today (2.2 lead edges, muzzle cores, element_accents.gdshaderinc ×2.2, border halo ×2.2, galaxy core_bright, dissolve edge +1.6, web_look_b boost 2.2, funzo excess, etc.) become global uniform reads, DECLARED in [shader_globals] of BOTH project.godot files and only SET at runtime (RenderingServer.global_shader_parameter_set). Revised 2026-10-09 after a spike on 4.7.1: runtime-only globals make the export shader baker fail (“Global uniform ‘X’ does not exist”) and leave the shader unbaked, i.e. a first-use stall on device. Granularity: one global per role per family where Chris needs separate control (e.g. wfx_white_hot, wfx_body, boss_white_hot, border_halo, galaxy_core, dissolve_edge), not one global for everything.
  • Each global has an SDR and an HDR value in the profiles; the active profile pushes them.
  • GDScript-side boosts (ArchetypeRenderer SHELL/SHELL_GLOW/HALO/HOT_CORE, SwarmRenderer CORE_HEAT/HOT_CORE_BOOST, WeaponFxLayer.BASE_DIM, the FX GAINs, HeroLights energies, EnemyAuraLook ×1.6, plasma nova, Warden drone colour) read the active profile instead of a const. Hot paths read a cached per-frame value, never a dictionary lookup per instance.

3. Colour-space consistency (behind its own A/B row)

Section titled “3. Colour-space consistency (behind its own A/B row)”
  • Every shader receiving a colour from code converts sRGB→linear in the shader, as render/zone_field.gdshader:42-44 already does: MultiMesh instance COLOR in swarm_renderer, archetype_renderer, far_stars, star_field, weapon_fx_layer (all 12 v2 weapon looks) and the 7 Warden shaders. One shared include (render/shaders/color_space.gdshaderinc) with the helper and a global uniform bool cs_linearize that the A/B row drives.
  • Plain colour uniforms become source_color: nebula_bg, low_hp_border, threat_aggregate_glow, aim_lance, arena_galaxy, death_dissolve/funzo_void_skin tint, orbit_shards eh_core, glass_snapshot veil_color. Where a uniform intentionally carries >1 values, keep it plain and convert explicitly instead.
  • LDR SubViewports follow the root’s use_hdr_2d: render/nebula_bg.gd:82, ui/threat_radar.gd:161, ui/codex_card.gd:18. Delete the nebula pow(1/2.2) compensation (render/nebula_bg.gd:163-174). The nebula keeps an 8-bit fallback that QualityManager tiers can drop to (measure ATV cost first).
  • ThreatAggregateGlow’s clamp(total, 0, 1) stays (UI-like), but its target follows the root.
  • Because these change what SDR players see, they ship behind one A/B row (colour_space_fix, console + Debug Settings parity) and get their own verdict before becoming default.
  • Hero brightness: the player ship’s halo, thruster, heal glow, flash and rim (render/player_renderer.gd) get highlight-tier values in both profiles. Today they’re capped at ≤1 while enemy hot cores reach 2.0.
  • Sparks and legacy FX: GPU particle ramps (render/shaders/*_process_mat.tres) get use_hdr = true; emit colours (fx/gpu_burst.gd, fx/thruster_trail.gd, fx/fx_manager.gd _spawn) and the legacy white-hot draw_* cores (fx/fx_manager.gd:128,172,216,290,340) read profile values.
  • Far stars: pinpoint highlights in HDR via the profile (alpha cap at render/far_stars.gd:146 unchanged, colour scale per profile).
  • UI stays readable: gold.glow (ui/tokens/defaults.json:47, used at main.gd:3979 and ui/level_up_panel.gd:77) is capped at UI white in the HDR profile; glass frost/snapshot backdrops (ui/theme/glass_frost.gdshader:13-15, glass_snapshot.gdshader:18-23) clamp the backdrop term so neon behind a panel can’t exceed UI white under text; UI flashes (FLASH_HDR) capped below peak in HDR. UI tokens otherwise stay one system (UI white is the same in both modes by construction).
  • Fix the wrong glow_hdr_scale comment in render/environment_look.gd.
  • Settings rows (per-device save): HDR on/off (default on; row hidden when the screen can’t do HDR and on web) and HDR brightness calibration (standard “raise until the symbol is barely visible” screen; the result scales the HDR profile’s headroom budget). An SDR brightness/contrast control is NOT included unless Chris asks.
  • The row writes the same switch the Debug Settings “HDR output (A/B)” row and the console use (HdrOutput), so there is one decision path. The remote tunable HdrOutput.ENABLED default becomes true.
  • UI fit tested at the real device profiles (ShopLayoutFixture: iPad 985, iPhone 1204, TV 1280), never only 1280.
  • Console: profile flip (SDR / HDR / auto), a tonemapper selector per profile, every profile tunable live, the colour_space_fix A/B. Debug Settings parity for each row (memory: Debug Settings ↔ console parity).
  • A freeze-scene preset: pause + a fixed busy scene (boss, webs, evolved weapons, dense swarm, zones) so both modes are compared on the same pixels. Built on the existing dev-command / perf scenario path.
  • VFX library: tools/vfx_library/library_manifest.gd entries get score_sdr and score_hdr; an unscored mode renders a red card like MISSING, and tests/test_vfx_library_manifest.gd requires both fields. The library page gets an SDR/HDR toggle.
  • Approved values persist through remote-config patches (publishing needs Chris’s authorization), then are folded into the bundled defaults at the next build.
  • tests/test_color_profiles.gd: every key in the SDR profile has an HDR counterpart and vice versa; the SDR profile’s bundled values equal today’s literals (step 1 is pixel-identical).
  • A shader lint test: any .gdshader/.gdshaderinc under render/, fx/, ui/ that multiplies colour by a literal > 1.0 fails unless the literal is a named global or carries an // sdr-hdr: <reason> exemption. New effects can’t skip the two-system rule silently.
  • A colour-uniform lint: a uniform vec3/vec4 whose name contains col|color|tint must be source_color or carry the exemption comment.
  • Manifest test requires both scores per VFX entry.

Rollout (each step its own branch, review, merge)

Section titled “Rollout (each step its own branch, review, merge)”
  1. Profile infrastructure + HdrLook + tonemapper selectors + shader globals plumbing + console flip
    • freeze-scene. Default output pixel-identical in SDR; HDR profile seeded but HDR output still default off. Confirm HDR engages on Chris’s iPhone (enabled=true in console /status hdr).
  2. Colour-space + LDR-SubViewport fixes behind colour_space_fix (default off). Plan: docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step2.md.
  3. Seeded HDR values for every source, hero + sparks + legacy FX + UI caps. Plan: docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step3.md; architecture: docs/architecture/rendering-and-performance.md § “HDR brightness sources (HDR/SDR colour systems step 3)”.
  4. Player HDR toggle + peak calibration; HdrOutput.ENABLED default true.
  5. Chris’s tuning passes on ATV, iPhone, Mac (both modes), persisted by patch.
  6. Phase 2: fork hue-preserving shoulder in the HDR blit (scale RGB together toward the headroom instead of per-channel clip), as the sixth tonemapper option. Needs a fork build for tvOS; check whether the iOS/macOS builds use fork or stock templates before promising it there.
  7. Phase 3: P3 / wide gamut (stop the blit clamping negatives in extended-linear sRGB), behind its own A/B.
  • scripts/gate.sh green; determinism baselines untouched (render-only; nothing in /sim).
  • Every shader edit compile-checked windowed under --rendering-method mobile AND gl_compatibility, then git checkout -- project.godot audio/bus_layout.tres.
  • Step 2: ATV perf check of the HDR nebula target (bh-telemetry / console fps, ABBA rounds).
  • After shader/export changes, a fresh-install stall check (skill bh-fresh-install-stall-check): new globals and shader variants can add first-use pipelines.
  • Each step ends with Chris’s eyes on both modes on device; no step is called done on headless evidence.
  • Workload: ~60 sources become 120+ tunables and every new effect needs two tunings and two verdicts. Accepted by Chris.
  • SDR change for everyone from the colour-space fixes: mitigated by the A/B row and separate verdict.
  • Headroom guess: 4.93 on the ATV is assumed; the calibration screen is the real answer.
  • Bandwidth: HDR SubViewports (nebula full-screen) on the ATV A15/A12 class GPUs; measured before making it default, with an 8-bit tier fallback.
  • Shader permutations from new globals could add first-use pipeline stalls; covered by the fresh-install check.

Appendix: colour-source inventory (2026-10-08)

Section titled “Appendix: colour-source inventory (2026-10-08)”

Already > 1.0 (become per-profile values): render/archetype_renderer.gd:165,176,187,190, render/swarm_renderer.gd:17,26, the 12 fx/weapons/*.gdshader looks + element_accents.gdshaderinc:49, the 7 fx/boss/warden_*.gdshader, render/shaders/border_look.gdshaderinc:26, render/shaders/arena_galaxy.gdshader:18, render/shaders/death_dissolve.gdshader:16, funzo_void_skin, fx/enemy_aura_look.gd:146, fx/weapons/plasma_nova_node.gd:99-101, main.gd:2987, render/funzo_zone_field.gdshader:45-48, render/web_look_b.gdshader:88, render/hero_lights.gd:17, render/funzo_living_shadow.gd:29.

Capped at ≤ 1 today: ElementPalette.vivid() (render/element_palette.gd:21-22), nebula LDR target, threat radar LDR target + clamp, far-star alpha, GPU particle ramps + emit colours, legacy fx/fx_manager.gd white cores, the player ship (render/player_renderer.gd).

Colour-space inconsistent: MultiMesh instance COLOR (swarm, archetype, far_stars, star_field, weapon_fx_layer, Warden), plain colour uniforms (listed in §3), LDR SubViewports (nebula, threat radar, codex card), nebula pow(1/2.2), gold.glow ×1.35 applied to sRGB values (≈1.9 linear), glass backdrops unclamped.

Must stay at UI white: token Theme + 172 Tokens.c() sites, glass panels, damage numbers, HUD, dialogue, level-up, shop, debug settings, screen feedback.