Unremapped alpha audit (linear canvas, 2026-10-12)
Unremapped alpha on the linear canvas: audit + measurements (2026-10-12)
Section titled “Unremapped alpha on the linear canvas: audit + measurements (2026-10-12)”Question (Chris, after the player hull glow fix): what else draws partial alpha onto a linear root
canvas (use_hdr_2d: Metal at L0 with SDR output, any tier with HDR output, the tvOS boot/menu) without
the hdr_alpha remap, and how much does each one actually lift? Background and the remap itself:
docs/architecture/rendering-and-performance.md § “A linear canvas blends alpha in LINEAR light”.
Short answer. The in-run UI glass is by far the biggest unremapped lift (level-up cards and the pause
menu draw ~6x the 8-bit light, their glow fringes 7-9x). The enemies’ Nova bucket glow (every
enemy in store play) is the biggest world-space one (2.6-4x, fringe 3-7x). A material swap fixes
its falloff shape. A second path, falloffs multiplied into RGB with alpha 1.0 (every weapon FX v2 and
Warden shader), lifts 1.5-2.4x, and the alpha remap cannot reach it. A third class (dark scrims, veils
and the glass’s dark tint) goes the OTHER way: the remap would make it worse. Also worth knowing: two
COVERED additive layers now draw well below 8-bit on the linear canvas (arena backdrop 0.37x, zones
0.47x), because a^γ is only exact over black.
Method
Section titled “Method”New dev harness tools/alpha_audit_capture/ (in the hdr-alpha-audit worktree, NOT committed by this
audit; see “Harness” at the end). It boots the real main.tscn into a 1920x1080 SubViewport (real
WorldEnvironment, colour profiles, L0, colour_space_fix OFF = store default) and builds one of four
scenes:
- freeze: the console freeze scene (batch1_evolved weapons, Warden, webs, zones, a held enemy ring).
- live: a real SkilledAutoPlayer survival run, paused at 40 s (starter Rift Bolt, 64 enemies, HUD).
- levelup: the same run left to reach its first level-up panel (18 s).
- pause: the same run with the pause menu open at 30 s.
It then sets Engine.time_scale = 0, which stops shader TIME, particles and every render clock. The
measured noise floor (two grabs of the same frame) is then exactly 0. Before that change the freeze
scene alone had a floor of ~900 light units, the size of the smaller items. For each item it grabs the
frame with the item shown and hidden (RenderingServer visibility), and the item’s contribution =
shown − hidden, in linear luminance (8-bit frames sRGB-decoded first). Conditions:
| tag | canvas | profile | what it is |
|---|---|---|---|
| S8 | 8-bit | SDR | the reference: L1+ tiers, web |
| SL | linear | SDR | Metal L0 with SDR output (ATV/iPhone/iPad/Mac in a run at full quality, the ATV menu) |
| SLc | linear | SDR | SL with colour_space_fix ON: what is left once raw MultiMesh/uniform colours are converted |
| H | linear | HDR, headroom 4.93 | the ATV with HDR output |
Metrics are from analyze.py: x8 = the item’s total light / the 8-bit total. core and fringe
= the same ratio, restricted to pixels where the 8-bit contribution is ≥30% of its peak (core) or 1-10%
of its peak (fringe: the soft falloff). The pure alpha-blend signature is a fringe lift much larger than
the core lift: the falloff spreads into haze. A uniform core+fringe lift is colour encoding (raw sRGB
values in a linear buffer, the colour_space_fix domain), not alpha. excess = light added over the
8-bit look (SL total − S8 total), in the same units, to rank by visible impact. H applies the HDR
profile’s own gains, so SL is the clean read of the linear-blend effect, and H adds the gain change.
Validation: the player hull glow measures SL 1.96x / fringe 4.0x here, against
tools/player_glow_capture’s 76 vs 35 (2.2x) from today’s fix. The swap to hero_glow_mix_mat brings
it to 0.86x / fringe 0.48 here.
Ranked findings (measured)
Section titled “Ranked findings (measured)”| # | item | file:line | path type | scene | x8 SL (core / fringe) | x8 H | excess SL | candidate fix measured | store-visible |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Level-up card glass (GlassPanel _bg, internal child) |
ui/theme/glass_common.gdshaderinc glass_surface (glow g * 0.55, sheen constants); ui/theme/glass_panel.gd:94-97 |
ShaderMaterial glass_snapshot/glass_clear/glass_frost, no remap; constants authored as sRGB |
levelup | 5.83 (1.68 / 7.26) | 5.84 | +41,170 | none (shader edit) | yes, every level-up; menus on ATV |
| 2 | Pause menu glass (16 GlassPanels) | same | same | pause | 6.35 (2.32 / 8.80) | 6.40 | +31,120 | none | yes |
| 3 | Enemy Nova bucket glow (all 26 Nova enemy types, VisualLook.nova default) |
render/archetype_renderer.gd:440-445 (mm_glow, no material) |
MultiMesh, material null (mix) | freeze | 2.57 (1.52 / 3.18) | 1.11 (0.86 / 1.51) | +2,790 | cs_mix_mat: 1.45 (1.13 / 1.13); H 0.58 (0.69 / 0.28) |
yes, every enemy |
| 3b | (same, live run) | live | 3.99 (3.35 / 6.82) | 4.14 | +906 | cs_mix_mat: 2.70 (2.93 / 1.17); SLc (fix ON swaps it to cs_mix too): 0.94 (1.02 / 0.40) |
|||
| 4 | Orbit shards v2 (Orbit weapon look) | fx/weapons/orbit_shards.gdshader:262 (COLOR = vec4(col * dim, 1.0)) |
ShaderMaterial blend_add, falloff in RGB, alpha 1 | freeze | 2.21 (1.79 / 2.99) | 2.37 (1.24 / 4.54) | +3,850 | none (remap can’t reach it) | yes, with Orbit |
| 5 | Pause dim scrim | ui/pause_menu.gd:94 dim ColorRect |
null (mix), dark tinted overlay | pause | 5.79 of its small +E; −E 2.0x | 6.41 | +2,300 (and −5,360 more darkening on a brighter base) | n/a (opposite direction, see § Dark overlays) | yes |
| 6 | Warden ring bodies | render/warden_body_renderer.gd:49-50 (_sprite(..., "body", null)) |
Sprite2D, null (mix) | freeze | 1.69 (0.67 / 8.75) | 0.45 | +1,230 | hero_glow_mix_mat: 1.63 (0.66 / 8.40), i.e. the remap barely moves it |
yes, Warden boss |
| 7 | Level-up backdrop veil | ui/level_up_panel.gd:140-152 (GradientTexture TextureRect) |
null (mix), dark tinted gradient | levelup | 5.34 (0.91 / 5.43) | 5.82 | +1,150 | n/a (dark overlay) | yes |
| 8 | HUD layer (bars, StyleBoxFlat shadows, text AA) | ui/hud.gd:385-414 (shadow_color 0.20-0.5) and others |
null (mix), StyleBoxFlat / Label | freeze | 1.13 (1.05 / 2.42) | 1.16 | +570 | none | yes, always |
| 9 | Player hull glow (already fixed in HDR today) | render/player_renderer.gd:305-307 |
Sprite2D, null in SDR | freeze | 1.96 (1.29 / 4.01) | fixed | +52 | hero_glow_mix_mat: 0.86 (0.98 / 0.48) |
yes; SDR at L0 still unremapped by choice |
| 10 | Drone panel empty hex slots | ui/theme/neon_theme.gd:64 (draw_colored_polygon alpha 0.06) via ui/drone_panel.gd |
default-material draw | live | S8 draws 0, SL +191 | +191 | +191 | none | yes, HUD (a slot invisible on 8-bit shows on linear) |
| 11 | Far star layer | render/shaders/far_stars.gdshader:53 |
saved ShaderMaterial blend_add, alpha falloff, no remap | live | 4.95 (3.72 / 12.4) | 8.70 (6.19 / 28.4) | +88 | none (one-line include) | yes, always (small dots, so low total) |
| 12 | Elite HP bars | render/elite_bar_renderer.gd:31-37 (track alpha 0.85, draw_string) |
default-material draw_* |
live | 1.55 (1.22 / 5.82) | 1.28 | +115 | none | yes, elites |
| 13 | Player bolts v2 (Rift Bolt look) | fx/weapons/player_bolt.gdshader:196 (vec4(col * win * fade * dim, 1.0)) |
ShaderMaterial blend_add, falloff in RGB | live | 1.55 (1.29 / 2.75) | 1.24 (0.92 / 3.05) | +40 | none (remap can’t reach it) | yes, starter weapon |
| 14 | Swarm cores (null-material MultiMesh) | render/swarm_renderer.gd (core = the node itself) |
MultiMesh, null (mix) | freeze | 1.27 (1.12 / 1.02) | 0.49 | +64 | cs_mix_mat did not stick (the owner’s ColorSpace sync swaps it back to null with the fix off) |
yes; low impact (flat cores) |
| 15 | Threat radar | ui/threat_radar.gd:161-178 |
aggregate SubViewport + additive display | live | 14.8 of a tiny total (+0.4 → +6.5) | 14.9 | +6 | none | negligible in this frame |
The ATV with HDR output (H) shows the same picture as SL for the UI, which runs no HDR gains. World
layers in H also carry the profile’s gains (e.g. the Nova glow’s ENEMY_NOVA_GLOW gain), so read
fringe-vs-core there, not the total.
What the numbers say per class
Section titled “What the numbers say per class”- UI glass is the headline.
glass_surfacemixes a dark base with authored constants (mix(base, vec3(1.0), 0.13..0.035), the rim and top-edge adds) and writesbody_a0.72 / the glow alphaexp(-d / glow_len) * 0.55. Two things lift on a linear canvas: the constants are sRGB-authored but land in a linear buffer (core 1.7-2.3x: the cards turn milky grey, see the S8/SL frame pair in the harness output), and the glow’s soft alpha spreads (fringe 7-9x). Turningcolour_space_fixon changes nothing here (SLc = SL), because the glass shaders never callcs_inon their constants. So the remap alone would fix the halo but not the grey body. - The Nova bucket glow is a clean alpha case with a colour rider. With the store default (fix OFF),
swapping in
cs_mix_mat(mix blend +hdr_alpha,cs_inan identity while the global is off) restores the 8-bit falloff shape exactly (fringe/core 1.0 in both scenes). Raw instance colours keep the core 1.1-2.9x bright, which is the colour-space domain, andcolour_space_fixON fixes both (live SLc 0.94x). In H the swap over-tightens (fringe 0.28-0.71 of the 8-bit shape) because the HDR gains are already tuned against the washed glow. Re-tuneENEMY_NOVA_GLOWafter the swap. - RGB-falloff shaders are a second, unremappable path. Every
fx/weapons/*.gdshaderandfx/boss/warden_*.gdshaderwritesCOLOR = vec4(col * falloff, 1.0)withblend_add. On an 8-bit canvas the falloff scalar is applied to sRGB-encoded values, so it also needspow(w, γ)to match, but alpha is 1.0, sohdr_alpha(an alpha remap) does nothing. Measured: orbit shards fringe 3-4.5x, player bolts 2.75-3x. These looks were tuned by eye in the vfx_library on the Mac’s mobile renderer, which at L0 IS a linear canvas, so the linear look may be the intended one. Chris’s eye decides, not this table. - Null-material Sprite2D bodies (Warden rings, the hull itself) are not an alpha problem. The remap moved the Warden ring fringe from 8.75 to 8.40. Their soft edge is colour-encoded texture content, so a remap material is the wrong tool.
Dark overlays go the other way (do NOT give them hdr_alpha)
Section titled “Dark overlays go the other way (do NOT give them hdr_alpha)”A dark layer at alpha a over a lit scene keeps (1-a)^2.2 of it on 8-bit but (1-a) on a linear
canvas. Over a lit ground the linear canvas therefore darkens LESS (e.g. a=0.5: 8-bit keeps 22%, linear
50%), and pow(a, 2.2) would thin the dark layer further, the wrong direction. The exact match is
1 - pow(1 - a, γ). In this class: the pause dim (#5), the level-up veil (#7), the glass’s dark body
tint (body_a 0.72 over the snapshot), ScreenFeedback’s VignetteFrame (fx/screen_feedback.gd:133),
the damage-number / label outlines (fx/damage_numbers.gd:42 black 0.75, fx/fx_manager.gd:361), and
surface.scrim StyleBoxes (ui/theme/neon_theme.gd:73). A fix for these needs a second helper, not the
existing include.
Covered, but now BELOW the 8-bit look
Section titled “Covered, but now BELOW the 8-bit look”| item | path | x8 SL (core / fringe) | x8 H |
|---|---|---|---|
arena_background (backdrop lobes / aurora / grid, fx/additive_mat.tres + covered shaders) |
covered | 0.37 (0.06 / 0.65) | 0.12 |
| zone_renderer (zone_field / web_field, covered) | covered | 0.47 (0.56 / 0.47) | 0.31 |
pow(a, γ) reproduces the 8-bit result exactly only for ONE additive layer over black. On 8-bit, an
additive layer over a lit base adds in gamma space, so (b + x)^2.2 - b^2.2 is far more light than x
alone (b = 0.3, x = 0.05 sRGB: 0.028 linear against 0.0013). Overlapping backdrop lobes and zones over the
nebula therefore lose most of their 8-bit light once remapped: the bright backdrop cores keep only
6%. This is the “SDR at Metal L0 draws thinner” note in the architecture doc, now measured. It’s a
candidate reason for a per-layer gamma below 2.2 on the backdrop. Chris’s eye on L0 SDR decides.
Static inventory: unremapped and NOT measured here
Section titled “Static inventory: unremapped and NOT measured here”These were not on screen in the four captured frames, which were transients or need a specific weapon/area. Each is classified by reading the code.
| item | file:line | path type | expected behaviour | store-visible |
|---|---|---|---|---|
| Low-HP border | render/shaders/low_hp_border.gdshader:24 (vec4(glow * a, a), blend_add) |
saved ShaderMaterial, alpha falloff | same class as far_stars (alpha squared into RGB: lifts more) | yes, low HP |
| Death dissolve | render/shaders/death_dissolve.gdshader:19 (blend_add, alpha a * tint.a) |
saved ShaderMaterial, alpha | alpha class | yes, enemy deaths |
| Weapon zones (Firestorm / Thunder Wake) | render/weapon_zone_field.gdshader:211 (vec4(edge + body, fade), blend_add) |
saved ShaderMaterial, alpha fade + RGB falloff |
both classes (already listed as a known gap) | yes, those evolutions |
| Every other weapon FX v2 look (nova_burst, blaster_flash, blade_slash, scythe_reap, scatter_burst, railgun_lance / mark, momentum_*, chalice_pool, plasma_nova) | fx/weapons/*.gdshader final COLOR = vec4(..., 1.0) |
ShaderMaterial blend_add, RGB falloff | RGB-falloff class (like #4 / #13) | yes |
| Warden attack FX (bolt, burst, charge, drone, lane, missile, swing) | fx/boss/warden_*.gdshader |
same | RGB-falloff class | yes, Warden |
| Arena border / story walls | render/shaders/arena_border.gdshader:26, story_wall.gdshader (blend_premul_alpha) |
saved ShaderMaterial | fog alpha class (off-screen in the freeze frame: measured 0) | yes, at the arena edge |
Menu buttons / plates (nova_box accent shadow 0.22-0.55, glow.size 7) |
ui/theme/neon_theme.gd:49-50; gold-pick shadow ui/level_up_panel.gd:774-775 |
StyleBoxFlat shadow (soft alpha ramp) | alpha class, part of #1/#2’s non-glass remainder | yes, all menus |
| HUD hex slots, touch controls, weapon/shop icons | ui/theme/neon_theme.gd:64-67, ui/touch_controls.gd, ui/weapon_icon.gd, ui/shop_icons.gd (default-material draw_* with alpha 0.06-0.6) |
default material | alpha class; faint ones appear from nothing (#10) | yes |
| Drones on the field (Decoy etc.) | render/drone_sprites.gd:42 via render/decoy_renderer.gd |
fx/additive_mat.tres |
covered | n/a |
| Thruster, heal glow/ring, ascension flash, cockpit | render/player_renderer.gd:499-505 |
fx/additive_mat.tres |
covered | n/a |
| GPU bursts, thruster trail, Funzo confetti | fx/gpu_burst.gd:40, fx/thruster_trail.gd:44 |
additive_mat on the particles node (colour-ramp alpha goes through it) |
covered | n/a |
| HeroLights | render/hero_lights.gd:56 |
PointLight2D add (energy, not alpha blend) | out of scope | n/a |
| Nebula / galaxy composites | render/nebula_bg.gd, arena_galaxy.gdshader |
LDR SubViewport composite, alpha 1 | colour-space domain (the nebula -E swings are its composite, not alpha) | n/a |
Funzo shaders (funzo_zone_field, funzo_lit_shell, funzo_void_skin, funzo_carnival_warp) |
render/funzo_*.gdshader |
ShaderMaterial blend_add, alpha | alpha class; FunZo area only | yes, FunZo |
| Candidate looks | fx/weapons/candidates/** |
dev-only | skip | no |
Coverage of the existing world renderers is good: 20 of the in-play render/*_renderer.gd draw
through fx/additive_mat.tres (dump: 102 enemy shell MultiMeshes, all the telegraph / hazard / zone /
pickup renderers). The gaps are the null-material Nova glow, the shader families above, and the UI.
Recommended fix batch (proposal, nothing fixed here)
Section titled “Recommended fix batch (proposal, nothing fixed here)”- Enemy Nova glow → a dedicated saved material on
cs_mix.gdshader(e.g.render/shaders/nova_glow_mix_mat.tres, a copy soColorSpace.stock_layers()does not swap it back to null while the fix is off;cs_instill followscs_linearize). Measured: fringe 3.2-6.8x → 1.1x. Re-tuneENEMY_NOVA_GLOW(HDR) afterwards, since H over-tightens. Make it a paired SDR/HDR tunable likeHERO_HULL_GLOW_REMAP. Same treatment forDroneSpritesinui/drone_panel.gd(UI, default material). Risk: low. New saved .tres + existing shader, so the baker sees it; add it to the prewarm pass. Baked-iPhone check anyway (godot#124088). - Glass:
hdr_alphaon the glow/body alpha AND linearise the authored constants when the root is linear (glass_common.gdshaderinc: includehdr_alpha.gdshaderinc, remap last; the sheen/rim constants need a linear-canvas conversion keyed on the canvas, not oncs_linearize, which is off in store). The remap alone fixes only the halo (fringe 7-9x). The grey body (core 1.7-2.3x) needs the constant conversion. Saved materials exist (glass_*_mat.tres), so no new materials, but this is a shader edit on every menu: A17 shader-baker risk (godot#124088). Check a baked iPhone Dev install before merge. Biggest visible win. Chris eyeballs menus on ATV (linear at boot). - One-line
hdr_alphaadds on the alpha-class saved shaders:far_stars(measured 5-9x, fringe 12-28x),low_hp_border,death_dissolve,weapon_zone_field(alpha part), and the Funzo shaders. Add each totests/test_hdr_alpha.gd. Risk: shader edits (baker check), no new materials. - Defer, needs design + Chris’s eye: the RGB-falloff family (weapon FX v2 + Warden FX: a
hdr_falloff(w)on the intensity scalar, per shader, and these looks may have been tuned on a linear canvas). The dark-overlay family (needs1 - pow(1 - a, γ)). Default-material UIdraw_*/ StyleBoxFlat shadows (no shader to hook; would need a theme-level material). The covered-backdrop under-shoot (consider a lower per-layer gamma).
Leave as is: the Warden ring bodies and the hull body (not alpha), the swarm cores (flat, +64), and the threat radar (negligible).
Harness
Section titled “Harness”tools/alpha_audit_capture/alpha_audit_capture.{gd,tscn} + analyze.py, in the hdr-alpha-audit
worktree and uncommitted (this audit commits the doc only). Commit it with the fix batch to re-measure:
AA_OUT=/abs/dir AA_MODE=measure [AA_SCENE=live|levelup|pause] [AA_ONLY=item,...] \ perl -e 'alarm 2400; exec @ARGV' godot --path . --rendering-method mobile --always-on-top \ --resolution 320x180 res://tools/alpha_audit_capture/alpha_audit_capture.tscnpython3 -I tools/alpha_audit_capture/analyze.py /abs/dirgit checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godotAA_MODE=dump lists every visible CanvasItem by owner script and material kind. Run it with
--always-on-top: the first attempt stalled for 10 minutes when another window covered the Godot
window (macOS stopped presenting frames, and frame_post_draw never fired). The frames are raw RGBA
half-float (~16 MB each, about 2 GB per four-condition run). Delete them afterwards.