Skip to content

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.

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.

# 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.

  • UI glass is the headline. glass_surface mixes a dark base with authored constants (mix(base, vec3(1.0), 0.13..0.035), the rim and top-edge adds) and writes body_a 0.72 / the glow alpha exp(-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). Turning colour_space_fix on changes nothing here (SLc = SL), because the glass shaders never call cs_in on 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_in an 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, and colour_space_fix ON 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-tune ENEMY_NOVA_GLOW after the swap.
  • RGB-falloff shaders are a second, unremappable path. Every fx/weapons/*.gdshader and fx/boss/warden_*.gdshader writes COLOR = vec4(col * falloff, 1.0) with blend_add. On an 8-bit canvas the falloff scalar is applied to sRGB-encoded values, so it also needs pow(w, γ) to match, but alpha is 1.0, so hdr_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.

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.

Section titled “Recommended fix batch (proposal, nothing fixed here)”
  1. Enemy Nova glow → a dedicated saved material on cs_mix.gdshader (e.g. render/shaders/nova_glow_mix_mat.tres, a copy so ColorSpace.stock_layers() does not swap it back to null while the fix is off; cs_in still follows cs_linearize). Measured: fringe 3.2-6.8x → 1.1x. Re-tune ENEMY_NOVA_GLOW (HDR) afterwards, since H over-tightens. Make it a paired SDR/HDR tunable like HERO_HULL_GLOW_REMAP. Same treatment for DroneSprites in ui/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).
  2. Glass: hdr_alpha on the glow/body alpha AND linearise the authored constants when the root is linear (glass_common.gdshaderinc: include hdr_alpha.gdshaderinc, remap last; the sheen/rim constants need a linear-canvas conversion keyed on the canvas, not on cs_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).
  3. One-line hdr_alpha adds 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 to tests/test_hdr_alpha.gd. Risk: shader edits (baker check), no new materials.
  4. 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 (needs 1 - pow(1 - a, γ)). Default-material UI draw_* / 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).

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.tscn
python3 -I tools/alpha_audit_capture/analyze.py /abs/dir
git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot

AA_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.