Skip to content

HDR alpha remap (linear-canvas wash fix) — design

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’s ALPHA_GAMMA while the root is linear, else 1.0; ColorProfileSdr.ALPHA_GAMMA seed is now 2.2 (both independent). Current architecture: docs/architecture/rendering-and-performance.md § “A linear canvas blends alpha”.

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.

  1. 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 same want as the root use_hdr_2d hold (main._sync_hdr_output → HdrLook.update), so the HDR value is live exactly when the canvas blends linearly, independent of colour_space_fix.
  2. New global hdr_alpha_gamma in ShaderGlobals.GLOBALS (→ ALPHA_GAMMA), declared in [shader_globals] of BOTH project.godot and platform/tvos/project.godot at the SDR value 1.0 (; comments only). A live set_tunables change re-pushes (verify the existing LiveScope path does this for profile statics; add the hook if not).
  3. New include render/shaders/hdr_alpha.gdshaderinc: global uniform float hdr_alpha_gamma; and float hdr_alpha(float a) { return pow(a, hdr_alpha_gamma); } (guard a <= 0.0). Callers apply it as the LAST write of COLOR.a in fragment() (a shader with no fragment() gets one that only does that).
  4. fx/additive_mat.tres becomes a saved ShaderMaterial (render_mode blend_add + the include), same path, so the ~50 preload sites keep pointing at it. Their typed var x: CanvasItemMaterial = preload(...) declarations become Material (mechanical). ColorSpace identity checks (== ADDITIVE) still work. CS_ADD and CS_MIX get the include too, so the MultiMesh swap under colour_space_fix keeps the remap.
  5. 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.
  6. A/B = console set_tunables {"ColorProfileHdr.ALPHA_GAMMA": 1.0 | 2.2}; add an alpha preset to tools/console_mcp/hdr_ab.mjs. No new Debug Settings row (the tunable is the switch).

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.

  • 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.gd pin); every target shader/material includes hdr_alpha; additive_mat.tres is a saved ShaderMaterial with blend_add; baker-materials test still green; gate green.
  • Shader compile: windowed run under BOTH --rendering-method mobile and gl_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.mjs A/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).