Two full colour systems: HDR and SDR (design)
Two full colour systems: HDR and SDR
Section titled “Two full colour systems: HDR and SDR”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.
Decisions (Chris, 2026-10-08)
Section titled “Decisions (Chris, 2026-10-08)”- 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.
- 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.
- HDR output ON by default wherever the screen supports it, with a player-facing toggle and a peak-brightness calibration.
- SDR may change too: lessons from the HDR work improve SDR. SDR changes get their own A/B and their own verdict.
- 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.
- 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/saturationrun 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_colorare linearised (authored 2.0 becomes ≈4.95 linear); MultiMesh instance COLOR and plainvec3/vec4uniforms are NOT (2.0 stays 2.0). The codebase mixes both conventions. - [Certain]
glow_hdr_scaleis the width of the threshold soft ramp, not bloom spread or pass count; the comment atrender/environment_look.gd:21is 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).
Architecture
Section titled “Architecture”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:SDRorHDR, plus a dev/console override (auto/ force SDR / force HDR-values-on-SDR-screen for previews).- Two profiles,
ColorProfile.SDRandColorProfile.HDR, each a full set of registeredstatic vartunables (registry +tools/tunable_docs/docs.d/*.jsonlabels + LiveScope hook, perdocs/architecture/remote-config.md). Each profile holds:- Tonemap: mode (Linear/Reinhard/Filmic/ACES/AgX, later Shoulder), exposure,
tonemap_white, and AgX’s owntonemap_agx_white+tonemap_agx_contrast(verified on 4.7.1: AgX ignorestonemap_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_bloom0, 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.
- Tonemap: mode (Linear/Reinhard/Filmic/ACES/AgX, later Shoulder), exposure,
EnvironmentLook.apply()becomes profile-driven: it reads the active profile. The currentEnvironmentLookstatics ARE the SDR profile’s bundled values (no SDR change in step 1). The oldLDR_*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 existingHdrOutputdecision, so the request, the rootuse_hdr_2dhold 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.2lead edges, muzzle cores,element_accents.gdshaderinc×2.2, border halo ×2.2, galaxycore_bright, dissolve edge +1.6,web_look_bboost 2.2, funzo excess, etc.) becomeglobal uniformreads, DECLARED in[shader_globals]of BOTHproject.godotfiles 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 (
ArchetypeRendererSHELL/SHELL_GLOW/HALO/HOT_CORE,SwarmRendererCORE_HEAT/HOT_CORE_BOOST,WeaponFxLayer.BASE_DIM, the FXGAINs,HeroLightsenergies,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-44already does: MultiMesh instance COLOR inswarm_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 aglobal uniform bool cs_linearizethat 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_skintint,orbit_shardseh_core,glass_snapshotveil_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 nebulapow(1/2.2)compensation (render/nebula_bg.gd:163-174). The nebula keeps an 8-bit fallback thatQualityManagertiers can drop to (measure ATV cost first). ThreatAggregateGlow’sclamp(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.
4. Content fixes (both profiles)
Section titled “4. Content fixes (both profiles)”- 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) getuse_hdr = true; emit colours (fx/gpu_burst.gd,fx/thruster_trail.gd,fx/fx_manager.gd_spawn) and the legacy white-hotdraw_*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:146unchanged, colour scale per profile). - UI stays readable:
gold.glow(ui/tokens/defaults.json:47, used atmain.gd:3979andui/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_scalecomment inrender/environment_look.gd.
5. Player controls
Section titled “5. Player controls”- 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 tunableHdrOutput.ENABLEDdefault becomes true. - UI fit tested at the real device profiles (
ShopLayoutFixture: iPad 985, iPhone 1204, TV 1280), never only 1280.
6. Comparison tooling
Section titled “6. Comparison tooling”- Console:
profileflip (SDR / HDR / auto), a tonemapper selector per profile, every profile tunable live, thecolour_space_fixA/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.gdentries getscore_sdrandscore_hdr; an unscored mode renders a red card like MISSING, andtests/test_vfx_library_manifest.gdrequires 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.
Structural guards
Section titled “Structural guards”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/.gdshaderincunderrender/,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/vec4whose name containscol|color|tintmust besource_coloror 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)”- 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=truein console/status hdr).
- freeze-scene. Default output pixel-identical in SDR; HDR profile seeded but HDR output still
default off. Confirm HDR engages on Chris’s iPhone (
- Colour-space + LDR-SubViewport fixes behind
colour_space_fix(default off). Plan:docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step2.md. - 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)”. - Player HDR toggle + peak calibration;
HdrOutput.ENABLEDdefault true. - Chris’s tuning passes on ATV, iPhone, Mac (both modes), persisted by patch.
- 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.
- Phase 3: P3 / wide gamut (stop the blit clamping negatives in extended-linear sRGB), behind its own A/B.
Verification per step
Section titled “Verification per step”scripts/gate.shgreen; determinism baselines untouched (render-only; nothing in/sim).- Every shader edit compile-checked windowed under
--rendering-method mobileANDgl_compatibility, thengit 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.