HDR alpha remap (linear-canvas wash fix) — design
HDR alpha remap — design (2026-10-10)
Section titled “HDR alpha remap — design (2026-10-10)”Branch hdr-colour-space. Approved by Chris 2026-10-10 (approach A, “measured worst first” coverage).
Amended 2026-10-11 (Chris): the remap follows the LINEAR ROOT CANVAS (
use_hdr_2d), not the colour profile. Linear blending also happens at Metal L0 with HDR output OFF, so “HDR value live exactly when the canvas blends linearly” (§ Design 1) was false.hdr_alpha_gamma= the active profile’sALPHA_GAMMAwhile the root is linear, else 1.0;ColorProfileSdr.ALPHA_GAMMAseed is now 2.2 (both independent). Current architecture:docs/architecture/rendering-and-performance.md§ “A linear canvas blends alpha”.
Problem
Section titled “Problem”On the 8-bit SDR canvas, alpha blending runs on sRGB-encoded values; on the HDR (use_hdr_2d) canvas it
runs in linear light. A faint fill authored for SDR is ~9-10x brighter on HDR (colour 0.78 at alpha 0.07
over black: 0.0043 linear on SDR, 0.040 on HDR). Every low-alpha layer lifts the dark/mid tones into a milky
veil: Chris’s “HDR very washed out” on the ATV. Glow, brightness and colour_space_fix cannot fix it
(tried live; docs/architecture/rendering-and-performance.md § “The HDR canvas blends alpha in LINEAR light”).
Measurement (Mac, tools/cs_capture CS_PROFILE=ablate, ATV state, headroom 4.93, 2026-10-10)
Section titled “Measurement (Mac, tools/cs_capture CS_PROFILE=ablate, ATV state, headroom 4.93, 2026-10-10)”Whole frame p75: HDR 0.0198 vs SDR 0.0009 (22x). Per-layer contribution, p75 HDR / SDR:
| Layer | HDR | SDR | Draw path |
|---|---|---|---|
| arena_background | 0.0181 | 0.0009 | additive_mat child layer + arena_grid/aurora/galaxy/star_field shaders |
| zone_renderer | 0.0157 | 0.0009 | additive_mat + web_field family shaders |
| archetype_renderer | 0.0035 | 0.0003 | additive_mat halo/hot/glow/shell + enemy_smear shader |
| hud | 0.0034 | 0.0003 | StyleBoxFlat panels (default material) |
| enemy_aura_look | 0.0006 | 0.0001 | additive_mat |
Over black, SDR yields L(c·a) and HDR L(c)·a'. For a power-law transfer L(x) = x^γ, a' = a^γ
matches exactly and independently of colour. sRGB is close to γ 2.2. Over non-black backgrounds (and for
additive layers) it is an approximation, which is why γ is a live tunable judged by eye.
Design
Section titled “Design”- New profile static
ALPHA_GAMMA:ColorProfileSdr.ALPHA_GAMMA = 1.0(identity),ColorProfileHdr.ALPHA_GAMMA = 2.2. Registered tunable on both, with docs.d labels. The colour profile already follows the samewantas the rootuse_hdr_2dhold (main._sync_hdr_output→HdrLook.update), so the HDR value is live exactly when the canvas blends linearly, independent ofcolour_space_fix. - New global
hdr_alpha_gammainShaderGlobals.GLOBALS(→ALPHA_GAMMA), declared in[shader_globals]of BOTHproject.godotandplatform/tvos/project.godotat the SDR value 1.0 (;comments only). A liveset_tunableschange re-pushes (verify the existing LiveScope path does this for profile statics; add the hook if not). - New include
render/shaders/hdr_alpha.gdshaderinc:global uniform float hdr_alpha_gamma;andfloat hdr_alpha(float a) { return pow(a, hdr_alpha_gamma); }(guarda <= 0.0). Callers apply it as the LAST write ofCOLOR.ainfragment()(a shader with nofragment()gets one that only does that). fx/additive_mat.tresbecomes a saved ShaderMaterial (render_mode blend_add+ the include), same path, so the ~50 preload sites keep pointing at it. Their typedvar x: CanvasItemMaterial = preload(...)declarations becomeMaterial(mechanical).ColorSpaceidentity checks (== ADDITIVE) still work. CS_ADD and CS_MIX get the include too, so the MultiMesh swap undercolour_space_fixkeeps the remap.- Shaders behind the measured layers get the include: arena_grid_breathe, arena_aurora, arena_galaxy,
star_field, the web_field family (web_field + web_look_a/b via
web_field_core.gdshaderinc) and enemy_smear. The implementer confirms per shader with the ablate run which ones actually move the number. - A/B = console
set_tunables {"ColorProfileHdr.ALPHA_GAMMA": 1.0 | 2.2}; add analphapreset totools/console_mcp/hdr_ab.mjs. No new Debug Settings row (the tunable is the switch).
Out of scope (this pass)
Section titled “Out of scope (this pass)”Default-material draw_* / StyleBoxFlat sites (the HUD bars at 14x included), HeroLights (PointLight2D),
GPUParticles. Re-measure after this pass; a follow-up decides whether any still needs it.
Acceptance
Section titled “Acceptance”- GUT: SDR profile pushes 1.0 and HDR pushes the HDR value (spy setter); the global is declared in both
project files (existing
test_color_profiles.gdpin); every target shader/material includeshdr_alpha;additive_mat.tresis a saved ShaderMaterial withblend_add; baker-materials test still green; gate green. - Shader compile: windowed run under BOTH
--rendering-method mobileandgl_compatibility. cs_capture ablate: each of arena_background, zone_renderer, archetype_renderer, enemy_aura_look has an HDR p75 contribution ≤ 1.5x its SDR one; report the whole-frame p75 before/after.- Device: ATV
hdr_ab.mjsA/B (1.0 vs 2.2, 15-20 s per state, in a run, one variable), Chris’s verdict and γ pick; ABBA perf on ATV (every additive draw now runs a fragment shader: flag > 0.5 ms or ≥ 2 fps); fresh-install stall check (bh-fresh-install-stall-check), since a shared material changed. - The chosen γ is persisted as the code seed (plus a remote-config patch only with Chris’s OK).
- AA edges and soft falloffs look thinner on HDR. That matches what SDR shows, but it changes the look of glows authored on HDR since step 3.
- Additive over bright backgrounds deviates from SDR; γ tunable covers it.
- Changing the type of a 50-user shared material risks a missed
CanvasItemMaterial-typed site: parse errors surface in the gate (warnings-as-errors), and the class-cache import must run first. - Shader baker: a saved ShaderMaterial is baked; never
ShaderMaterial.new()at runtime (godot#124088).