Bullet Heaven — rendering, visuals, telemetry & adaptive quality
Bullet Heaven — rendering, visuals, telemetry & adaptive quality
Section titled “Bullet Heaven — rendering, visuals, telemetry & adaptive quality”Extracted from
CLAUDE.mdon 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.
UI styling (colours, font sizes, spacing, theme constants) is live tokens, not literals: see
docs/architecture/ui-tokens.md.
Neon visual overhaul (M2 cycle 6, DONE)
Section titled “Neon visual overhaul (M2 cycle 6, DONE)”Render/UI-only pass giving the game a chess-defense-td neon aesthetic. All changes are determinism-neutral (no sim logic modified except extending the per-tick FX-event list).
render/element_palette.gd(ElementPalette): render-side static helper,color_for(content, element_idx)reads hex frombible.jsonelements; falls back toNEUTRAL(magenta) for -1 or missing. Source of truth for all entity tinting.render/glow_texture.gd(GlowTexture): generates a shared 64×64 soft-radialImageTextureonce at load; used by halo layers and FxManager.render/swarm_renderer.gd— gained a halo layer: secondMultiMeshInstance2Dchild,HALO_SCALE=3.0,CanvasItemMaterialwithBLEND_MODE_ADD, syncs same transforms + element-tinted colours each frame. Core layer configure withColor.WHITE(not the entity colour); per-instance colour drives the look.sim/sim.gd—fx_bursts: Array[Vector2]replaced byfx_events: Array[Dictionary]. Event kinds:reaction(pos+element),death(pos+element),pickup(pos). Cleared at top of each tick; NOT included insnapshot_string()/state_checksum()(determinism-neutral).fx/fx_manager.gd(FxManager): pooledPOOL_SIZE=256additiveSprite2Deffects consumingsim.fx_eventseach frame.DEATH_CAP=8per frame (surplus deaths skip the pop visually but still count in sim).fx/screen_feedback.gd(ScreenFeedback): damage vignette shader + low-HP border + decaying camera shake. Driven by player HP changes inmain.gd.ui/theme/neon_theme.gd(NeonTheme): code-built Theme (not a .tres). Orbitron as default font, JetBrains Mono for HUD numbers, dark translucent fills, cyan border, corner_radius=12. Apply viaNeonTheme.get_theme().fonts/: Orbitron + JetBrains Mono variable.ttffiles (OFL-licensed), committed with.importsidecars andOFL-*.txtlicence files.- HUD/LevelUpPanel/ResultsPanel — all restyled with NeonTheme; “LEVEL UP” / “RUN OVER” titles in Orbitron.
render/arena_background.gd— now usesBLEND_MODE_ADDfor additive grid glow.
Visual distinction pass (M2 cycle 10, DONE)
Section titled “Visual distinction pass (M2 cycle 10, DONE)”Render/UI-only pass so every enemy type and weapon is identifiable on the field. Determinism-neutral (baseline checksums unchanged at the time: 1240757503/1997788103; superseded by cycle 11).
- The “invisible entity” trap (the headline lesson): a sim entity pool with no renderer, OR an
fx_eventkind with noFxManagercase, is silently invisible — no error, the sim still ticks and deals damage. Before this cycle, orbit shards (no projectile + no fx), the beam (emitted abeamfx_event thatFxManagerhad no case for → dropped), and shooterenemy_proj(pool existed, no renderer) all rendered nothing while affecting gameplay. Rule: every entity pool needs a renderer inmain.gd; everyfx_eventskind needs a matchingmatcharm inFxManager.consume. Audit both when adding a pool or an fx kind. - Enemy per-type colour + size, both data-driven from
bible.json: each enemy has acolorhex inseed.js(re-export after editing).main.gd._build_enemy_type_colors()builds a render-side type→Color LUT keyed byEnemyPool.TYPE_*._enemy_colors()shows the type colour when an enemy has no aura, and the element aura colour overrides it when hit (so reactions still read). Colours: swarmer#ff5a8c, tank#5b8cff, shooter#ff4d4d, splitter#1fe0b0(teal — deliberately NOT green, which collides with the green XP gems), elite#c06aff. - Per-instance size:
SwarmRenderer.sync(pool, colors, scales=null)gained an optionalscales: PackedFloat32Array; when present it buildsTransform2D(Vector2(s,0),Vector2(0,s),pos)so each instance draws at its true radius. (Originallymain.gd._enemy_scales()=radius/ENEMY_MESH_RADIUS; that helper is gone — enemies now draw throughArchetypeRenderer, which scales each instance byenemies.radius[i]/BASE_RADIUS, base mesh radius 14.) Tanks (r22) and elites (r26) are visibly bigger. Gems/projectiles pass no scales (uniform). The oldsyncignored per-enemy radius entirely — all enemies drew at 14px regardless of collision size; the comment claiming “per-enemy sizing handled in sync()” was aspirational, now real. - Per-element projectile colour:
_proj_colors()tints each projectile byprojectiles.element_idx(pulse=lightning-yellow); elementless shots (turret, el −1) usePROJ_NEUTRALnear-white. Shooter bolts render via the newenemy_proj_rendererin threat-red (ENEMY_PROJ_COLOR). - Orbit shard render:
WeaponOrbit.positions: Array[Vector2]is filled eachupdate()— render-read only, excluded from snapshot/checksum (recomputed every tick, so determinism-neutral).main.gddraws 3 additive cold glowSprite2Dat those positions. - Beam render:
FxManagergained a_BeamNodepool (BEAM_POOL_SIZE=8) + abeamcase drawing a fading additive line (soft outer + white core).weapon_beam.gd’s fx event now carrieselementfor colour.beam_active_count()accessor added for tests. - WeaponPanel shows weapon glyphs, not cooldown arcs:
_CooldownArcreplaced by_WeaponIcon(procedural_drawperkind: bolt/rings/ring+shards/line/turret), drawn in the weapon’s element colour. Names + damage stat kept; the cooldown/frequency arc is gone (per design call). Kinds/names are staticconstarrays indexed by slot. - The public site is a matching legend.
~/Claude/bullet-heaven-site/index.htmlArsenal+Enemies sections show the SAME glyphs (inline SVG) and glowing colour-squares (real colours + relative sizes). If you change an enemy colour, a weapon glyph, or an enemy size, update the site legend to match — it advertises itself as “exactly how it reads in-game.”
Performance telemetry (M2 cycle 15, DONE; tvOS-first expansion + adaptive-quality reporting)
Section titled “Performance telemetry (M2 cycle 15, DONE; tvOS-first expansion + adaptive-quality reporting)”Auto-records perf from real devices (web + tvOS) so we tune from real sessions, no manual reports. Full ops in telemetry/README.md.
- Game sender:
net/telemetry.gd(Telemetry, render-side, NOT /sim) — now active on tvOS AND web (gate =not OS.has_feature("editor") and DisplayServer.get_name() != "headless"; was web-only — the ATV is the primary playtest platform, so perf MUST be captured there). Every 5s POSTs an anonymous sample (fps, frame/sim/render ms, draws, prims, enemy/zone/web counts,run_time,low_fx,quality= adaptive tier,dev= coarse device id,Sim_Const.BUILD, a session) as text/plain (CORS-simple, no preflight); errors ignored.net/telemetry_device.gd(TelemetryDevice.id()) gives the device id (“web” or e.g. “tvOS/AppleTV11,1”).maingenerates ONE per-run session id (_run_session) shared by BOTH senders so a slow run’s perf samples correlate with its gameplay summary. - Backend: CF Worker
bullet-heaven-telemetry(telemetry/, account 06f…) + D1bullet-heaven-telemetry(id6e33b252-…).POST /sampleingests perf;GET /dashboard +/stats.jsonaggregate by build (avg/min fps, % <40fps, slowest sessions by device/UA). Deploy with$CF_LUMARA_DEPLOY_TOKEN+$CF_ACCOUNT_ID(wrangler fromtelemetry/; schema intelemetry/schema.sql,CREATE … IF NOT EXISTSso re-applying is idempotent). URL:bullet-heaven-telemetry.chris-allen-06f.workers.dev. - GAMEPLAY telemetry (build 46) — the balance data, from tvOS too.
POST /gameplayingests a per-run summary into the D1runstable; the dashboard gained sections for runs by build/mode (avg run_time, level, kills, DPS, reactions, gold, weapons), deepest story region reached, and decoy type usage. Sender =net/gameplay_telemetry.gd(active on tvOS + web, persistent across runs). This is what lets us tune story balance (Hush/Warden HP, reaction damage) from real data instead of guessing — USE IT before blind-tuning. - DPS is EFFECTIVE damage, not overkill (fixed).
Sim._damage_enemycaps thedmg_dealt_totalcredit at the enemy’s remaining HP (clampf(dealt,0,max(before,0))) — raw HP subtraction unchanged, so the overkill mechanic + determinism baseline are intact, but the dashboard DPS is now meaningful (was reading in the billions because every AoE/burst counted full magnitude on low/dead enemies). The old DPS numbers pre-fix are garbage. - Richer run summary + new dashboard sections (tvOS-first expansion): the gameplay summary now also sends
death_cause(dominant contact enemy type / “ranged” / “boss” / hazard — latched at the fatal blow in_hurt_player(amount, source)),peak_enemies(per-run high-water load — a perf signal on EVERY run without the 5s sampler),damage_taken,weapons_list,build_picks(every upgrade taken),dev. The render-excluded sim countersdeath_cause/peak_enemies/damage_takenare NOT in the checksum;EnemyPool.type_name(id)maps a type id → short name for attribution. Dashboard added Perf by device, Deaths — what killed the player (by mode/cause), quality avg/max columns, peak/taken on the runs table. D1 columns added via one-timeALTER(Compatibility:CREATE … IF NOT EXISTSonly covers fresh DBs;ALTER TABLE … ADD COLUMNis NOT idempotent — re-running errors, expected). - Security (caught by the commit security review, fixed): the dashboard renders the attacker-controlled user-agent → must HTML-
esc()every interpolated value (stored-XSS). Viewer auth is fail-closed (401 ifSTATS_KEYunset/mismatch) with a constant-time compare + strict CSP. Key in~/.secretsBULLET_HEAVEN_TELEMETRY_KEY. - Per-build tracking: each
deploy-demo.shbumpsSim_Const.BUILDso the dashboard compares perf across builds — this is WHY the build-number convention matters.
Adaptive graphics — QualityManager (DONE, builds 49-50)
Section titled “Adaptive graphics — QualityManager (DONE, builds 49-50)”render/quality_manager.gd (render-side, NOT /sim) auto-scales render fidelity to hold a frame-rate floor so the Apple TV stays playable with no settings to touch. All render-side → determinism baseline untouched by construction (the determinism test builds Sim.new and never binds it).
- 6 cumulative tiers (L0 full neon → L5 stripped). Cheapest-looking losses first: FX caps → additive halo layer (
SwarmRenderer.set_halo_visible) → bloom (world_env.environment.glow_enabled) + arena grid (ArenaBackground.set_low) → damage numbers (DamageNumbers.enabled) + vignette/shake (ScreenFeedback.enabled) + outline-only zones (ZoneRenderer.low_detail) → all juice fx (FxManager.enabled/death_cap/reaction_cap). Each render system got a null-safe knob; the tier→knob policy lives only inQualityManager._apply(). - Driven by
mainduring active play (quality_manager.tick(1.0/dt, dt)after the game_over guard) — NOT a self-run_process(so the cheap menu/results screens don’t disturb the tier). Persists across runs (created in_ready, excluded from_new_runqueue_free alongsidegameplay_telemetry;bind(...)re-points it at each run’s fresh nodes + re-asserts the tier). F4 cycles a manual override (auto → fixed L0..L5 → auto); tvOS needs no input. - ⚠️ The Living Room Apple TV runs at 60 Hz since 2026-10-01 (Chris changed the TV’s output setting). The “50Hz panel” notes below describe everything before that: the cap is now 60 fps / a 16.7 ms frame budget (was 20 ms), and telemetry before ~11:30 UTC 2026-10-01 is 50-capped (see bh-telemetry § “Reading ATV fps”).
QualityManager.DOWN_FPS = 49assumed the 50 Hz cap; re-tune it before ADAPTIVE_QUALITY is turned back on. - Upgrades are PROBE-based, not headroom-based — because Chris’s Living Room Apple TV (
AppleTV11,1, A12) is a 50Hz panel. vsync pins fps at 50, so you can NEVER observe “fps > 57” to know there’s GPU room — the first attempt (build 49) got permanently stuck at the ugliest tier. The fix (build 50): while holding the floor (fps ≥ 49) it optimistically steps a tier UP every 6s; if that overshoots and frames drop, the downgrade path walks it back and arms a 20s backoff. Downgrade is fast (0.4s; double-step below 40fps). Refresh-agnostic. Decision coretick(fps,dt)is pure + unit-tested. - ⚠️ Renderer split (CORRECTED 2026-06-30 — the old “gl_compatibility on BOTH” note was WRONG): the web demo runs
gl_compatibility(WebGL — no Vulkan/Metal in browsers), but the tvOS build deliberately runs the Mobile renderer on METAL. The MAIN repo’sproject.godotisrendering_method.mobile="gl_compatibility"; the tvOS repo’sproject.godotoverrides torendering_method.mobile="mobile"(a documented tvOS-only platform file — see the tvOS CLAUDE.md), so on the Apple TV the deployed app renders through Godot’s RenderingDevice Metal backend (the tvOS Godot is a 4.7-dev fork with the Metal driver + the unified “Apple Embedded” iOS/tvOS driver). Implications: (a) on tvOS, real HDR glow/bloom via WorldEnvironment IS available (it’s NOT gl_compat there) — more post-FX headroom than this note used to claim; (b) the game is 2D MultiMesh so it’s GPU-light — the ATV bottleneck is fill-rate/overdraw (additive halos + bloom) + the 50Hz vsync cap, NOT Metal API throughput, so “full use of Metal” isn’t the lever, overdrop reduction is; (c) any HDR pipeline still must stay faked on web (gl_compat). To confirm the live tvOS renderer string definitively, addRenderingServer.get_video_adapter_name()/driver to the telemetry payload (Godot’s boot log goes to Apple unified logging, NOT stdout, sodevicectl --consoleshows nothing). - Telemetry feedback loop: the perf sample’s
qualityfield shows what tier each device settles at; the ATV at 50Hz holds ~50fps but settles ~L1-L3 at normal load (since build 53 the ladder sheds the full-screen BLOOM first and KEEPS the cheap neon halos through L3 — see cycle 22; pre-build-53 it stripped halos at L2 and looked far flatter) and exhausts tiers only in extreme 300+ swarms (a case the cycle-21 survival rework’sSOFT_ENEMY_CAP340→140 cut largely removes). - Next graphics/perf work is PLANNED, not built:
docs/superpowers/plans/2026-06-25-graphics-perf-plan.md(viewport culling, platform-aware entity budget, player weapons-always-on — FxManager.enabled currently kills weapon bolts/beams at L5, ultra-neon palette/saturation, evolving player sprite). Gated on the other agent’s cycle-21 rework landing + a re-measure first.
Graphics & perf pass (M2 cycle 22, DONE) — build 52
Section titled “Graphics & perf pass (M2 cycle 22, DONE) — build 52”Implemented the render-only, determinism-safe items of the graphics-perf plan (docs/superpowers/plans/2026-06-25-graphics-perf-plan.md). ALL render-side → survival determinism baseline 1432233777/2300319179 byte-identical by construction (the determinism test builds Sim.new directly and binds no render nodes). 5 chunks, each TDD’d + verified + shipped to the Apple TV @ BUILD 52.
- Weapons always render (Item 1):
FxManager.consumenow SPLITS event kinds — essential weapon feedback (bolt/beam/slash/nova) ALWAYS spawns; disposable juice (death/reaction/chain/pickup/dmgnum/ghost_*/accum_warn) is gated byenabled(+ per-frame caps). The old single top-levelif not enabled: returnkilled your pulse/beam/blade/nova at L5; nowenabled=false(set by QualityManager at L5) only sheds juice. The lesson: a quality “off switch” must distinguish player-feedback fx from spectacle fx — gating them together makes the weapons disappear exactly when the player most needs to see them. - Halo overdraw + hot cores (Items 2a, 6):
HALO_SCALE3.0→2.0 inSwarmRendererANDArchetypeRenderer— the additive halo quad is 2D so fill cost scales with the SQUARE (9×→4× area, ~55% less halo overdraw) while keeping an obvious glow. Cores nowlerp(WHITE, CORE_HEAT=0.62)(was 0.5) so the centre reads white-hot and the saturated-hue additive halo reads coloured — that contrast IS the bloom look on thegl_compatibilityrenderer (no HDR buffer needed). - Hit-flash (Item 6):
FxManagerconsumesdmgnumevents (previously ignored by it — onlyDamageNumbersread them) with a brief white-hot impact spark (gold-white + larger for “big” hits),HITFLASH_CAP/frame, gated as juice. Both consumers read the samesim.fx_eventsarray independently. - Player visual evolution (Item 7): NEW
render/player_renderer.gd(PlayerRenderer, Node2D) — extracted the inline player visual frommain.gd. The craft accretes detail across 5 level-tier bands (tier_for(level) = clampi(level/5, 0, 4)): dart → finned interceptor → multi-hull → winged cruiser → ornate flagship, with growing glow + hotter core + thruster plume, both recolouring cyan→teal→azure→violet→gold per tier; crossing a tier pops an “ascension” flash. Architecture:player_nodestays the transform root (position + facing rotation + camera child — the camera’signore_rotationdefault keeps the view upright);PlayerRendereris a child that owns the evolving visual and rebuilds only on tier change. 2026-07-16 (Chris’s call): the orbiting accent-orb ornamentation this section used to describe is REMOVED (was the only tier-visual on top of the halo/thruster onceUSE_BAKED_HULLbypassed the procedural per-tier silhouette) — a replacement upgrade indicator is an open follow-up, not yet designed.QualityManager.player_visual.low_detailis kept as a dormant hook (currently affects nothing on PlayerRenderer) for whatever replaces it. Keys onplayer.level(read-only) → determinism-safe. - Viewport culling (Item 3):
SwarmRenderer.syncgained an optionalcull_rect— visible entities compact into slots0..j-1andvisible_instance_count = jlimits the draw (Godot built-in, no buffer realloc; colours stay indexed by ORIGINAL pool index).main._visible_world_rect()(camera-centred, paddedMARGIN=110) is passed for gems/projectiles/enemy_proj; enemies are NOT culled (they chase the player, mostly on-screen, and use the differentArchetypeRenderer). Gems — which litter the whole ±2000 arena where enemies died — are the clear win. - Adaptive ladder re-order (build 53) — “graphics reduced more than they need to be” fix. The build-52 halo cut reduced the COST of halos but the QualityManager tier POLICY still killed them at L2 while keeping the EXPENSIVE full-screen bloom until L3. On the 50Hz/20ms ATV, bloom is the constant that doesn’t fit, so it dragged the device to L2-L3 and stripped the cheap, high-value neon halos as collateral — the “stripped” look. Re-ordered the ladder around real cost:
bloom_on=l<=0(the costly full-screen pass sheds FIRST),halos_on=l<=3(cheap per-entity neon glow survives to L3, only goes at L4), with vignette/damage-numbers/grid/accent-orbs shedding in the middle (screen_fx_on/damage_numbers_onl<=2,low_terrain/player_low_detaill>=2/l>=3). So the ATV (settles ~L1-L3) now KEEPS its halos the whole way. The ladder is extracted into pure static predicates (QualityManager.bloom_on(l)etc.) — unit-tested + a monotonicity test (a feature can’t turn back ON at a worse tier).PROBE_DWELL6→4s for faster recovery after a transient spike. Lesson: a quality ladder must be ordered by COST-PER-VISUAL-VALUE, not by a fixed “big GPU wins last” rule — when you make one effect cheaper (halos), re-check that the ladder still sheds the now-most-expensive thing (bloom) first, or the cheap effect gets stripped as collateral. Render-only; determinism unchanged. - DEFERRED: Item 4 (platform-aware entity budget) — sim-touching; gated on a fresh on-device ATV re-measure per the plan, and the cycle-21
SOFT_ENEMY_CAP140 already designs away the failing 250–340 band. Item 6 full palette overhaul (OKLCH hue re-spacing of every element/enemy colour inbible.json) — subjective, wants Chris’s eye + coordination with content edits. Web demo R2 redeploy still deferred (the start menu changed the public demo’s first screen auto-attract→menu — needs Chris’s OK).
⚠️ Adaptive quality OFF since 2026-06-28 — now remote-flippable (RC-4, 2026-10-01)
Section titled “⚠️ Adaptive quality OFF since 2026-06-28 — now remote-flippable (RC-4, 2026-10-01)”The old main.gd const ADAPTIVE_QUALITY := false became QualityManager.ENABLED (a bool remote-config flag, default false) in RC-4 — same behaviour (pin every run at L0, skip QualityManager’s per-frame tick()), but a published config patch can now switch the ladder ON without a store build. The ladder thresholds/timings (DOWN_FPS, HARD_DROP_FPS, dwell/backoff/EMA consts) are remote tunables too — see docs/architecture/remote-config.md § Render-side tunables. History: the pin was a deliberate choice (commit 16ef059, 2026-06-28) so graphical changes could be evaluated without the ladder fighting the dev mid-test. Confirmed live via telemetry (2026-07-04): the Apple TV’s quality tier has been stuck at 0 across every build since, including sessions with 88-100% of frames under 40fps. Chris’s call: leave it off until the v0.1 release build, then flip it back on. Until then, treat every new full-screen/decorative effect as running at ALWAYS-FULL cost with no ladder to catch it — see the pattern below. When re-enabling (via patch or default flip): NebulaBg (next section) still has no _on(level) predicate or _apply() case, so it needs that wiring added at the same time or it’ll keep running at full cost even at L5 — and re-tune DOWN_FPS for the now-60Hz Living Room ATV (see the QualityManager section above).
Default pattern — cheap-by-construction backgrounds/atmospheric FX (established 2026-07-04)
Section titled “Default pattern — cheap-by-construction backgrounds/atmospheric FX (established 2026-07-04)”Found while debugging a “cycling backgrounds tanks fps” report (render/nebula_bg.gd’s full-screen shader + ArenaBackground’s Nebula/Galaxy variants). Apply both of these to any NEW full-screen or large-overdraw background/atmospheric effect by default, not just when a perf complaint comes in:
-
A full-screen procedural shader is the worst case for the ATV’s fill-rate-bound A12 GPU (every pixel, every frame, regardless of scene content). Render it into an offscreen
SubViewportat a FRACTION of the real display resolution (NebulaBg.RESOLUTION_SCALE = 0.35→ ~8x fewer shaded pixels; 0.5 until 2026-09-29), then show it via a bilinearly-upscaledTextureRect. Invisible for anything already soft/blurred (clouds, glow, distant stars); would visibly soften anything with hard edges. This is a plain 2D Godot technique — NOT Metal-specific. It works identically ongl_compatibility(web) since it’s just a smaller render target + a texture sample, no renderer-specific feature involved.render/nebula_bg.gdis the reference implementation (_viewport/_display/RESOLUTION_SCALE). -
Additive-sprite overdraw cost scales with AREA (scale²), not count. When an effect needs fewer pixels shaded, shrink each sprite’s SCALE before cutting how many there are — halving scale cuts area (and cost) by ~4x while keeping the same visual density/count. Already the exact lesson behind
HALO_SCALE3.0→2.0 (cycle 22 above); reapplied 2026-07-04 toArenaBackground.GALAXY_GLOW_SCALE(20.0→12.0) and the Nebula variant’s cloud sprite scale range (14-28→8-16). -
Animate static backdrop geometry in a shader, don’t
queue_redraw()it every frame (2026-09-29, perf review #8c).ArenaBackgroundre-recorded its whole grid (~28 µs) or 21 wide aurora polylines (~165 µs) every frame just to breathe/shimmer them. Now the grid and each aurora band draw ONCE into a child_Layer, and saved shaders (render/shaders/arena_grid_breathe,arena_aurora) animate alpha / the travelling sine fromTIME; the node itself only redraws while warp streaks are up. Same pattern the star twinkle already used (render/star_field.gdshader). Also from the same batch: the static vignette (ScreenFeedback.VignetteFrame) draws only its non-transparent border band. -
Depth cues for star layers (2026-09-29, Chris: the arena starfield read “foreground”; nebula-bg big stars twinkled too much). Three levers on
ArenaBackground’s STARFIELD/GALAXY stars, all render-side: (a) tiny + dim scatter (r 0.7-1.5 px, alpha 0.14-0.5); (b) slow gentle twinkle — speeds 0.5-1.4 rad/s throughstar_field.gdshader’s ±25% curve (0.75 + 0.25*sin, was ±45% at up to 4 rad/s; the CPU warp-streak curve mirrors it); (c)STAR_PARALLAX = 0.55—mainfeedsarena_bg.set_camera(player.pos)each frame (next tonebula_bg.set_camera) and the star MultiMesh + galaxy nucleus LAG the camera at 0.55x, visibly falling behind the 1:1 gameplay plane. Current depth stack: NebulaBg 0.1 < arena stars 0.55 < gameplay 1.0.NebulaBg’s own big-star twinkle was halved to ±12% at 0.7 rad/s (nebula_bg.gdshader). Before/after capture tool:tools/bg_compare/(RUN_TAG=before|after, fixedSTAR_SEEDso star positions match). -
Background layer stack (RC-10, 2026-10-08): NebulaBg cloud (CanvasLayer -2, parallax 0.1) →
FarStars(CanvasLayer -1, parallax 0.1) →ArenaBackground(world, its starfield at 0.55 is now the mid-depth layer) → world. NebulaBg’s own shader stars are off by default (STAR_BRIGHTNESS/STAR_BIG_BRIGHTNESS0; the shader skips the star math when both are 0): at its 0.35RESOLUTION_SCALEthey upscaled into soft blobs, and 0.7 made them crisp but cost ~2 ms.render/far_stars.gddraws the far stars at full resolution as ONEMultiMeshInstance2DofCOUNT(110) stars in a screen tile (view + a margin sized for the biggest star plus its longest warp streak).render/shaders/far_stars.gdshader(skip_vertex_transform; instance origin = tile position, x scale = diameter) wraps each star withmod(base - cam, tile) - margin, so per frame the CPU sets onecamuniform. Warp streaks are the same quad stretched forward alongwarp_dirbywarp_len x INSTANCE_CUSTOM.a, the dot and a fading trail resolved in the fragment from two texture samples: no CPU loop, no redraw. A viewport resize regenerates the tile. QualityManager hides it atlow_terrain(L2+) with the arena starfield. The arena starfield’s CPU warp streaks now early-out atWARP_STREAK_ALPHA0 (no redraw) and loop flattened packed arrays built once per variant (_cache_streaks). Tunables:docs/architecture/remote-config.md§ RC-10. -
Galaxy variant looks (
VisualLook.galaxy_look, 2026-10-07; default 1 “spiral”). Chris: “make it look like it is spiraling away into the distance, not too much stars in the foreground”.ArenaBackground._build_galaxy_spiralrendersrender/shaders/arena_galaxy.gdshader(a tilted + foreshortened log-spiral disc: arm band × seamless unit-circle fbm with a radial term advancing on TIME so the knots stream INWARD, dust lanes, round star knots, nucleus > 1.0 for the bloom, far half dimmed) into aGALAXY_RES² transparent SubViewport (use_hdr_2dkept in step with the root each frame — NebulaBg’s colour-space trap) and composites it with ONE additiveSprite2D(metagalaxy_glow, so the scatter look’s parallax/low-quality/clear paths apply) atGALAXY_PARALLAX0.3, deeper than the 0.55 arena stars. Fps lever:GALAXY_UPDATE_HZ— the pass isUPDATE_ONCE, re-armed from_processat that rate (15 default; 0 = render once, static; 60 =UPDATE_ALWAYS), so the per-frame cost is one textured quad;set_low/set_frozen(main calls it withnebula_bg.set_frozen) put the target onUPDATE_DISABLED, since a hidden sprite never stops its viewport. OnlyGALAXY_SPIRAL_SCATTER_STARS(60, the Starfield’s tiny/dim treatment) stay on the arena plane. Look 0 “scatter” is the original dense MultiMesh star spiral + gaussian nucleus, untouched. Shader gotcha paid here:pow(0.5 + 0.5 * cos(x), k)NaNs at the troughs (base −1e-8) and the bloom smears each NaN into a white blob with a black square —max(…, 0.0)the base. Capture harness:tools/galaxy_compare/(GALAXY_LOOK=scatter|spiral RUN_TAG=<tag>, real main.tscn, centre/offcentre/context frames). -
Nebula variant cloud looks (
VisualLook.nebula_look, 2026-10-07; default 1 “organic”). Chris: the close-plane clouds read as “large coloured gaussian looking circular blobs”. Three switchable looks, all rendering the SAME base scatter (positions/scales/hues/alphas draw from the variant rng in the same order for every look; look-only params draw from their ownseed + 0x9E6Bstream — a live mid-run switch changes only how a cloud looks, never where it is): 0 “disc” = the shippedGlowTexturegaussian discs, untouched; 1 “organic” = CPU-baked fbm cloud textures (render/nebula_cloud_textures.gd: 4 fixed shapes, domain-warped ridged-noise filament sheets over a noise-dented silhouette plus one small core knot, filaments baked brighter than the haze so the additive hue tint keeps depth; static per-process cache likeGlowTexture.shared(), bake ~0.3 s prewarmed behind the start menu byPrewarmPass5b; sprite scale is 0.6x the disc’s while the texture is 2x as large, with cloud density/opacity increased to make the wisps read at gameplay distance; very slow ±0.035 rad/s churn via therotmeta in_process); 2 “cluster” = 6 offset gaussian lobes under ONE pulsed/shedNode2Dparent per cloud. The default now uses 12 clouds at 0.24–0.42 opacity; all looks keep the alpha pulse +set_lowshedding, and every existingNEBULA_*tunable (count/scale/alpha/hues/pulse) still applies. Every look parameter is itself a live Remote Console tunable (Dev builds,HOOK_ARENA→arena_bg.rebuild()): the 13NebulaCloudTextures.*bake params (warp, filament freq/sharpness, edge raggedness, body falloff, density threshold/softness, shape variety, core alpha/radius, haze floor, silhouette edge in/out) andArenaBackground.NEBULA_ORGANIC_SCALE/_CHURN,NEBULA_CLUSTER_LOBES,NEBULA_LOBE_SCALE_MIN/MAX,NEBULA_LOBE_SPREAD; the texture cache is keyed onNebulaCloudTextures.signature(), so an edit (or the run-start reset after one) rebakes on the nextshape()call. Keys and bounds:docs/architecture/remote-config.md§ RC-9. Live mid-run:nebula_lookdev command (Debug Settings “Nebula Look” row + the console’s “Nebula look” selector, theweb_lookpattern) rebuilds the variant with the same seed. Capture harness:tools/nebula_compare/(real main.tscn, fixed seed, centre/offcentre/context). -
Sim per-enemy dispatch: read the column once, test the common case first, inline the plain walker (
Sim._move_enemies, 2026-10-07): the loop used to re-readenemies.behavior[i]in a 12-way if/elif chain with the plain walker (most of the swarm) as the LASTelse, then callEnemyBehaviors.step_walk, which called_nearest_pilotandDroneDirector.enemy_speed_scale(aslow_zonesloop) per enemy. Now: one localb,BEHAVIOR_WALKtested first, and a non-elite / no-drone / one-pilot walker is stepped inline, callingenemy_speed_scaleonly whenslow_timer/stasis_timer> 0 or Blood slow zones exist. Bit-identical by construction (the skipped factors are exact*1.0), pinned bytests/test_move_enemies_walker_fast_path.gdagainststep_walk. Measured with an in-process interleaved A/B (same seed-1234 run, old vs new alternated per rep, 6 rounds, M5 under load ~20):_move_enemiesmedian 734 -> 306 us/tick at ~180 enemies, checksum 2056710076 unchanged. Findings that did NOT pay: a GDScriptmatchon the behaviour was SLOWER than the if/elif chain with the walker in the default arm (matchtests patterns in order, so the walker still paid ~10 comparisons);_facing_prev = enemies.pos.duplicate()instead of the copy loop measured no gain (it copies the whole capacity, notcount). Under heavy machine load run-to-run noise is 20-30%, so compare variants interleaved in ONE process, not separatesim_benchruns. -
Per-frame GDScript hot-loop allocation pattern (W11 trim, 2026-07-15;
main.gdcolour builders +ArchetypeRenderer.sync): render loops that run per entity per frame should (a) write into a REUSED member Packed*Array buffer instead of allocating a fresh one — but note packed arrays are copy-on-write VALUE types, sovar out := _bufdetaches on first write and silently defeats the reuse; index the member directly; (b) precompute per-element colour transforms (ElementPalette.vivid) into a LUT at run start rather than re-running HSV math per entity; (c) hoistobj.pool.packed_arraymember chains into locals before the loop (a packed-array assignment is a zero-copy CoW share) and resolve per-entity lookups likeshape_for()once, not once per pass. Measured on the M5 at 140 enemies: enemy colour+sync path 256→169 µs/frame (−34%),_enemy_colors138→~85 µs (−~35%). Matters doubly whileADAPTIVE_QUALITYis off (no ladder to shed cost). -
Draw-call count: never issue
draw_*calls per item in a loop that can grow (2026-09-24, BUILD 191 telemetry). Godot does not batch these into one call for us: eachdraw_circle/draw_arc/draw_line/outlineddraw_rectcosts its own draw call(s) every frame, on-screen or not. Two live cases found withtools/perf_probe/(windowed, root-viewport draw-call probe that hides each top-level node in turn and prints the delta;--webs=N --isolate --web-fade=Ffor like-for-like web captures): spider webs atSim.WEB_CAP(400) took a Survival frame from ~64 to ~2,080 draws (fps 17-25 on the ATV), andStoryMapRendererdrew every room border/wall/gate of the whole story map each frame, 407 of ~422 draws in a tutorial frame. Fixes, reusable as patterns: (a) many similar dynamic shapes → oneMultiMeshInstance2D+ a smallblend_addcanvas shader drawing the shape procedurally fromINSTANCE_CUSTOM(render/web_field.gdshader+ZoneRenderer._web_layer; capacity doubles and never shrinks, per-frame data goes in through onemultimesh.bufferwrite, layer hidden when empty) — same as the starfield’srender/star_field.gdshader; (b) static geometry → bake once into a vertex-colouredArrayMeshanddraw_meshit, with anything that pulses in its own small mesh tinted via thedraw_meshmodulate (StoryMapRenderer._static_mesh/_gate_mesh/_secret_mesh, gate mesh rebuilt only when the sealed set changes). Results: 400 webs 2,082 → ~80 draws; tutorial 422 → 23. Weapon zones (2026-10-01, FX v2 batch 2): Firestorm zones + Thunder Wake puddles are the same pattern (ZoneRenderer._wz_layer+render/weapon_zone_field.gdshader), plus azones[64]uniform list of every zone so the shader fills each pixel once (lowest slot wins) and draws rim/flames only where no other same-kind zone covers it. One Firestorm chain leaves 40-50 overlapping zones; drawn independently they summed into a solid additive blob over the enemies. Reusable whenever many overlapping additive discs should read as one area. Colour gotcha: 2D here blends in linear space, sodraw_*colours are linearised but a plain shadervec3uniform is not; declare it: source_colorAND pass aColor(aVector3skips the conversion), or the batched version renders visibly bluer/brighter than the original. Same trap for per-instance colours (2026-10-01, zone layer): under HDR 2D (Metal;main.gdenablesuse_hdr_2dat runtime) MultiMesh instanceCOLORis NOT linearised whiledraw_*colours are, so a batched port renders washed-out minor channels (blue → cyan) on the ATV/Mac but matches exactly on web (gl_compat, LDR). Fix: convert sRGB→linear in the shader behind a bool uniform mirrored fromget_viewport().use_hdr_2d(render/zone_field.gdshaderhdr_linear). Verify a port withtools/perf_probe --isolatecaptures under BOTH--rendering-method mobileandgl_compatibility, comparing pixel values, not just RMSE. A new batched layer is a new shader pipeline: add it toPrewarmPass(reaction zones = family 13, weapon zones = family 14) or its first appearance mid-run first-links cold on device. Batched so far: webs (_web_layer), reaction zones (_zone_layer, 48 zones 254 → 15 draws), weapon zones (_wz_layer); story walls in the Survival border look (StoryMapRenderer.wall_layer, 2026-10-02:render/shaders/story_wall.gdshadertakes the arena edge’s look from the sharedrender/shaders/border_look.gdshaderinc—arena_border.gdshadernow includes it too, maths unchanged — and unions the walls near the view through arects[64]/kinds[64]uniform list with lowest-slot ownership, because StoryData’s per-room bars duplicate every shared boundary and overlap at every corner; list cached on (map, sealed-gate signature, view grown by 256 px) so it is rebuilt only when one changes; halo 65 px plus an earlydiscardpast its fade (M5 MoltenVK, 8-layer stack to defeat DVFS: worst view 0.41 → 0.29 ms, start room 0.24 → 0.17 ms per layer vs the 110 px first cut); QualityManagerlow_terrain(L2+) setsStoryMapRenderer.low_detail, which falls back to the legacy meshes; the layer isshow_behind_parentso NPCs draw over it, and room outlines are skipped in that look;VisualLook.story_walls_v2A/B, legacy meshes untouched — still needs an ATV frame-time check); Chalice/Chronowell zones are still per-itemdraw_*. FunZo zones (2026-10-08, ATV BUILD 282 at 3-4 fps in a long invulnerable FunZo fight): batching was NOT enough, fill was the cost. They never stop growing while FunZo lives (cap 160);tools/funzo_zone_probe/probe.gdmeasured ~106 zones covering the whole view and ~51 crossing it, ~134 screens of additive fill a frame.ZoneRenderer.funzo_binssums whole-view zones into one constant (floor_a), drops zones off the view, and cuts the view intoFZ_TILE_PXtiles (live tunable) whoseFunzoQuads list only the zones crossing them forrender/funzo_zone_field.gdshader(zones[64]): ~40 draws, 1 screen of fill, ~22 zones looped per pixel. Binning is per-row interval math (a per zone x tile test was 1.6 ms a frame on an M5; this is ~0.5). Lossless:tests/test_zone_renderer_funzo.gdchecks the plan and tiles against the old per-zone sum, andtools/funzo_zone_probe/compare.gdchecks the GPU output against exact circle math under mobile + gl_compatibility. Pattern: when many additive discs pile up, estimate fill in screens before batching; a disc that covers the view is a constant. -
Shader baker coverage: every custom shader must be a SAVED material (2026-09-29). The export shader baker (every Apple preset, macOS included,
shader_baker/enabled=true) pre-compiles onlyMaterialresources it finds in exported files/scenes (~/Claude/godot-tvos/godot/editor/export/shader_baker_export_plugin.cpp_customize_resource), and only when the export runs WINDOWED with--rendering-method mobile(under--headlessit silently bakes nothing). AShaderMaterial.new()/Shader.new()built in code is never baked and compiles on first use: ~1 s stall per effect on the Apple TV, visible in telemetry ashitchesrows withpipelines > 0and lowproc_ms. Pattern:.gdshader+<name>_mat.tres, thenpreload("res://…_mat.tres").duplicate()per instance (render/screen_punch.gdis the reference). Guarded bytests/test_baked_shader_materials.gd;install-dev.shandscripts/release/build-{tvos,ios}.shfail if the export log lacksStarted Baking shaders(step count varies with the export cache’s dedupe — 328 fresh at BUILD 209, 202 when most variants are already cached). Engine-material coverage (2026-09-30, BUILD 216’s 494 ms run-start hitch): the then ~45CanvasItemMaterial.new()sites shared one baked defaultCanvasShaderRDversion, so compile cost was covered. Superseded 2026-10-03: runtime-created CanvasItemMaterials are now BANNED — on baked iOS exports they trip godot#124088 (A17+ iPhones lost the player hull and every Button label); every additive site preloads the sharedres://fx/additive_mat.tres(seedocs/architecture/godot-gotchas.md).ParticleProcessMaterial.new()was the real gap: each distinct code-built config composes its own particle shader version at RUNTIME (damping/curve/ramp feature toggles), missing from the bake → ~0.5-1 s source compile on first use on device. Fixed by saving all 3 process materials as exact-config.tres(render/shaders/*_process_mat.tres, preloaded atfx/thruster_trail.gd/fx/gpu_burst.gd/render/funzo_confetti_mist.gd) — the baker bakes saved ParticleProcessMaterial .tres (verified: bake 202→204 steps, 2 newParticlesShaderRDcache entries); the guard test now bansParticleProcessMaterial.new()in shipped dirs. Watch: a CanvasItemMaterial with non-defaultparticles_animation/light_mode, or a NEW distinct particle config built in code, would need its own saved material or it compiles on first use again. Residual after the bake: PSO linking (not bakeable). Even with every shader library in the pack, Metal LINKS each pipeline state object in-process on its first submit — BUILD 217’s fresh-process first play still froze 762 ms (4 pipelines, render-thread, zero source compiles: no runtime shader-cache writes, driver library cache untouched). Paid invisibly instead:render/prewarm_pass.gd(wired inmain._show_start_menu, AND — since 2026-10-07 — on the forced-tutorial boot route viaPrewarmPass.boot_then, which puts up its own opaque CanvasLayer-60 cover (base ColorRect + NeonBackdrop + one clear GlassPanel with a plain Label, so the menu’s own UI families – which the menu route links for free – link here too, and the tutorial’s ObjectivePanel glass is warm), waits two frames so the cover is drawn before the pass’s _ready bake, runs the pass behind it and only then starts the tutorial; before that, a save that had not finished the tutorial skipped the menu and the pass, and D1 builds 263-274 showed 16/16 ATV/iPhone launches hitching at run_time 0.017 with 11-13 pipelines, 66 ms - 3.3 s; routing is the puremain.boot_route; guardstests/test_prewarm_pass.gd,tests/test_prewarm_boot.gd) draws one full-alpha instance of every run-start pipeline family for 24 frames UNDER the start menu’s opaque backdrop (StartMenu = CanvasLayer 60; the pass is in main’s world canvas;warm(root, cover)hides + frees the pass the instant the menu hides or leaves the tree), so first runs find the PSOs warm. Never “hide” it with a tiny modulate instead: Godot culls any canvas item whose combined modulate alpha is < 0.007, so the pass’s original alpha-0.001 design submitted nothing outside its SubViewports (fixed 2026-10-02 after BUILD 248’s Warden first-link stalls; desktop mobile-renderer probe: menu prewarm linked 9 → 38 pipelines, Warden fight 10 links incl. all 7fx/boss/shaders → 1-5 links, none Warden; menu pixel diff with vs. without the pass = 0; guardtests/test_prewarm_cull_alpha.gd). The BUILD 218-220 measurements below were taken while the pass was alpha 0.001, i.e. only its SubViewport/CanvasLayer-independent parts were warming – re-read them with that in mind. Measured: v1 cut the fresh-process run-start hitch 762 → 126 ms (BUILD 218); v2 added the families the menu never draws (HeroLights PointLight2Ds, ArenaBorder strips, Polygon2D, outlined dmgnum Labels) and BUILD 219’s ATV fresh-process first run had ZERO hitches — the tvOS run-start stall is closed. Rule for new content: any draw family that first appears at run start (new weapon visual, new UI overlay, iOS-only nodes) must be added toPrewarmPass, or it will PSO-link on device at its first submit. Remaining known gap: iOS-only first-draw families — the iPad showed a 129+107 ms, 4+3-PSO residue (BUILD 219). v3 added the TouchControls pad as family 11 (CanvasLayer gotcha: the pad does not inherit the pass’s modulate; it draws at full alpha on layer 30 under the menu’s layer 60 – until 2026-10-02 it was set to alpha 0.001, i.e. culled, so v3 never actually warmed it;TouchControls.begin_prewarm()also neutralizes the pad’s_inputfor the warm window — a real touch can’t hit the hidden buttons); tvOS/desktop draw none of it, so the prewarm child is invisible there and contributes nothing. Verified 2026-09-30 (BUILD 220): v3 did NOT move the iPad rows (but see above: the pad was culled at alpha 0.001 then, so this does not prove its families weren’t missing – recheck the iPad rows on a post-2026-10-02 build). The 131/98 ms 4+3-PSO stalls are missing BAKE coverage, not missing prewarm: the stock 4.7.1 iOS export baker emits ~16 RD cache entries (~1 CanvasShaderRD variant) vs the fork’s ~137 (19 CanvasShaderRD), so the iPad’s first submits pay a ~100 ms source compile+link where the fork-baked ATV pays an invisible ~30 ms link that the menu prewarm absorbs. A fork-baked pack in the stock runtime is worse still (BUILD 221: 1243 ms run start — reverted same day; the runtime can’t read the fork’s bake cache). The fix is the fork runtime+templates inside the iOS app (tvOS model);install-dev.shcarries the warning comment anddocs/RELEASE-1.0.1.mdtracks the decision. v4 (2026-09-30, landed with the Weapon FX v2 merge as family 12) warms the six v2 look pipelines (one perfx/weapons/shader — withVisualLook.weapon_fx_v2now default ON, the first bolt / swing / orbit frame / burst would otherwise first-link cold) plus the legacy Plasma Nova quad (fx/plasma_nova_mat.tres, the L3+-fallback path and the same family as BUILD 222’s 305 msnew:novafirst-use stall). Both halves are switch-gated: the pass never links a pipeline the flags won’t draw. v5 (2026-10-07) added families 19-24 after an audit found 18 game shaders the pass never drew, several with recorded ~1 s first-use stalls on device (first aim shotnew:aim_fireATV 262/264, first death ~950 ms ATV 260-264, Codexenemy_smear~1 s ATV, first pause glass ~1.9 s ATV): everyArchetypeRendererbucket (prewarm_fill(), all three core vertex shapes onenemy_smear), the aim lance (AimLance.make_renderer(), now main’s constructor too), oneDeathDissolve, the GRID backdrop (arena_grid_breathe, a random Home-pool pick on area entry), the full-screenBackBufferCopy(VIEWPORT)+ ColorRect passes drawn full-screen at strength 0 so each writes the copied screen back unchanged (ScreenFeedback’s always-submitted low-HP border,ScreenPunch,GravityLens), andGlassPanelclear/frost/snapshot glass with the twobuild_snapshot()blur passes. Coverage guard:tests/test_prewarm_coverage.gdwalks the built pass and fails on any.gdshaderunderrender/,fx/,ui/(candidates excluded) that nothing in the pass draws, unless it is in itsACCEPTED_COLDlist with a reason (today:player_hull_litunreachable behindUSE_BAKED_HULL,player_tiltdev-switcher only,arena_aurora+ the threefunzo_*deferred as not-first-minutes,threat_aggregate_glowno recorded stall). Scope: fresh-process / fresh-install first-use stalls only; not verified on device yet (needs skillbh-fresh-install-stall-check), and the added menu warm-time on the ATV is unmeasured. Family 25 (2026-10-07, dev builds only): every archived weapon-look candidate (fx/weapons/candidates/<wid>/<slug>/look.gd+ Railgun’smark.gd, 24 scripts today) is drawn once, so a mid-runweapon_lookswitch (Debug Settings / console) no longer links cold. The list (PrewarmPass.candidate_scripts_to_warm) comes from the_labels.jsonroster +ResourceLoader.exists, the same ruleFxManager.set_candidate_lookuses — never a DirAccess scan, which is unreliable in an exported PCK — so a new candidate folder is covered with no prewarm edit (pinned against the folders on disk bytests/test_prewarm_pass.gd). Each is placed whereFxManager._host_layerputs it in-run: inside a forced-live half-res target whenPerfCuts.fx_half_resis on andFxHalfResHost.can_hostaccepts it, else on the lit canvas (the premultiplied identity looks). Theirmat.tresfiles are already shader-baked (test_baked_shader_materialsscansres://fx), so this absorbs the PSO link only. Cost on an M5 Mac (mobile renderer, warm disk cache): ~+120 ms on the first, menu-covered warm frame; not yet measured on the ATV. Known gap: flippingfx_half_resmid-run moves candidates to the other render state, which is not warmed. Launch cover (2026-10-16, Chris: “the splash screen shows then disappears and a slightly grey screen stays there for a bit then the main menu shows”).ui/boot_splash.gd(BootSplash, CanvasLayer 100) is added bymain._readybefore routing (never headless) and redraws the engine boot splash (res://splash/boot_splash.png, now at the repo root;platform/tvos/splashis a symlink to it) on pure black with the engine’s own stretch_mode-1 KEEP rect (BootSplash.splash_rect, copied from Godot 4.8get_splash_stretched_screen_rect: the axis follows WINDOW orientation, so landscape iPad overflows the sides exactly as the engine does), plus a slim opaque cyan shimmer under the wordmark (default canvas items only, no shader). It covers the menu route’s prewarm (main._warm_menu: prebake nebula ->PrewarmPass.warm-> wait for the pass to leave -> release, which also restarts the menu’s row reveal) and the tutorial route’sPrewarmPass.boot_thencover; release =HOLD_FRAMESframes then aFADE_Sfade whose alpha goes throughfade_alpha(inverse hdr_alpha remap); failsafe 20 s. It goes up only when a prewarm will run (PrewarmPass.available(): never headless or in the editor, so windowed capture tools see no black frame). While it is up the start menu isinput_locked(its_inputswallows taps / pad / accept, so a Siri click on the splash cannot start a row blind) andmain._inputreturns early; the cover’sliftedsignal (main’s release or the failsafe) unlocks it, and a failsafe lift never replays the menu reveal._warm_menuonly warms a menu that is still drawn after the bake (a Shop / Bestiary / Online screen that hid it would otherwise spend the once-per-launch pass with nothing linked). Routes go throughmain._route_bootso each is tested. TheNebulaCloudTexturescloud bake (~0.37 s on an M5 editor build, 4 images) now runs on a worker thread (prebake_start/poll/finish,PrewarmPass.prebake_clouds) so the loader keeps moving; headless and tests stay synchronous, andshape()joins any pending job so results are identical. Desktop probe (M5, exported release, mobile renderer): first game frame 1324 -> ~1134 ms; gl_compatibility still stalls ~4 s on its second frame (GL shader compiles) but under the cover. Pinned bytests/test_boot_splash.gd. -
Weapon-look GPU cost: measure it per SHOT, through the real classes (2026-10-01, BUILD 237/238 ATV fps drop with Railgun + Beam).
tools/perf_probe/look_bench.gdspawns K overlapping shots through the realFxManager.consume(), pins them at a fixed age, and averages root-viewport GPU time (MoltenVK:--rendering-driver vulkan, Metal reads 0); summing uniform age samples gives GPU ms x s per shot, so a 0.12 s legacy flash and a 0.72 s helix+scar compare fairly.--shot=DIR --ref=REFDIRis the lossless proof: it renders the same shots TWICE IN THE SAME FRAME (two HDR SubViewports, soTIMEis identical; it can’t be pinned any other way) with B’s shaders swapped for reference copies (git show main:<path>), and prints max channel diff + differing px. A control with REFDIR = the current shaders must print 0. Lessons:- Cost is quad area x per-fragment work, and the “cheap” analytic terms are most of it. Bisecting the Railgun lance shader on the Mac: a constant-colour shader on the same quads costs ~1/8 of the real one, the value-noise helix + accent motifs only ~15-25%, the ~15
exp()Gaussian tails the rest. Gating the noise blocks behind branches (exactly zero once the helix has burned out) and skipping accents below a mask epsilon saved almost nothing on their own. Real savings need FEWER SHADED PIXELS (smaller quad, shorter life), not cheaper noise. - A fragment early-out only pays where whole SIMD groups drop. A
discardfor the empty part of the quad once only thin features remain (the lance’s post-helix scar phase) cut that phase ~30%, not the ~70% of pixels it drops: groups straddling the kept bands still run everything. Godot rejectsreturninfragment(); usediscard(fade-before-discard still applies: each dropped term must already be < ~2e-5 pre-dim, under a tenth of an 8-bit step on black; sRGB’s 12.92x slope near black makes 5e-4 linear a visible ~2 levels). - Measured (M5, K=64, GPU ms x s per shot; legacy draw_line flash for scale): Railgun legacy 0.0038, promoted C 0.027 without the scar / 0.055 with RAIL_SCAR_S 0.3 / 0.071 evolved (after the lossless pass: 0.025 / 0.044 / 0.050); Beam legacy 0.0015, D/E/F 0.008-0.009, evolved D 0.067, E/F 0.038. Per second of firing the Beam (10 shots/s, Lance 4/s) is the largest v2 cost; the mark layer costs ~2 us per marked enemy per frame (GPU, ~0.14 ms at 20) plus ~3.5 us CPU in
draw_marks. Legacy beam/rail flashes are 8-25x cheaper per shot. - The Mac GPU down-clocks under light load: per-shot numbers at K=32 swung +-30%; K=64 (GPU busy) settles them. Read relative costs, never absolute ms as ATV ms.
- Pixel counts scale with the output resolution. The project stretches
canvas_itemsfrom 1280x720, so every look’s quad is x2.25 the canvas px at 1080p and x9 at 4K. The Apple TV renders 1920x1080, so its multiplier is x2.25, not x9 (corrected 2026-10-22). It has done so since BUILD 211 (commit4dc6d572, 2026-09-29, fork branchtvos-render-1080p); it is NOT build 234, which is the late-run perf-fix build. Telemetry does record it:eventsrows of typelaunchcarrywin_w/win_h1920x1080,screen_scale1,refresh_hz60 and renderermobileon all 51 ATV launches on builds 303-335, and only the single BUILD 211 launch was 3840x2160 at scale 2.samples.vp_w/vp_hare NOT the output size: they equal the window times window/canvas (2880x1620 on the ATV, 11520x6480 on that one 4K launch). A fill-bound look is therefore 2.25x dearer on the ATV than at the 1280x720 design size, not 9x; the old 4x-at-4K comparison no longer applies. The measured ATV GPU cost per scene size is indocs/reference/fidelity-d1-device-model.md.
- Cost is quad area x per-fragment work, and the “cheap” analytic terms are most of it. Bisecting the Railgun lance shader on the Mac: a constant-colour shader on the same quads costs ~1/8 of the real one, the value-noise helix + accent motifs only ~15-25%, the ~15
-
Roster pass (2026-10-01, branch
fx-perf-roster): the same method on every other look.look_bench --set=rostercovers blade/scythe/nova (p0.3, p1, evolved), legacy plasma nova, blaster flashes, the orbit ring, Firestorm/Thunder weapon zones, player bolts, gravity lens, shockwave and the low-HP border;--abtimes current and reference shaders interleaved in one run. Largest costs in a typical evolved run (M5, relative only): Firestorm zones (~0.04 ms per zone per frame, ~2 ms at the 40-50 a chain piles up), evolved Nova (0.51 ms x s per shot, the dearest single shot), evolved Blade (0.11 ms x s per 0.5 s swing), the full-screen gravity-lens / shockwave passes while active. Lossless fixes that landed: Blade/Scythe arc-sector cull (-41..60% un-evolved), un-evolved Orbit ring annulus (-42% at p0.3), weapon-zone quad trim before the union loop (-22..45%), gravity lens outside-radius discard (-20%), low-HP border inner discard (-20..62%). Lessons:- A discard pays when it drops a LARGE CONTIGUOUS region. The arc cull drops two-thirds of a swing’s quad in whole blocks: discard alone was bit-identical and gave -37..49%; shrinking the quad to the sector’s bounding box added the rest. Contrast the lance scar above, where thin kept bands keep most SIMD groups alive.
- Shrinking a quad whose corners ALREADY discard saves nothing. Evolved Nova’s 1.35 -> 1.16 quad trim (-26% area) measured 0, and gating its tongue fbm behind
x >= 0measured 0 too: both reverted. - A quad shrink is not bit-exact. UV interpolates over a different triangle, so expect 1-3/255 round-off on a few hundred px and, where a hash picks a cell from
p, the odd flipped sparkle (one 64/255 px on evolved Nova). Prove the CUT with a discard-only variant first (must be max 0), then attribute what is left to interpolation. - Full-screen passes need stacking to measure. One pass per frame reads anywhere from 0.01 to 0.65 ms across runs; 8-16 stacked copies (
"k"per config) settle it. The shockwave’s zero-displacement discard measured no saving and was not applied. A--resolution 1280x720window read every screen pass as 0 (not understood; 640x360 works). - Never time alongside another agent’s look_bench: a concurrent bench on the same GPU swung one run’s zone saving from 0% to -45%. Check
pgrep -f look_bench.tscnfirst.
-
The general (non-background-specific) perf technique catalog lives in
docs/superpowers/plans/2026-06-25-graphics-perf-plan.md— weapon-fx-always-on, halo overdraw, viewport culling, platform-aware entity budget, palette/HDR-feel tricks, player evolution. Items 1/2a/3/6-free-tier/7 shipped at BUILD 52 (cycle 22 above); Item 4 (platform-aware entity budget) is the one still-open item, gated on a fresh on-device re-measure. That plan predates the SubViewport-downres technique above — it doesn’t mention resolution scaling at all, so don’t assume it’s already covered there. -
Is a device sim_ms change real? A/B the sim headless on the Mac before believing it (2026-09-28). (2026-10-07:
samples.sim_msis Godot’s worst whole physics step of ONE second per 5 s sample, input/autopilot/audio included, so it is spiky by construction. Builds from 2026-10-07 also sendtick_ms/tick_max_ms/ticks, everysim.ticktimed over the window: compare builds on those, neversim_msagainsttick_ms. See dev-tools.md § Telemetry v2 Perf fields.) ATVsamples.sim_msswings ~1.5 ms between sessions of the same build (one BUILD 204 autopilot session read 4.5 ms vs 2.3-2.8 for the others), so two sessions can’t prove a sim regression.tools/sim_bench/sim_bench.gdruns a fixed seed-1234 invulnerable Survival sim and prints µs/tick plus the state checksum; run it in agit worktree add --detach <old>checkout and onmain(instructions in its header). Matching checksums = same sim work, so any µs gap is code cost. First use: remote-config batch 1 (12 consts →static var) read +1.3 ms on the ATV but benched 859 → 852 µs/tick, i.e. no cost. -
Long FunZo fight: the autopilot, not the sim, is most of the per-tick cost (2026-10-09, ATV BUILD 291).
tools/perf/funzo_profile.gdreplays the console state (spawn_boss funzo+ invuln + damage_mult 0 + pause_spawns, attract autopilot throttled + humanized) and prints per window zones/enemies, sim µs/tick, autopilot µs/tick, full-plan rate and, with--phases, the topSim.tickphases (sim_bench’s timing subclasses;--no-jesters/--no-zonessplit the two things that grow). M5, 0 -> 150 zones: sim ~105 -> ~300 µs/tick, autopilot ~110 -> ~1,100 µs/tick. Sim:FunZo.update_zoneswas ~0.5 µs/zone (74 µs at 149 zones, the top phase); reading each zone Dictionary once and skipping the pilot scan when no pilot can be hurt took it to ~30 µs (value-identical,tests/test_funzo_zone_update.gd). The rest of the sim growth is the jesters: with damage_mult 0 they never die (one per 6 s), ~4 µs each across_move_enemies/ collisions / status, so it is an artefact of the test setup. Autopilot:HazardField.collectreturns EVERY live zone as a soft disc (no reach filter) and_decideappends them after the 32-nearest-lane trim, so the escape-ring / warp search tests ~100 points against ~150 discs; and once the pilot stands in any zone_can_hold_planrefuses to hold, so the throttle runs a full plan every tick (0.45 -> 1.00 plans/tick). Becausesamples.sim_msincludes the autopilot (see above), an autopilot run’s sim_ms climbs with zone count even though the sim barely does; comparetick_ms.
Weapon FX v2 (2026-09-29, batch 1)
Section titled “Weapon FX v2 (2026-09-29, batch 1)”Spec docs/superpowers/specs/2026-09-28-weapon-fx-v2-design.md, plan docs/superpowers/plans/2026-09-29-weapon-fx-v2-foundation-batch1.md, memory bullet-heaven-weapon-fx-v2. Per-weapon shader looks that grow with investment and transform on evolution. Batch 1 = Blaster (all bullets + muzzle/impact flashes), Orbit Shards, Blade, Scythe, Nova. Switch: VisualLook.weapon_fx_v2 (Debug Settings toggle) — default ON since the 2026-09-30 merge (Chris’s T16 sign-off: batch 1 merged as reviewed; bolt_impact sparks draw against their own BOLT_IMPACT_CAP, not the shared JUICE_FRAME_CAP). Enemy effects stay visual only (Chris, 2026-09-29). Global intensity: WeaponFxLayer.BASE_DIM (0.75 since BUILD 224) — every v2 look’s final rgb multiplies by the per-layer dim uniform (alpha untouched; player_bolt and the legacy plasma quad dim via their own paths), and the warp’s bolt dim scales by it. Chris’s 2026-09-30 device feedback: at 1.0 the stacked additive looks washed the screen white and drove iPad render_ms 14.7→24.4.
- Data.
WeaponVfx.stamp(ev, arsenal, wid)addswid/power/evolved/rungsto every weapon fx_event.power = 1 - 0.72^(level picks + crystal rungs), capped atPOWER_MAX. Bullets carryProjPool.vfx/rungs/kind(fromWeaponVfx.bullet_vfx/rungs); these are render-only and never checksummed, so the determinism baseline does not move.WeaponVfx.stamp_bulletre-stamps events raised from a bullet (burst,bolt_impact). - Render. Each look is a
WeaponFxLayer(fx/weapons/weapon_fx_layer.gd): one MultiMesh + one shared shader = one draw call, stride-16 buffer (transform 8 + COLOR 4 + INSTANCE_CUSTOM 4). Event looks callspawn(pos, rot, half_size, tint, custom, life_s)(false when full: bounded drop) andadvance(dt); COLOR.a is life progress unless the look says otherwise. Persistent looks useput()+commit()each frame. Per-instance layouts (verified against the look scripts and shaders; COLOR.rgb is always the element tint):
| Look | COLOR.a | custom.x | custom.y | custom.z | custom.w |
|---|---|---|---|---|---|
| Blade / Scythe | life t | power + 2*evolved |
accent slot | arc (rad) | seed |
| Nova | life t | power + 2*evolved |
accent slot | seed | 1 = lite (one fbm octave less: PerfCuts.nova_lite, evolved only), else 0 |
| Orbit ring (1 per pilot) | spin_norm | power + 2*evolved |
accent slot | world angle of shard 0 (rad) | shard count (>= 1) |
| Orbit shard | 1 | power + 2*evolved |
accent slot | seed k (k * SEED_STEP, in [0,1)) |
-1 |
| Player bolt | per-bullet seed in [0,1), from travel angle | vfx = power, +1 if evolved |
accent slot | ProjPool.kind |
remaining life (s), the fade-out input, not a seed |
| Blaster flash | life t | power + 2*evolved |
accent slot | seed in [0,1) | mode (0 muzzle, 1 impact) |
| Railgun lance | life t | power + 2*evolved |
accent slot | seed in [0,1) | length / width (the shader works in these units) |
Railgun mark (persistent, FxManager.mark_layer) |
mark strength (timer / MARK_DURATION) | stacks 1-4 | seed in [0,1) | 0 | 0 |
Evolved decodes as x >= 1.5 for looks and vfx >= 1.0 for bolts. The accent code never rides per-instance: gl_compatibility (web) stores MultiMesh COLOR and INSTANCE_CUSTOM as HALF floats (max 65504, 11-bit mantissa), so custom.y holds layer.accent_slot(code) (0-15, slot 0 = no accents or table full; ACCENT_SLOTS = 16) and the shader recovers the code with accent_code_for_slot(custom.y) from the layer’s accent_codes int-array uniform. Every per-instance value must survive half precision: seeds are integers < 2048 or fractions in [0, 1).
- Dispatch.
FxManager.V2_LOOKSmaps wid -> look class name (a"main:"value marks a persistent look owned by main.gd).v2_active() = VisualLook.weapon_fx_v2 and QualityManager.weapon_fx_v2_on(level)(L0-L2 only; L3+ falls back to the v1 looks). The persistent looks (OrbitShardsLook,PlayerBoltRenderer) live in main.gd and swap per frame. Railgun (RailgunLanceLook, promoted 2026-10-01 by COPY fromfx/weapons/candidates/railgun/opus55, which stays archived as submitted) is an event look like the others; its per-enemy mark is the built-inFxManager.mark_layer(RailgunMarkLook, tint = the bible’s light element viaElementPalette.color_for), redrawn bydraw_marks()only while_mark_watchis armed (arailgun_lancewas consumed and some enemy still carries a mark) and emptied once the marks are gone or v2 is off. A Railgun candidate override hides it and the off slot (“current”) restores it. The promoted lance (not the archived candidate) adds a rail scar afterglow:static var RailgunLanceLook.RAIL_SCAR_S(0.3 s; 0 = off = the as-submitted look) extends the instance life and is pushed to the shader asscar_s, which converts life t back to real seconds overbase + scar_sand feeds the accents the lance’s own t; the scar is a thin (0.08 half-width), ~0.12-peak line that fades in as the helix burns out, so it never reaches the bloom threshold. Pinned bytests/test_railgun_rail_scar.gd. - Accents.
fx/weapons/element_accents.gdshaderincand its GDScript twinElementAccents(fx/weapons/element_accents.gd) share the ids (ACC_*==ElementAccents.IDS), the packing (2 bits per element id at bit2*(id-1), ids 1-10; the evolve id at bits 20-23:ACC_EVOLVE_SHIFT/MASK,ACC_LEVEL_MASK,ACC_MAX_ID) and the slot-table size. The motif entry point is the generic(p, mask, edge, s, t, seed, now)contract (accents_all), so a later batch can reuse it for enemies without touching the packing. Batch 1 builds 7 motifs (kinetic, cold, lightning, blood, fire, void, light). - Plasma Nova.
PlasmaNovaNode(fx/weapons/plasma_nova_node.gd) is the v1 / L3+ nova; the section below is still its source of truth. - Harnesses (
tools/weapon_vfx/, skillbh-vfx-iteration):compare(grid per weapon: v1 | p0 | each rung | evolved, through the real render classes;WEAPON=<wid>),ingame(real main.tscn with a seeded horde, v1/v2 like-for-like;WEAPON=,V2=,EVOLVE=,LEVELS=),motifs(7 motifs x 1/2/3 rungs, animation frozen for diffing). Each writes to$WEAPON_VFX_OUT(defaulttools/weapon_vfx/out/); rungit checkout -- project.godotafter a windowed run. - Guards (
tests/test_weapon_fx_guards.gd; these make a weapon added later viabh-add-contentfail the gate if its effect is invisible or doesn’t scale):- every
fx_events.append(insim/weapon_*.gdis one line, callsWeaponVfx.stamp(and names its own file’s wid (aim_fire,burst,bolt_impactare checked separately); - the batch-1 weapons’ real emitted events (blade, scythe, nova, blaster, scar tissue’s discharge nova, aim) carry
wid/power/evolved/rungs; evolve()on ANY weapon changes its stamp;- every weapon-local
var x: Array[Dictionary]zone array is referenced by a renderer (main.gd,render/,fx/), or sits inKNOWN_INVISIBLE(wildfire burn zones, thunder wake, both owned by batch 3); - every weapon id is in
FxManager.V2_LOOKSorPENDING_V2_LOOKS(with its batch), never both; - accent ids, packing constants, slot-table size and decode expressions agree between the shader include and
ElementAccents/WeaponFxLayer; every per-instance payload (slot,power + 2*evolved, seeds, HDR tint) round-trips throughencode_half/decode_half; - no shader uniform shares a name with a local or parameter declared inside an
#includeit pulls in (Godot 4.7 resolves the local to the uniform and the whole shader fails to compile).
- every
- Gotchas.
- The headless GUT gate never compiles shaders. A shader compile failure (Task 13’s
dimclash betweenplayer_bolt’s uniform and a local inaccents_all) passed the gate; only a windowed run or the compare harness compiles them. Compile-check every shader edit that way, and see guard 7 for the one clash class that is now caught structurally. - A
QuadMeshin a MultiMesh flips UV y; derivepfromUVknowing +y is down, and don’t assume the quad’s local up. - Colour uniforms need
: source_color(or the tint is treated as linear and looks washed/dark). - Nested
#includeneeds#ifndefguards or a shader that pulls the same include twice redeclares everything. - Name locals in includes so they cannot match any including shader’s uniform (guard 7).
- The headless GUT gate never compiles shaders. A shader compile failure (Task 13’s
- Scatter (gfx loop 2026-10-07,
VisualLook.scatter_v2, Debug Settings “Scatter Look (A/B)”, default ON). Two halves: the event lookScatterBurstLook(scatter_fire, essential, half-res hosted) draws the muzzle blast, a fan sized from the event’s realspread/pelletswith one tracer streak per pellet; andPlayerBoltRenderer’sKIND_SCATTERbranch (gated by thescatter_v2uniform, pushed on change) draws each pellet as a round white-hot slug that cools overSCATTER_COOL_S(evolved: a short flak arrowhead). The streaks ride the real pellets: the event carriesspeed, and the look pushestravel = (speed*LIFE/HALF, (MUZZLE_OFFSET - speed*DT)/HALF)so the shader’s head position matches the sim’s pellets (pellets leave the pilot centre and move on their firing tick). Pinned bytests/test_scatter_fx_v2.gd. Caveat:main.gdadvances FX by a fixed 1/60 per rendered FRAME, so the tracers match the pellets only at 60 fps: below it (a slow uncapped capture, where it showed) they lag the pellets, and if a device ever renders at 120 Hz every FxManager event look ages twice as fast. That is a pre-existing property of all event looks (nomax_fpscap is set; whether iOS ProMotion actually runs above 60 here is unverified). - Momentum Core (gfx loop 2026-10-07,
VisualLook.momentum_v2, Debug Settings “Momentum Look (A/B)”, default ON). Three parts:MomentumChargeLook(persistent, main-owned, synced every frame fromentries_for_sim) draws six hull cells filled byWeaponMomentumCore.charge_level(pilot), a pure read of the sim’s rolling speed window (trailing run of fast samples over the window; exactly 1 when the next burst is charged);MomentumReleaseLook(event look, essential, half-res hosted) draws a recoil flash, shock cone and pressure fronts on EVERY burst (the sim now emitsmomentum_firefor weak/base bursts besidemomentum_charged_fire, both carryingstate/shots/speed, render channel only);PlayerBoltRenderer’sKIND_MOMENTUMbranch (gated by themomentum_v2uniform) draws a dense slug with a bow shock and Mach-cone wake. Pinned bytests/test_momentum_fx_v2.gd. - Turret (gfx loop 2026-10-07,
VisualLook.turret_v2, Debug Settings “Turret Look (A/B)”, default ON).TurretBodyLook(fx/weapons/turret_body_look.gd, FxManager.V2_LOOKS"main:TurretBodyLook") is a persistent, main-owned Node2D_drawlook (no shader): a normal-blend body (dark hex plate, neon rim, braced feet, rung notches, domed head, barrel; twin barrels + amber when evolved) plus an additive glow child (halo, hot core, land shock ring, muzzle star, per-shot tracers). It readsWeaponTurret.render_states(sim), a pure read (pos, life, age, since_fire, andaim= the shot line of the turret’s last real_fire, recorded render-only on the turret dict, ZERO while holding fire; no per-frame enemy rescan, 2026-10-07 fix2/turret-render-rescan), and keys each turret by its deploy tick so recoil/flash/tracer fire on real shots only. The legacyturret_firespark and main’s oldturret_bodiesare skipped while it is on.turret_firenow also carriesdir. Live tunablesTurretBodyLook.SIZE/CORE_GAIN/TRACER_LEN/FLASH_S/EXPIRE_S. Bench:tools/weapon_vfx/turret_bench.tscn(old vs v2 across deploy/firing/tracking/expiring/garrison). Pinned bytests/test_turret_fx_v2.gd. Rounds + ricochet (part 2, same toggle):PlayerBoltRenderer’s KIND_TURRET branch draws each round as a compact steel-cyan slug (white-hot core, pointed nose) trailing a short thin tracer; Garrison = amber with a spin glint. Colours are the emplacement’s, not the bullet tint (kinetic, no element). Uniformsturret_v2/turret_gainpushed on change; live tunablesPlayerBoltRenderer.TURRET_LEN/WID/GAIN. Every landed round stamps a render-channelturret_impact(ElementalSystem.resolve_collisions, likebolt_impact); FxManager skips it before any budget and main forwards render events toTurretBodyLook.note_events, which draws a ricochet (hit flash, tight ring, sparks kicked back off the struck side; capped atIMPACT_CAP), liveTurretBodyLook.IMPACT_S/IMPACT_LEN. Bench:tools/weapon_vfx/turret_bullets_bench.tscn. Pinned bytests/test_turret_bullets_fx_v2.gd. - Scar Tissue (gfx loop 2026-10-07,
VisualLook.scar_tissue_v2, Debug Settings “Scar Tissue Look (A/B)”, default ON).ScarTissueMeterLook(fx/weapons/scar_tissue_meter_look.gd, FxManager.V2_LOOKS"main:ScarTissueMeterLook") is a persistent, main-owned Node2D_drawlook (no shader) that makes the wound stacks visible: a segmented crimson ring round the hull, one segment per stack up to the REAL discharge threshold, read viaWeaponScarTissue.meter_state()(pure: stacks, threshold, decay 0..1). The newest lit segment drains with the decay clock; one stack short of discharge the meter throbs (heartbeat bloom); a stack gain (detected as a stacks increase) cuts a scar slash across the hull. The discharge is detected from main forwardingrender_fx_events(note_events: the sim’s ownnovastampedwidscar_tissue), never from a stacks drop, so a decay never fakes a detonation; it blows the segments outward with shards and a core flash while NovaBurstLook still draws the damage ring. The legacyscar_tissue_stackspark is skipped while it is on. The meter fades in/out with stacks > 0. Live tunablesScarTissueMeterLook.RADIUS/WIDTH/GAIN/GAIN_S/BURST_S/BURST_SCALE. Bench:tools/weapon_vfx/scar_bench.tscn. Pinned bytests/test_scar_tissue_fx_v2.gd. - Chalice (2026-10-07, Chris: “the chalice pools feel too small, make them bigger and look better”;
VisualLook.chalice_v2, Debug Settings “Chalice Look (A/B)”, default ON). Bigger first: bibleweapons.chalice.area90 -> 130 plus the live tunableWeaponChalice.RADIUS_MULT(1.0, read per CAST, so pools already down keep their radius; it also drives the damage radius and the upgrade-card numbers).ChalicePoolLook(fx/weapons/chalice_pool_look.gd+chalice_pool.gdshader, FxManager.V2_LOOKS"main:ChalicePoolLook") is a persistent, main-ownedWeaponFxLayer: main callssync_entries(entries_for_sim(sim))every frame, one quad per livesim.chalice_zonesentry, all pools in ONE draw,z_index -1(under the swarm), added as a sibling of ZoneRenderer, not a child (ZoneRenderer’s modulate would corrupt COLOR.a). Pure read: COLOR = element tint (ElementPalette.weapon_color, poison green; AMBER when the zone has no element), a = remaining/duration; INSTANCE_CUSTOM = (power + 2*evolved, accent slot, age s, seed). The zone dict carries render-only keys (element/wid/power/evolved/rungs,WeaponVfx.stamp);chalice_zonesis not in the checksum, so the baselines did not move. Shader: spill-in overSPILL_S(0.25 s) with a splash ring + droplets (first 0.45 s; replaces the legacychalice_castring, which FxManager skips while the look is on), domain-warped caustic swirl, single-cell-lookup bubbles that swell and pop, an HDR meniscus rim, evaporation over the last 20% (edge shrinks, surface breaks into patches); the interior stays ~0.1 additive so enemies and bullets read through it; evolved pools get lobes + the void accent. ZoneRenderer’s hexagon loop is skipped whilechalice_v2_drawn. Live tunablesChalicePoolLook.PAD/GAIN/SPILL_S. Cost (M5 MoltenVK,tools/perf_probe/chalice_perf.tscn, ABBA in one run, GPU ms over empty, root viewport + the offscreen target’s own measured time; review round 1): 8 pools r130 +0.51 (round 0: +1.30; hexagon +0.09); 8 overlapping r350 +0.53 (round 0: +3.08; hexagon +0.12); same evolved +0.66 (round 0: +5.01; hexagon +0.12). What bought it: (1) the look draws through main’s ownFxHalfResHost(main.chalice_host, z -1, right after the look: FxManager’s host composites ABOVE the swarm and would wash green over enemies), soPerfCuts.fx_half_rescovers it; few pools at r130 (review round 2, 9 ABBA rounds): 1 / 2 / 3 / 4 pools half-res +0.33 / +0.40 / +0.49 / +0.47 vs full res +0.35 / +0.57 / +0.74 / +0.78 vs hexagon +0.16 / +0.16 / +0.18 / +0.17, so the extra offscreen pass never costs more than full res on M5 (its ATV cost, a tile-based GPU, is unmeasured); low-load numbers are noisy at about +/-0.15 ms; (2) after the 0.45 s splash the quad shrinks fromPADtoPOST_PAD(1.1, +EVO_PAD0.12 evolved; the shader derives the same pad from age,ChalicePoolLook.quad_pad), and the quad corners past the inscribed circle are discarded with a round falloff (no square clipping; splash droplets are clamped inside it); (3) the evolved void bite reuses the surface noise instead of a second 3-octave fbm, and the lobes are one sin instead of a vnoise; (4) no atan/cos/sin outside the splash (cdir = q/r, triple-angle shimmer); (5) everything on the liquid is skipped outside it. Shedding:lite(single-layer caustics, 60% bubbles) is on whenPerfCuts.chalice_lite(Debug “Perf: Chalice pools lite (A/B)”, default OFF, unapproved), at QualityManager L2+ (chalice_lite_forced, the “zones outline” tier; L3 falls back to the hexagon with the other v2 looks), or by the crowd: a FADE-WEIGHTED pool count (crowd_weight: each pool ramps in overWEIGHT_RAMP_S0.5 s after its cast and out with the shader’s evaporation fade) aboveCROWD_LITE+ 0.5 turns lite on, belowCROWD_LITE- 0.5 off (hysteresis, so a count hovering at 4-5 does not swap every pool’s pattern at once); interiors (body/caustics/bubbles, not the rim) scale bysqrt(CROWD_REF / weight)(CROWD_REF1.0) so stacked pools do not sum into a bright green floor, and the weighting keeps the per-cast 1 -> 2 -> 1 cycle from popping the live pool’s glow (a 0.29 step became a ~0.5 s ramp). The half-res variant is prewarmed (PrewarmPass family 16). The pools dim with the warp (set_dim, like the old hexagon’s modulate). Apple TV cost is unmeasured. Bench:tools/weapon_vfx/chalice_bench.tscn(before/old r130/v2 by life stage, full res;CHALICE_LITE=1). Pinned bytests/test_chalice_fx_v2.gd. Each pool applies the element it was cast with (zone.element), not pilot 0’s (co-op correctness; identical in single player). - Chalice “Brew” alt (gfx loop 2026-10-07 part 2, re-added 2026-10-08 as a THIRD Chalice look so Chris can compare on the TV;
VisualLook.chalice_brew, Debug Settings “Chalice look: Brew (alt)”, default OFF). On, it OVERRIDESchalice_v2;ChaliceBrewPoolLook.pool_mode(v2_active)is the one place main asks which of legacy hexagon / main’sChalicePoolLook/ the Brew draws, so exactly one does in every toggle combination (pinned through main’s real_processintests/test_chalice_brew_fx.gd).ChaliceSpillLook(FxManager runtime keyChaliceSpillLook.WID="chalice_spill", not aV2_LOOKSweapon key) draws the cast: a splash crown landing ON the real pool radius atSPILL_S, droplets splatting on the rim.ChaliceBrewPoolLook(main-hosted, z -1, registered in main’s existingchalice_hosthalf-res host) draws eachSim.chalice_zonesentry as a lime brew with caustic veins, bubbles and a gold meniscus ON the real radius, full strength while it damages and evaporating over the lastEXPIRE_S; power/evolved/rungs from the zone’s own fields. Both sharefx/weapons/chalice_brew.gdshader(+chalice_brew_mat.tres), modes 0 pool / 1 spill. 10 live tunables (ChaliceSpillLook.*,ChaliceBrewPoolLook.*). Prewarmed in family 12 and the half-res list. No sim change. - Enemy aura rings (gfx loop 2026-10-07,
VisualLook.aura_rings_v2, Debug Settings “Enemy Aura Rings (A/B)”, default ON).EnemyAuraLook(fx/enemy_aura_look.gd, main-owned; main callssync_if_changedevery frame, which rebuilds viasync_from_simonly whensim.run_timeadvanced, the sim instance changed, orPAD/WIDTH/GAIN/RINGS_MAX/the toggle moved) makes weapon-APPLIED auras visible (vfx libraryelemental_aura): anElementPalette.color_forring just outside each afflicted enemy, a bright arc draining clockwise withaura_remaining / aura_decay_sover a faint socket, one extra concentric ring per stack up toRINGS_MAX(default 6 =ElementalSystem.STACKS_MAX; since the Rift Bolt detonation, 2026-10-12, every weapon hit accumulates a stack, so the ring count is the player’s read of the next detonation’s size). Innate base auras (aura_remaining >= BASE_AURA_PERSIST) are not ringed. The whole swarm is ONE additivedraw_multiline_colorscall (16 segments per ring,CAP400 enemies); headless sync measured ~0.64 ms/frame at 300 afflicted enemies on the M5. Perf rewrite (2026-10-07, look-neutral): each ring is one C++Transform2D(scale, origin) * _seg_unit(the unit ring as pre-doubled segment endpoint pairs) +append_array, and its colour run is a cachedPackedColorArrayper (element, lit count), instead of 2x16 GDScript appends;test_geometry_matches_reference_ringspins the output to the old per-segment formula. Scratch bench (same machine, back-to-back, best of 5x60 frames): 1 stack 100/300/400 afflicted 330-390/840-1120/1070-1170 us -> 145/430-450/650-690 us; 3 stacks 400 afflicted ~2.65 ms -> 1.1-1.3 ms. Roughly 0.75 us/enemy of the remainder is the GDScript scan over every enemy. Not measured: the canvas renderer’s CPU tessellation of the width-2 multiline (unchanged, not visible headless) and any ATV number; telemetrysampleshas no afflicted-count column, so 272/273 vs 271 render_ms cannot attribute a change to the rings (the 100-299 enemy buckets on 273 hold 48 samples). Pure read ofsim.enemies. Live tunablesEnemyAuraLook.PAD/WIDTH/GAIN/RINGS_MAX. Bench:tools/weapon_vfx/aura_bench.tscn; the vfx libraryelemental_auracard draws the real look on three swarmer-sized enemies at 1/3/6 stacks. Pinned bytests/test_enemy_aura_look.gd. Density: a sharedMarkerBudget(fx/marker_budget.gd) keeps the full ring on theNEAR_Nafflicted enemies nearest a pilot and crossfades the rest to a faint draining cap tick atFAR_ALPHA(thinned in clumps); live tunablesEnemyAuraLook.NEAR_N/FAR_ALPHA+MarkerBudget.*(gfx loop 2026-10-07 part 2, ported 2026-10-24). - Glass Wisp phase (gfx loop 2026-10-07 part 2,
VisualLook.wisp_phase_v2, Debug Settings “Wisp Phase Look (A/B)”, default ON).WispPhaseLook(fx/wisp_phase_look.gd, main-owned,sync_from_simevery frame) makes the Wisp’s untargetable phase visible (vfx librarywisp_phase) from the realenemies.phased/phase_timer: the body ghosts (main’s_enemy_colorstints it toward pale glass viaWispPhaseLook.tint, andArchetypeRenderer.sync’s new optionalalpha_multsarray multiplies the body + glow-layer instance alpha down toBODY_ALPHA); the ghost is a pure function of the timer, ramping in overPHASE_IN_Sand back out over the lastFADE_OUT_S, so it is fully solid exactly when the sim makes it hittable. A spinning dashed glass outline’s lit dashes count downphase_timer / PHASE_DURATION(time to solid), with a faint cyan/magenta split rim, an expanding shatter ping on phase-out and a contracting snap ring on re-solidify (the one piece of render-side state: entity_id -> re-solid time). One additivedraw_multiline_colorscall;alpha_mults()is empty (renderer fast path) when nothing is ghosted. Live tunablesWispPhaseLook.PHASE_IN_S/FADE_OUT_S/BODY_ALPHA/PAD/WIDTH/GAIN/SPIN/SNAP_S/PING_S. Bench:tools/weapon_vfx/wisp_bench.tscn. Pinned bytests/test_wisp_phase_look.gd. Note: the headless GUT RenderingServer stores no MultiMesh instance colours, so the alpha path is only checkable windowed (the bench). - Enemy bolts v2 (gfx loop 2026-10-07 part 2,
VisualLook.enemy_bolts_v2, Debug Settings “Enemy Bolts Look (A/B)”, default ON).EnemyBoltRenderer(fx/enemy_bolt_renderer.gd+fx/enemy_bolt.gdshader, aWeaponFxLayer: one MultiMesh, one draw call) replacesenemy_proj_renderer(the old 3-MultiMeshSwarmRenderer15 x 5 dash, kept as the A/B off side) for every enemy shot main does not hand to WardenFx (tint a = 0). One hostile language: a white-hot core in a hostile magenta-red body (HOSTILE, a shader const no friendly shot or element uses) cut out by a DARK contour, which works because the shader isblend_premul_alpha(glow terms add with alpha 0, the contour writes alpha with rgb ~ 0) while every friendly look isblend_addand can never darken; a teardrop tail along travel (iq uneven capsule, lengthspeed x TAIL_SclampedTAIL_MIN..TAIL_MAX); a white-hot spawn flash cooling overFLASH_S(full size from the first frame); a muzzle bloom at the shooter forMUZZLE_S, placed atpos - vel x agefrom the render-readEnemyProjPool.agecolumn (Sim._move_enemy_proj adds each shot’s realproj_dt; not instate_checksum). Honest size:Sim._check_pilot_hittests EVERY enemy shot atSim.SHOOTER_PROJ_RADIUS, not the pool’s radius column, so the body radius ismax(SHOOTER_PROJ_RADIUS, radius[i]) x BODY_SCALEwithBODY_SCALEclamped >= 1 (the old dash drew ~3 px half-width against the 6 px hit radius). Families only where the sim behaves differently:is_homing == 1= torpedo + flickering exhaust; an on-hit effect (EliteKit burn / slow / jolt, plus the Fleet Kite’s built-in slow) = a spinning dashed ring in the effect colour. The source enemy’s tint survives only in the outer glow (SOURCE_MIX). The bolt pass writes the buffer inline from the unit travel direction (no angle/cos/sin): 500 shots (theENEMY_PROJ_CAP) sync in ~0.3-0.5 ms vs ~0.5-0.9 ms for the old renderer on an M5; GPU cost for both is below wall-frame noise there even at 20k shots (tools/weapon_vfx/enemy_bolt_perf.tscn), and each bolt quad is ~1,050 px^2 in one pass vs ~470 px^2 over three for the old dash. Live tunablesEnemyBoltRenderer.GAIN/BODY_SCALE/HALO/CUT/TAIL_S/TAIL_MIN/TAIL_MAX/FLASH_S/MUZZLE_S/MUZZLE_R/SOURCE_MIX. Benches:tools/weapon_vfx/enemy_bolt_bench.tscn(real Shooter / Fleet Kite fire, hit-radius rings) andenemy_bolt_ingame.tscn(real main.tscn, dense ranged horde,ENEMY_BOLTS=0|1). Pinned bytests/test_enemy_bolt_look.gd. Not on the QualityManager halo ladder (one pass already). - Thunder Wake strike (gfx loop 2026-10-07 part 2,
VisualLook.thunder_wake_v2, Debug Settings “Thunder Wake Look (A/B)”, default ON).ThunderStrikeLook(fx/weapons/thunder_strike_look.gd+.gdshader, FxManager event look onthunder_wake_strike, V2_LOOKS"thunder_wake", essential, half-res hosted) replaces the legacy Pulse-primitive zigzag from the ship + generic ring. Per strike up to three instances in one draw: STRIKE (a quad fromHEIGHTabove the impact to radius +PADbelow, rotated byLEANtoward the pilot’s side; the shader draws a two-octave hashed jagged channel that draws itself down overDROP_S, power-scaled branches, a re-strike flicker, an impact flash, a shock ring easing out to land on the event’s REALradius, and ground arcs that crawl to it and fade before the puddle’s own arcs take over), LINK (a faint pilot -> strike filament,LINK_GAIN, 0 hides it) and CHAIN (Storm Front volleys: the sim’s event now carriesvolley+chain_from, render channel only). custom.x packs power (capped atPOWER_ENC0.9) + 2evolved + 4mode so it survives gl_compatibility’s half-float storage. Gotcha hit building it: the QuadMesh UV.y runs bottom-up in these 2D quads, so a look that is not symmetric in y must flipp.y. The puddle is unchanged (render/weapon_zone_field.gdshader). Live tunablesThunderStrikeLook.HEIGHT/LIFE/DROP_S/WIDTH/LEAN/GAIN/LINK_LIFE/LINK_GAIN. Benchtools/weapon_vfx/thunder_wake_bench.tscn(real sim + FxManager + ZoneRenderer; old / fresh / invested / evolved volley / 2x). Pinned bytests/test_thunder_wake_fx_v2.gd. - Wildfire (gfx loop 2026-10-07 part 2,
VisualLook.wildfire_v2, Debug Settings “Wildfire Look (A/B)”, default ON). Two layers sharingfx/weapons/wildfire.gdshader(warm fire ramp red -> orange -> amber -> pale gold, never white).WildfireLook(FxManager event look, V2_LOOKS"wildfire") draws POP (fbm fireball, flame rim easing out to land on the event’s REAL hopradius, licks + embers, heat from the chaindepththe sim now stamps onignite/wildfire_pop, a vortex whenfirestormis set: evolved and depth >=FIRESTORM_CHAIN_LEN), IGNITE (catch flare) and HOP (a flame comet from the detonation that lit the enemy, from the ignite event’s newfrom). Crowd dimming: a decaying load counter scales COLOR (CROWD_N), so a 60-pop chain stays bounded instead of summing to white; per-frame pop cap 16.WildfireFuseLook(main-hosted, synced each frame fromenemies.ignite_fuse_timer) draws a fuse ring per fused enemy whose spark burns clockwise at the real fuse progress, MarkerBudget nearest-N + clump thinning. custom.x = power(<=0.9) + 2evolved + 4mode (half-float safe). Sim change is render-channel only (fx_event fields,_chain_depthnow recorded for every ignite and cleared when no enemy is fused); baselines unchanged. Live tunablesWildfireLook.LIFE/IGNITE_LIFE/HOP_LIFE/GAIN/HEAT_STEP/CROWD_N,WildfireFuseLook.PAD/GAIN/NEAR_N/FAR_ALPHA. Benchtools/weapon_vfx/wildfire_bench.tscn. Pinned bytests/test_wildfire_fx_v2.gd. - Chronowell (gfx loop 2026-10-07 part 2,
VisualLook.chronowell_v2, Debug Settings “Chronowell Look (A/B)”, default ON). A clock under glass (aqua glass, brass hand, hottest = pale aqua), deliberately unlike the purple Disruptor field. One shaderfx/weapons/chronowell.gdshader, five modes.ChronowellDeployLook(FxManager event look onchronowell_deploy, V2_LOOKS"chronowell", mode DEPLOY): a glass front closing in fromDEPLOY_OVERx R and locking onto the event’s REALradiusatINFLATE_Swhile a hand winds one turn.ChronowellBubbleLook(main-hosted, z -1, synced every frame fromSim.chronowell_zones, one draw call): BUBBLE per area zone – rim ON R from the first frame (the sim slows the whole disc from the deploy tick, so an inflating rim would understate it; the face fades in overINFLATE_S), 12 hour + 60 minute ticks, a brass hand sweeping once over the zone’s REALdurationwith a bright arc + brighter rim for the time still to run, an inner gear turning at the zone’s REALslow_multwith frost density from it, a mint ring whenheal > 0, a counter-turning ring when evolved; full strength until the lastEXPIRE_S(cracks + rim blink; the legacy disc faded linearly from deploy). FREEZE (a zone withslow_mult<= 0.01, the evolved Stopwatch): crystal body, crown, stopped hand. DIAL on every enemy inside a drawn bubble withslow_timer > 0(a ring whose tick steps atDroneDirector.DISRUPTOR_SLOW_MULT, the movement slow the sim really applies; MarkerBudget nearest-N). SMEAR on every enemy bolt inside a bubble (afterimages along-vel, longer for a deeperslow_mult, which is whatSim._move_enemy_projreally applies). Owner power/evolved/rungs from each zone’s ownpilot. Main setsZoneRenderer.chronowell_legacy = falsewhile it draws. No sim change. Live tunablesChronowellDeployLook.INFLATE_S/GAIN,ChronowellBubbleLook.GAIN/FILL/RIM_W/FROST/EXPIRE_S/MARK_GAIN/MARK_NEAR_N/MARK_FAR_ALPHA/SMEAR_LEN. Benchtools/weapon_vfx/chronowell_bench.tscn. Pinned bytests/test_chronowell_fx_v2.gd. - Elite threat (gfx loop 2026-10-07 part 2,
VisualLook.elite_v2, Debug Settings “Elite Look (A/B)”, default ON).EliteThreatLook(main-hosted above the swarm, under the name bars, synced every frame from the enemy pool +Sim.elite_kit, one draw call,fx/elite_threat.gdshader): a hostile crown on the elite’s REAL radius (dark contour + 6 turning spikes), its two REALEliteKit.attacks_for/forcedattacks as per-attack glyphs in the attack’s element colour (web/warp use EliteFxRenderer’s colours), an arc filling with the REALcooldownoverEliteThreatLook.cooldown_total()(pinned to the kit’s real refire) that flares over the lastWIND_S, dim dashed whilesuppress_timer > 0or in the tutorial, and in the dive half ofEliteKit.diving()(the pure helperprowl_diragrees with) the pilot-facing spikes stretch. Kitless elites (Tackler, Blight) get the crown only. Live tunablesEliteThreatLook.*. Pinned bytests/test_elite_threat_fx.gd. - Dimension hazards (gfx loop 2026-10-07 part 2,
VisualLook.dimension_hazards_v2, Debug Settings “Dimension Hazards Look (A/B)”, default ON).DimensionHazardLook(main-hosted, z -1, synced every frame fromSim.dimension_hazards, one draw call,fx/dimension_hazard.gdshader, 7 modes): BLOOD slow zones rimmed on the real radius (inside flag from the real pilot positions), POISON clouds on the real current radius at full strength until the lastEXPIRE_Sof the realremainingplus a dashed ring atbase_radius * POISON_ZONE_GROWTH_MULT, LASER quads centred onorigin + dir * LASER_RANGE/2with the realLASER_WIDTHlane, warn fill closing on the realtimer / LASER_WARN_S, fire fading overLASER_ACTIVE_S; FIRE body + orbit band (offset +- orbiter radius) + per-orbiter fireballs, ICE/VOID on the real hitbox. Main setsDimensionHazardRenderer.legacy = not VisualLook.dimension_hazards_v2. Live tunablesDimensionHazardLook.*. Pinned bytests/test_dimension_hazard_fx.gd. - Ghost eye + Void flicker (gfx loop 2026-10-07 part 2,
VisualLook.ghost_eye_v2/void_flicker_v2, Debug Settings “Ghost Eye Look (A/B)” / “Void Flicker Look (A/B)”, default ON; both Void-Dimension-only in shipped play).GhostEyeLook(main-hosted, z 1, synced every frame from the enemy pool, one draw call,fx/ghost_eye.gdshader): per real Ghost a lidded slit-pupil eye whose state isGhostEyeLook.state_forof the Ghost’s REALdash_phase/dash_timer(drift: opens + starts to glare overGHOST_DRIFT_S, pupil on the pilot; telegraph: wide, glaring hostile, pupil on the real endpoint, same frac asGhostTelegraphRenderer; strike: streak =GHOST_STRIKE_SPEED * TRAIL_Salong the real heading).VoidFlickerLook(main-hosted, z 3, one view-covering quad on the live P1,fx/void_flicker.gdshader): started by thephase_flickerrender event vianote_events, clocked onsim.run_time, darkens outside a ragged clear circle round every pilot (pilotsuniform, up to 4) with theenvelope()close-in / two stutters / lift. FxManager’s old dot / spark step aside under each flag (alegacyevent key keeps the vfx library card on the old look). TunablesGhostEyeLook.*,VoidFlickerLook.*; both prewarmed. - Damage numbers + weapon pickup (gfx loop 2026-10-07 part 2,
VisualLook.damage_numbers_v2/weapon_pickup_v2, Debug Settings “Damage Numbers Look (A/B)” / “Weapon Pickup Look (A/B)”, default ON).DamageNumbers._consume_v2: one 40 px font for every label, the per-hit size rides on the scale (no theme-override churn); size + tint fromsize_t(peak hit, rolling typical hit)on a log scale (SPANx typical = top); placement lifted out ofkeep_clear_zones(sim)(live pilots atPILOT_CLEAR, Ghost bodies, Ghost endpoints at the GhostTelegraphRenderer radius,sim.bombs) bylift_clear; a clump keepsCELL_MAXnumbers in an ellipse ofCELL_R(horizontal at 0.55x), further hits roll into the nearest. MarkerBudget is not used: it favours detail nearest the pilot, where numbers must yield.WeaponPickupRendererv2: additive beacon (column, real collect ring =Sim.WEAPON_PICKUP_RADIUS+ pilot radius, display name from content) plus a collection burst on thereactionevent that now carries render-channelweapon_pickup(wid) +pilot; text on a normal-blend child with a dark outline (the arena grade re-saturates coloured text, so text is white/lightened). FxManager and GpuBurst skip that event viaWeaponPickupRenderer.owns_event. TunablesDamageNumbers.*/WeaponPickupRenderer.*; pinned bytests/test_dmgnum_pickup_fx.gd. - Tackle / stasis web / Blight aura (gfx loop 2026-10-07 part 2,
VisualLook.tackle_v2/stasis_web_v2/blight_v2, Debug Settings “Interceptor Tackle Look (A/B)” / “Stasis Web Look (A/B)” / “Blight Aura Look (A/B)”, default ON; all three are DEV content behind DevFlags, forced off in store builds).WebLinkLook(fx/web_link_look.gd+fx/web_link.gdshader, main-hosted, synced every frame, two draw calls: the layer at z 2 plus agroundchild at z -1 for the aura disc) gives the family one grammar: HOSTILE (an enemy acting on you) = magenta-red strands with barbs and a premul dark contour; FRIENDLY (your web on an enemy) = additive white-cyan glass silk with knots and a countdown arc.plan(sim)returns plain entries (tests read them;sync_from_simputs exactly them) from real state only:Sim.tackler.links(lock frac, strain = d /TACKLE_RANGE),PlayerState.tackle_timer(net lifetime + loosening overHOLD_S),EnemyPool.stasis_timer(net lifetime + arc), Web drones’target_eid/webbing,stasis_webrender events (shotCAST_FLY_Sthen snapCAST_SNAP_S), andSuppressorRenderer.auras/pilot_marks. The old renderers step aside per flag. Gotcha fixed here: a child layer of a WeaponFxLayer is hidden whenever the parent commits 0 instances, sosync_from_simkeeps the parent visible whilegroundhas items. TunablesWebLinkLook.*(10, live); prewarmed. - Interceptor chain (gfx loop 2026-10-07 part 2,
VisualLook.interceptor_chain_v2, Debug Settings “Interceptor Chain Look (A/B)”, default ON).InterceptorChainLook(fx/interceptor_chain_look.gd+.gdshader, FxManager-owned event look under the runtime keyInterceptorChainLook.WID="interceptor_chain", NOT inV2_LOOKSbecause that map is weapon-keyed) draws everychainhop of the Interceptor drone’s Arc Chaining (DroneDirector.drone_chain_strike; the only emitter of thechainkind). The sim event now carriesfrom(the drone for hop 0, then the previous struck enemy),hopandfrac(this hop’s real damage / the first hop’s,INTERCEPTOR_CHAIN_FALLOFFper hop), render channel only. Per hop: an ARC (a taut gold cord bowed byBOW, a bead zipping down it overZIP_S, two strands humming round the core while it decays; smooth on purpose, Thunder Wake owns jagged lightning) and a NODE (a three-prong clamp snapping shut, sized by frac). Hops are staggeredHOP_DELAYapart (the sim lands them in one tick; the stagger is the one liberty). custom.x = mode2 + frac0.9 (half-float safe), custom.y = the delay fraction. Still disposable juice (enabled-gated, juice budget). Live tunablesInterceptorChainLook.LIFE/HOP_DELAY/ZIP_S/WIDTH/GAIN/BOW/NODE_R. Benchtools/weapon_vfx/chain_bench.tscn(real deploy_drones -> update_drones -> drone_chain_strike + FxManager). Pinned bytests/test_interceptor_chain_fx_v2.gd. The Interceptor’s own strike is drawn by the same look’s STRIKE mode (next bullet). - Interceptor strike (gfx loop 2026-10-07 part 2,
VisualLook.interceptor_strike_v2, Debug Settings “Interceptor Strike Look (A/B)”, default ON, its own row so the chain and the strike A/B separately).DroneDirector.drone_interceptornow appends a render-channeldrone_strikeevent (pos,radius= the realINTERCEPTOR_RADIUSx Area,hits= enemies really damaged,element) after the strike and BEFORE the chain hops; no rng, no state, not in the checksum. Drawn byInterceptorChainLook.spawn_strikeas mode 2 (STRIKE) of the same shader: a gold rim ON R from the first frame, three clamp chevrons closing from 1.18R onto R, a faint disc flash, all x strength (1 on a catch,STRIKE_EMPTYon an empty strike). Same draw call and same CAP 32 as the chain (the shared density budget);STRIKE_LIFE0.12 s < the 0.25 s cadence so rings never stack. Undrawable strikes (switch off, v1, L5) are skipped before the juice budget is spent. Crits are not shown. Live tunablesInterceptorChainLook.STRIKE_LIFE/STRIKE_GAIN/STRIKE_W/STRIKE_EMPTY. Pinned bytests/test_interceptor_strike_fx_v2.gd. - Drone pulse (gfx loop 2026-10-07 part 2,
VisualLook.drone_pulse_v2, Debug Settings “Drone Pulse Look (A/B)”, default ON).DronePulseLook(fx/drone_pulse_look.gd+drone_pulse.gdshader, FxManager event look under"drone_pulse", CAP 24). Thedrone_pulsesim event now carriessrc(drone class),radiusandhits; Logistics alsoto(pilot pos) andhealed(render channel only). Mode 0 DECOY (src sentinel, the default drone, reachable on a fresh save): a cyan front eases out to R overEXPAND_Swhile a thin double rim sits on R from frame one (the sim hits the whole disc on the pulse tick). Mode 1 LOGI: a mint ring on the real drone-repair radius with four riding ‘+’ marks and a ‘+’ at the drone. Mode 2 THREAD (only when it really healed the pilot): a mint thread with a bead drone -> pilot. Disruptor pulses are left toDisruptorFieldLook(skipped pre-budget when that look is on; legacy spark otherwise). Live tunablesDronePulseLook.LIFE/EXPAND_S/GAIN/RIM_W/EMPTY/LOGI_LIFE/LOGI_GAIN. Benchestools/weapon_vfx/drone_fx_bench.tscn(real deploy_drones -> update_drones + FxManager, dashed reference circle at the real radius) anddrone_ingame.tscn(real main.tscn with one drone deployed). Pinned bytests/test_drone_pulse_fx_v2.gd. - Enemy blasts (gfx loop 2026-10-07 part 2,
VisualLook.enemy_blasts_v2, Debug Settings “Enemy Blasts Look (A/B)”, default ON).HostileBlastLook(fx/hostile_blast_look.gd+hostile_blast.gdshader, FxManager event look under"hostile_blast", CAP 16,blend_premul_alphasoFxHalfResHostnever adopts it). Tworeactionsources now carry a render-channelsrcand their real geometry, and are drawn here instead of the generic spark + fixed 120 px ring (the kind is unchanged, so audio / haptics / telemetry are too):src bomb_blast(EnemyAttacks.update_bombs, everySim.bombsdetonation:radius= the bomb’s real radius,hit= it hurt a pilot,tint= a hazard bomb’s owncolor_ring) as mode 0 BLAST, on the same centre and circleBombRenderertelegraphed, handing over from its detonation red to the enemy-bolt hostile magenta-red: a faint front lands on R, the disc flares and scorches dark with embers, the rim lingers with the dark hostile contour;src tank_launch(EnemyAttacks.update_tank_fire:angle,count,spread, bodyradius) as mode 1 MUZZLE, a flare out of the hull along each missile’s heading + backblast + recoil ring. Routed inFxManager.consumeBEFORE the shared juice budget (a blast that hurt you is gameplay feedback);GpuBurstskips its debris andHeroLightsflashes hostile for routed events. Live tunablesHostileBlastLook.LIFE/GAIN/RIM_W/CUT/MISS/MUZZLE_LIFE/MUZZLE_LEN/MUZZLE_GAIN. In-game capturetools/weapon_vfx/hostile_ingame.tscn(MODE=bomb|tank). Pinned bytests/test_hostile_blast_fx_v2.gd(realSim.tickfor both sources). The same flag owns the bomb TELEGRAPH:BombRenderer.plan(bm)(pure, tested) puts the rim ONbm.radius(the circle the blast lands on), shows the real fuse left (bm.delay) as one segment perTICK_Sburning out clockwise from 12 o’clock, ramps urgency over the lastURGENT_S(thicker rim pulsing atPULSE_HZ, colour lerping toHOT, which must equalhostile_blast.gdshader’sTELEhandover const: a test parses it), and a fill front that reaches the rim at detonation; a_Contourchild (normal blend, behind) draws the ground shadow + dark ring under the rim. A bomb with its owncolor_ring(lightning hazard) keeps it. Off = the old flat orange. Live tunablesBombRenderer.GAIN/RIM_W/FUSE_W/FILL/SHADOW/CUT/TICK_S/URGENT_S/PULSE_HZ; capturehostile_ingame.tscn(MODE=bomb FUSE_AT=...); pinned bytests/test_bomb_telegraph_v2.gd. - Warden attack starts (gfx loop 2026-10-07 part 2, under
VisualLook.warden_v2, no own row). Fivereactionevents insim/boss_warden.gdcarry a render-channelsrc(warden_barragex2 rings,warden_spiral,warden_lunge,warden_dash) plusangleandcount/speedorreach;WardenFx.owns_start(ev)routes them toWardenLaunchLook(fx/boss/warden_launch_look.gd+warden_launch.gdshader, CAP 16, blend_add): RADIAL muzzle rays + shot-riding streaks on every real spoke (ring 2 offset PI/count), THRUST chevrons + backblast down the latched lunge lane / dash heading. FxManager, GpuBurst and HeroLights skip their generic burst for owned starts. Live tunablesWardenLaunchLook.LIFE/THRUST_LIFE/GAIN/WIDTH/RAY/THRUST_LEN/DASH; capturetools/weapon_vfx/warden_starts.tscn; pinned bytests/test_warden_starts_fx.gd; prewarmed in the WardenFx family ofrender/prewarm_pass.gd. - Disruptor field (gfx loop 2026-10-07 part 2,
VisualLook.disruptor_field_v2, Debug Settings “Disruptor Field Look (A/B)”, default ON).DisruptorFieldLook(fx/disruptor_field_look.gd, main-owned, z -1 under the swarm,sync_from_simevery frame) draws the Disruptor drone’s field (vfx librarydisruptor_field) atDroneDirector.disruptor_radius(d), the same pure helperdrone_disruptorqueries with, so the drawn edge IS the gameplay edge: a bright ewar-purple ring + dim inner ring + turning inward teeth, a faint fill disc and three wobbling ripples drifting edge to centre, a sweep ring forPULSE_Safter each real field pulse (DISRUPTOR_PULSE_S - pulse_timer), a deploy shock +SPAWN_Sfade-in, a blink + fade over the lastEXPIRE_Sofd.life, and a collapse ring when the drone leavessim.drones. Enemies inside a drawn radius withslow_timer > 0get corner lock brackets (hot pink whenweaken_timer/weaken_amt> 0, a mute bar whensuppress_timer > 0;MARK_CAP300). Render-side state is only drone instance id -> first-seen time + last pos/radius. One additive fill polygon per field + onedraw_multiline_colors. Live tunablesDisruptorFieldLook.GAIN/WIDTH/FILL/TEETH_LEN/SPIN/WOBBLE/RIPPLE_S/PULSE_S/SPAWN_S/EXPIRE_S/COLLAPSE_S/MARK_PAD/MARK_GAIN. Bench:tools/weapon_vfx/disruptor_bench.tscn. Pinned bytests/test_disruptor_field_look.gd. - Rift Bolt detonation (2026-10-12).
ElementalSystem.detonateemitsrift_detonate {pos, radius, element, stacks}; FxManager (ESSENTIAL, own capRIFT_DETONATE_CAP8 against the shared ring pool) draws an element ring at the splash radius plus a lightened core-flash sprite whose start scale isRIFT_FLASH_BASE + RIFT_FLASH_PER_STACK * stacks(live tunables, withRIFT_FLASH_S). Sound:scatter_explosionre-pitched (AudioManager.RIFT_DETONATE_SFX_DB/_PITCH), not aWEAPON_FIRE_KINDScue. VFX library cardrift_detonate(score 2: FxManager primitives). Tests:tests/test_fx_manager.gd,tests/test_audio_coverage.gd. - Perf (
docs/research/2026-09-29-weapon-fx-v2-batch1-perf.md). All five looks on screen, evolved, M5: v2 is 1.117x v1 GPU (+0.23 ms), almost entirely the orbit look. The criterion was <= 1.10, so it FAILED. Render-threadcpu_msshows no difference, but main-thread CPU is not a pass: the vsync-bound frame times cannot show it, and a direct microbenchmark puts the v2 bullet sync (PlayerBoltRenderer.sync_bolts) at ~1.5x legacy, +0.5 ms/frame at 1500 bullets on the M5. Bolt GPU and CPU cost at scale was not loaded by the perf scene (0.56 live bolts). Draws are not resolvable by the method, and Apple TV is unmeasured. Accept-vs-cut is pending Chris (plan Task 16). - Perf-measurement pitfalls (learned the hard way, fx-v2 Task 14): (1) separate launches are NOT comparable even on one seed, because the sim advances on wall-clock time (the same seed gave 22-72 draws across identical v1 launches), so compare inside one run with
--abABBA. (2) Level-up panels freeze one arm of a pair, and a frozen arm renders less and reads “cheaper”. This once manufactured a false pass (1.092 vs the honest 1.117), so reject any pair where either arm stalled, and print the rejection rule before sampling. (3) Prove each look under test was actually live during the sample (fired/livecounts per pair), because hold-fire weapons draw nothing with no target in range (use--targets). (4) Three launches on one seed are near-copies, so count independent pairs, not samples. (5) vsync-bound frame times (8.33 ms) hide main-thread cost below budget, so microbenchmark the CPU path directly. (6) Revert any “cost cut” that shows no measured saving; one changed the Event Horizon look for nothing. - perf_probe A/B (
tools/perf_probe/perf_probe.tscn, flags after--):--weapons=a,b,c(activate these wids at level 5),--evolve,--v2=0|1(VisualLook.weapon_fx_v2),--level=N(pin QualityManager tier N, adaptive off),--seed=N(seeds the global RNG so the scene repeats),--ab=N(after the normal sample, toggle v2 off/on N times in ONE run, ABBA order; rejects unclean arms;--ab-gap=Splays S seconds between pairs),--targets=N(N stationary, effectively unkillable dev targets so hold-fire weapons fire throughout; sim code unchanged),--hide-v2=a,b(cost attribution: zero the draw of those looks), plus--no-breakdown. GPU time needs--rendering-driver vulkan(Metal reports 0). Example:godot --path . --rendering-method mobile res://tools/perf_probe/perf_probe.tscn -- --mode=survival --weapons=blaster,orbit,blade,scythe,nova --evolve --v2=1 --level=0 --seed=1234 --no-breakdown. - Shader noise must survive a long session (2026-10-02): shader
TIMEgrows for the whole app session (to the 3600 s rollover), so any noise driven by it is judged in a fresh capture but SHIPPED at TIME ~1000+.fx/weapons/fx_noise.gdshaderincwraps everyhash21/vnoiseinput intoNOISE_PERIOD(128) for this reason; before that the float32 hash collapsed to ~4 values at 30 min (the orbit’s lightning crackle vanished, swirls went blocky). New noise helpers must do the same, andtools/weapon_vfx/orbit_drift.tscnshows how to preview a look at a TIME offset (inline the includes, rewriteTIME->(TIME + offset); a#define TIMEwrapper does not compile).render/shaders/arena_border.gdshaderandnebula_bg.gdshadercarry their own unwrappedhash21copies (unchecked). - Orbit Shards Event Horizon A/B:
VisualLook.orbit_comets(default ON; Debug Settings / Remote Console “Orbit Comets (A/B)”) ->OrbitShardsLook.push_comets()-> the shader’scomet_ehuniform (a uniform branch: same shader + material, no new pipeline). On = comet orbs with flat-topped tapering tails (COMET_*consts), lens band as faint broken arcs; off = the classic singularity ring. - Event Horizon warp + film tint (2026-10-07):
VisualLook.orbit_warp(default ON; Debug Settings / Remote Console “Event Horizon Warp (A/B)”) is the render consumer of the sim’s space-time warp:OrbitShardsLookputs one LENS instance (custom.w = -2, half-size = the sim’sWeaponOrbit.WARP_LENS_RADIUS) on every evolved shard andWARP_EDDIESEDDY instances (-3) at 0.55 of the orbit radius, after the shards in the same MultiMesh (same shader + material, so no new pipeline / prewarm family). A fake lens, no screen read: an Einstein ring ineh_corewith a cool fringe inside and ripples falling inward; eddies are a faint two-arm log spiral. Lens/eddies are only drawn while the matching sim strengths are non-zero (warp_parts()). Every former void-violet term in the evolved shader now uses theeh_coreuniform (OrbitShardsLook.EH_CORE, blood-red gravity-drive core, a nod to the 1997 film), and evolved tints lean toward it byEH_TINT_MIX. Trail loop bound 6 -> 7 (MAX_SHARDS). Cost (look_bench--set=roster --only="orbit evo", M5 MoltenVK, k=32, 5-round medians, GPU ms per evolved ring per frame): 7 shards with warp 0.126, without 0.101 (+0.025 ms, ~+25%). Note the olderorbit evo x6/lens-liteconfigs now include the warp (it is the shipped default). Apple TV unmeasured. Compile-checked windowed undermobileandgl_compatibility. Pinned bytests/test_event_horizon.gd.
Plasma Nova burst (2026-09-27) – the v1 / L3+ nova
Section titled “Plasma Nova burst (2026-09-27) – the v1 / L3+ nova”Switch: VisualLook.plasma_nova (default true; false = the legacy _RingNode arc ring). Dev toggle: Debug Settings → “Plasma Nova (A/B)”; read at spawn, so a flip shows on the next pulse. Only the Nova weapon’s own nova fx_event switches; burst/reaction/level-up rings keep _RingNode.
PlasmaNovaNode(fx/weapons/plasma_nova_node.gd; pooled byFxManager, poolNOVA_POOL_SIZE=6, essential feedback, never quality-gated): onedraw_texture_rectquad of half-sizeradius × 1.22throughfx/weapons/plasma_nova.gdshader(value noisehash21/vnoise/fbmcomes from the sharedfx/weapons/fx_noise.gdshaderinc), plus aPlasmaNovaNode.Emberschild drawing 14 softGlowTextureembers (28 small quads, closed-form positions from progress, no per-ember state).- Per-burst data rides in the draw colour, not uniforms:
COLOR.a= progress,COLOR.rgb= element tint. So every burst shares ONEShaderMaterialand there are no per-node uniform writes. Life0.46 s, each burst gets a random render-side rotation (FxManager._fx_rng, never sim rng) so the noise isn’t identical every pulse. - Shader: a white-hot core flash that burns out by ~45% of life; a thin shell at a noise-displaced front (
1-(1-t)^2.4) decaying inward so the interior clears fast and enemies under it stay readable; streaky outward flame tongues past the front; a heat ramp white→yellow→orange→magenta→violet (the violet end is 30% element-tinted). Only the hot parts go above 1.0, so the existing HDR bloom halos the fire without an extra pass. - Cost: fragment-only. Per covered pixel: 3× 3-octave value-noise fbm (12 hash-lerps each), 1
exp, 1pow. Pixels outside the disc are discarded. Base radius 180 → ~440 px quad (~0.19 MP incl. discard region); evolved Supernova (×1.8) → ~790 px (~0.62 MP, ~30% of a 1080p frame) for 0.46 s once per cooldown. There’s no full-screen pass and no screen read. Not yet measured on the Apple TV. - Harnesses:
tools/nova_vfx/compare.tscn(isolated legacy vs plasma at 4 points in the burst, realFxManager, main.gd’s HDR env, fixed-size SubViewport) andtools/nova_vfx/ingame.tscn(PLASMA=0|1, boots real main with a horde and captures a burst mid-expansion). Both are windowed; rungit checkout -- project.godotafterwards.
Web Look v2 (2026-10-01, candidates; spec docs/superpowers/specs/2026-10-01-web-v2-design.md Part 2)
Section titled “Web Look v2 (2026-10-01, candidates; spec docs/superpowers/specs/2026-10-01-web-v2-design.md Part 2)”Chris asked for “a more exciting looking web effect”. The spider-web layer (ZoneRenderer._web_layer,
one MultiMesh, one draw call) now draws with one of four saved materials picked by
VisualLook.web_look (WEB_LOOK_NAMES = v1 | A | B | C, default v1 until Chris picks):
- v1
render/web_field.gdshader— unchanged. - A Dewy silk
render/web_look_a.gdshader— orb webs: 9 spokes per hub, scalloped (catenary-sag) capture threads that step 1/9 ring per sector so they form a spiral, a frame thread on the capsule edge, anchor threads between hubs, and dew drops (~28% of thread cells) that glint in a sweep at1.0 so the HDR bloom haloes them.
- B Venom pulse
render/web_look_b.gdshader— violet wavy spokes + wobbling rings with acid-green pulses running along them. Aplayer_posvec4 uniform (xy P1, zw P2-or-P1, world space) is set ONCE per frame by ZoneRenderer, only while B is active; each web maps it into its local frame invertex()fromMODEL_MATRIX(which includes the MultiMesh instance transform on both Mobile and gl_compatibility — verified in the harness), so the web a pilot stands in ripples/brightens. - C Snare lattice
render/web_look_c.gdshader— neon hex mesh in the capsule’s own frame, a magenta rim whose hashed ~7 px segments crackle white; over the last 35% of life (fade channel) the crackle gets faster (12→30 Hz), denser and brighter, then the web snaps off.
Shared contract: same INSTANCE_CUSTOM layout. custom.a PACKS two values (ZoneRenderer.web_custom_a):
a = 0.5 * trail_flag + 0.48 * seed. The flag marks a stretched passive trail capsule (Web v2 Part 1);
v1 reads it as step(0.5, a) and drops its centre strand, and ignores the seed. The seed is
web_seed(web_anchor(...)), a fraction in [0,1). The 0.48 scale leaves a margin under 0.5 because
gl_compat stores custom data as half floats, which round 0.49999 up to 0.5. The packing round-trips
through encode_half in tests. The candidates decode trail = step(0.5, a),
seed = (a - 0.5*trail) / 0.48.
Trail anchoring. ElementalSystem._extend_trail moves a trail’s pos to the new midpoint on every
stretch. It sets dir from the fixed end towards the spider, so in shader space the fixed end is always
at p.x = -v_half. For a trail, both the seed and the pattern are anchored to that fixed end:
- The seed hashes
pos - dir*half_len. It is rounded to a whole px, because recomputing the end drifts by about 1e-4 px and the hash has a slope of about 6e5. - A/B lay hubs from
p.x + v_half, sokruns from 0 tofloor(2*v_half/spacing). - C offsets its lattice, crackle cells and wave by
v_half.
The silk already spun stays put, and new hubs appear at the growing end. The fixed end only moves if a spider extends the trail from its OTHER end, which re-seeds that capsule (rare).
Circles and Elite capsules lay hubs every 1.6 × radius from the centre, so inner hubs stay put while
the capsule grows. k is clamped to the hubs that exist, ±floor(v_half/spacing + 0.5). This is
review D1: an unclamped k gave the per-hub rotation a seam at x = ±0.8r on every round web.
Perf rules: capsule distance first then discard outside, hash-only randomness (no noise), cheap approximations
(polynomial atan2, small-angle sin/cos, golden-ratio per-hub hash, hashes gated to thin bands).
Switch: Debug Settings “Web Look” cycle row → dev_command_requested {"kind":"web_look","look":L}
→ main.apply_dev_command (handled BEFORE the sim == null guard); the Remote Console gets a “Web look”
selector from caps.web_looks and reads flags["look:web"]. Live: _update_web_layer rebuilds the
material when VisualLook.web_look moves. Prewarm family 17 (ZoneRenderer.new_web_prewarm_layer)
links the ACTIVE look’s pipeline in release builds, and all four in dev builds
(PrewarmPass.web_looks_to_warm), so a mid-run A/B switch never links cold. It also closed the old
“web_field not prewarmed” gap. B’s player_pos skips downed pilots (ZoneRenderer.web_player_pos,
hp <= 0), so the web a dead co-op pilot lies in stops rippling.
Cost (tools/perf_probe/look_bench --set=web, M5 Mac, MoltenVK GPU time, 400 overlapping webs at 1080p, 1/3
capsules): v1 0.76 ms · A 1.68 ms (2.2×) · B 1.91 ms (2.5×) · C 1.36 ms (1.8×); low_detail path:
v1 0.60 · A 1.18 · B 1.53 · C 0.89. The “≤ v1 per web” goal is NOT met — v1 is ~20 ALU ops per pixel and
any real pattern costs more; first-cut shaders were 3.4×/3.7×/2.0× before the approximations above
(discard itself measured neutral). Re-measure the winner on the Apple TV (fill-bound A12) before it
becomes default. Tools: tools/web_vfx/compare.tscn (v1|A|B|C × circles / capsule trail / Elite
landing / a LIVE stretched trail walked through the real drop_webs; 3 time offsets plus
_stretch0/1 captured either side of one more stretch; output tools/web_vfx/out/, gitignored) and tools/perf_probe/look_bench.tscn -- --set=web (the shared weapon-look bench; --webs=N alone also selects it).
Liquid-glass UI (2026-09-24, v0.1 freeze exception 4)
Section titled “Liquid-glass UI (2026-09-24, v0.1 freeze exception 4)”Spec: docs/superpowers/specs/2026-09-24-liquid-glass-ui-redesign-design.md. Switch: VisualLook.glass_ui (render/visual_look.gd, default true; false = the pre-glass Nova-plate UI). Dev toggle: Debug Settings → “Liquid-glass UI”. Screens read the flag when they are built, so a flip shows on the next open: the pause menu is rebuilt on every pause, and the level-up panel is rebuilt at the start of every run in main._new_run() (LevelUpPanel vs LegacyLevelUpPanel, both extend LevelUpBase), so the level-up swap shows on the next run.
GlassPanel(ui/theme/glass_panel.gd) is the one surface component. It fills its content to the inset rect (children’s size flags are ignored); its minimum size is the content minimum plus2 * margin. The surface and its glow are an internalColorRectgrown byGLOW_PAD(18 px) on every side, so the glow never changes layout.- Frosted vs clear.
frosted = trueusesglass_frost.gdshader: it samples the screen texture at mip LOD 3.5 (blur_lod) for the blur.frosted = falseusesglass_clear.gdshader, which never reads the screen. Shared tint/rim/glow code is inglass_common.gdshaderinc. All three live inui/theme/. - Frosted only on paused screens. A screen read forces a copy of the frame; on a paused screen that cost is paid over a static frame with no swarm running. Anything shown during live play must use the clear variant (the HUD docks, planned for Plan 2; not converted yet). Keep frosted surfaces few even when paused: the pause menu frosts its one column and draws its buttons with the clear variant.
- One screen copy per frame. Godot copies the screen once, for the first screen-reading item drawn, and reuses that copy for later ones in the same frame. There is no
BackBufferCopy, so a second frosted panel blurs what was under it before any panel drew, not the first panel. That is intended. - Users (Plan 1): the level-up bottom strip (
ui/level_up_panel.gd, frosted cards), the ship-stats sheet (ui/stats_sheet.gd, frosted, full-width × 58% in level-up / centred in pause, rebuilt lazily only when opened; its weapon rows and the per-slotWeaponGlyphmarkers on the crystal rung bars are the selection ↔ threshold link, 2026-10-07), and the pause column (ui/pause_menu.gd, frosted, with a read-only stats sheet). - ⚠️ On-device frame cost on the Apple TV is not yet measured (pending after the merge; see
docs/RELEASE-0.1.mdexception 4).
Enemy facing (2026-09-24, branch enemy-facing)
Section titled “Enemy facing (2026-09-24, branch enemy-facing)”Swarm enemies (every ArchetypeRenderer instance: body core + all glow layers) turn to face their direction of travel.
- Sim side, render-only data.
EnemyPool.heading/facing(radians, swap-removed in lockstep like every other column).Sim._move_enemiessnapshots every enemy’s position before the movement pass (_facing_prev), subtracts any knockback shove as it is applied (so an enemy knocked backwards doesn’t flip round), and callsEnemyPool.update_facing()after the Wyrm chains.heading= angle of the step actually taken (steps under 0.1 px/tick keep the old heading);facingturns toward it atFACING_TURN_RATE= 11 rad/s (~630°/s), scaled by the enemy time step (frozen enemies don’t turn). The first real move snaps. Nothing in/simreads either column, no rng is drawn, and neither is instate_checksum()/snapshot_string()— both determinism baselines are untouched by construction. It lives in the sim rather than the renderer because pool slots aren’t stable identities (swap-remove), and diffing positions across RENDER frames reads zero whenever physics/render cadence isn’t 1:1. - Never-moved = upright.
FACING_UNSET(1e9) until an enemy’s first step: bosses (moved by their directors, not_move_enemies), a stationary Beacon, a Shooter still holding range all draw at the art’s authored orientation, exactly as before. - Renderer. Instance basis = smear × rotation × scale, inlined in
sync()(a per-enemy GDScript call roughly doubles the cost of that step);ArchetypeRenderer.face_transform()is the pure mirror tests use, because a headless MultiMesh reads every instance transform back as identity. Rotation angle =facing + forward offset:FACE_OFFSET_UP(+π/2) for every procedural shape and every Nova unit (all authored nose-up, -y);FACE_OFFSET_DOWN(−π/2) for the legacy spider/ghost sprites, which render nose-down in game (QuadMesh samples bottom-to-top; they were never pre-flipped like the Nova bake)._FACE_NO_ROTATEis empty (radially symmetric shapes rotate harmlessly). The warp-smear tail shader now measures the vertex direction in world space viaMODEL_MATRIX, so the fade stays on the true trailing side of a rotated body. - Checking orientation:
GALLERY_FACING=0 RUN_TAG=glow godot --path . res://tools/glow_compare/enemy_gallery.tscn --rendering-method forward_plus(addGALLERY_NOVA=0for the legacy look) renders every type facing east with a green arrow under each.
Railgun look: Chris-approved visual savings (2026-10-01)
Section titled “Railgun look: Chris-approved visual savings (2026-10-01)”On the promoted Railgun (RailgunLanceLook / RailgunMarkLook) and the identity entry E, measured with tools/perf_probe/look_bench.gd --ab (interleaved REF twins in one process, M5 MoltenVK, K=64, 5 rounds, GPU ms x s per shot, noise ~+-0.005):
- Rail scar on its own thin strip instance (
SCAR_K0.4 half-widths, custom.x + 4): the full lance quad dies at its bare life. Same look (same-frame A/B max 1/255; 2/255 on E’s premul core). C scar 0.038 -> 0.026, C evolved 0.048 -> 0.037, E 0.065 -> 0.040. - K_HALF 1.7 -> 1.3 (-24% px; measured -20 to -36%): muzzle flare/spike/prism diamond squashed by 1.7/1.3 so they fade inside the quad; edge fade = last 0.2 half-widths. PAD 1.2 was rejected (clips the diamond’s back vertex), PAD stays 2.2.
- Mark EXTENT 2.1 -> 1.7 (MIN_HALF 18 -> 16): -34.5% shaded px (69k -> 45k at 20 marked); widths scaled by 2.1/1.7 to keep px thickness. GPU delta was below the timer noise this session.
- Totals vs ca316aae: C scar 0.037 -> 0.021 (-43%), C evolved 0.048 -> 0.031 (-36%), E 0.064 -> 0.033, E evolved 0.081 -> 0.041.
- Bench gotcha: pass
--disable-vsync. With vsync on, a window that another window covers stops presenting on macOS and the bench stalls at ~1% CPU. Look-script changes (not only shaders) A/B like-for-like because--refalso swaps in the REFDIR copy of the look’s script.
Switchable visual perf cuts — PerfCuts (2026-10-01)
Section titled “Switchable visual perf cuts — PerfCuts (2026-10-01)”Chris-approved (“do all of those”) from the fx-perf-roster proposals. Every cut is a static flag on PerfCuts (render/perf_cuts.gd), default ON, with its own Debug Settings row (perf_* in ui/debug_settings_panel.gd GRAPHICS_TOGGLES, Remote Console parity) so each one A/Bs live on the Apple TV; false = the look exactly as before. Values live as remote tunables (net/tunables_registry.gd). Render-only: no /sim file reads them; determinism baselines unchanged. Measured with tools/perf_probe/look_bench.gd --set=roster (M5 MoltenVK, relative only; configs carry "cuts", and --shot=DIR without --ref A/Bs a config against itself with its cuts off, same frame). Stills in .superpowers/captures/proposals/ (worktree-local).
| Row | Flag | What changes | Tunables | Measured | Visual |
|---|---|---|---|---|---|
perf_orbit_lens |
orbit_lens_lite |
evolved Orbit lensing band discards past |d-1| 0.3 instead of 0.42 (Gaussian narrowed in proportion) |
OrbitShardsLook.LENS_LITE_BAND |
0.083 -> 0.075 ms/inst-frame (-10%) | slightly thinner haze |
perf_blade_echo |
blade_echo_lite |
evolved Blade echo whirl life x0.7 | BladeSlashLook.EVO_LITE_LIFE_MUL |
0.108 -> 0.078 ms x s (-27%) | same whirl, gone ~0.1 s sooner |
perf_nova_lite |
nova_lite |
evolved Nova: one fbm octave less (custom.w), life x0.75, DRAWN radius multiplier capped 1.8 -> 1.5 (~0.83 x the damage radius). Visual only: WeaponNova.EVOLVE_AREA_MULT (the sim damage area) is untouched. Since NovaBurstLook.EVO_DRAW_SCALE (0.6, row below) the drawn radius is the smaller of the two, so this cap only bites when EVO_DRAW_SCALE > ~0.83: at bundled values the toggle changes the fbm octave and the life, NOT the size. |
NovaBurstLook.EVO_LITE_LIFE_MUL, EVO_LITE_RADIUS_CAP |
0.525 -> 0.293 (-44%, peak -31%) | quicker (smaller only if EVO_DRAW_SCALE > ~0.83) |
| (not a perf cut) | — | Supernova readability (TestFlight #27, Chris 2026-10-11): the evolved burst is drawn at NovaBurstLook.EVO_DRAW_SCALE (0.6) x its damage radius and its final colour is scaled by the shader’s evolved_gain uniform (EVO_GAIN 0.55). Both live remote tunables; the drawn radius is min(EVO_DRAW_SCALE, lite cap) x the hitbox. Applied on the v1 fallback too (FxManager “nova” branch at QualityManager L3+: PlasmaNovaNode radius + a duplicate plasma_nova material with gain, or the ring’s rgb). Visual only: the sim area is untouched (tests/test_supernova_visual_scale.gd). |
NovaBurstLook.EVO_DRAW_SCALE, EVO_GAIN |
fill falls with the drawn area (~0.52x vs the 0.83 lite draw) | smaller, dimmer Supernova |
| (not a perf cut) | — | Supernova carry-on (Chris 2026-10-13): (1) SupernovaHaloLook (fx/weapons/supernova_halo.gdshader, FxManager companion layer of the “nova” look, evolved Nova only): saturated iridescent spark streaks (sparks only since BUILD 317: Chris cut the shell + afterglow ring) from the drawn edge out to the DAMAGE edge, where the sim was already hitting and shoving enemies with nothing drawn. No fbm; sparks found per pixel from 3 polar sectors. Quad = damage radius x 1.08, discard inside 0.8 x the drawn edge. (2) SpacetimeWarpLayer (render/spacetime_warp_layer.gd + spacetime_warp.gdshader, CanvasLayer 4, max 3): collapse-inward + twist, then a rebound ring to the damage edge with an RGB split, then damped ringing; per blast a RECT BackBufferCopy + quad cut to the blast, hidden between blasts; tracks the blast’s WORLD position. The copy rect goes through SpacetimeWarpLayer.back_buffer_px (render-target pixels, see godot-gotchas: a logical rect drew a stale screen over the ATV, BUILD 315). (3) NovaBurstLook.EVO_FULL_RES: the nova + halo layers opt out of PerfCuts.fx_half_res (FxHalfResHost.wants_full_res, live) from the run’s first evolved burst (a never-evolved Nova keeps half-res), since the Supernova now fires ~5x less often. (4) NovaBurstLook.EVO_IRID_NOISE (0.3): weight of the plasma noise in the burst’s hue phase; 1.0 read as colour speckle. Prewarm: family 12/16 (halo), 15b (warp). Bench tools/weapon_vfx/supernova_carry_bench.tscn (CARRY_OFF=1 = before). |
SupernovaHaloLook.HALO_GAIN/SPARK_GAIN/HALO_LIFE, SpacetimeWarpLayer.STRENGTH/LIFE/COLLAPSE/SWIRL/RING_PUSH/CHROMA, NovaBurstLook.EVO_FULL_RES/EVO_IRID_NOISE |
not yet measured on device; halo fill ~2x the burst’s quad at a fraction of its per-pixel cost, warp ~0.75 s per blast | a Supernova that visibly reaches its hitbox and bends space |
perf_shockwave_ring |
shockwave_ring |
ShockwaveLayer draws the displacement on a 64-segment annulus per ripple (_RingMesh, outer radius / cos(pi/64) + 1 px) with the BackBufferCopy shrunk to the annuli’s bounds + reach, instead of a full-screen quad |
— | 8 layers x 2 ripples 0.155 -> 0.088 ms (-43%) | bit-identical (mobile + GL) |
perf_firestorm_cluster |
firestorm_cluster (default OFF since BUILD 243) |
the shader’s union loop (ascending slots) stops once past its own slot with the edge settled (early_stop uniform bool; only a lower slot can hide the fill). The per-zone neighbour lists (nbrs[256], WZ_NBR_THRESHOLD/WZ_NBR_K) were REMOVED after the BUILD 243 A12 Apple TV GPU page fault: prime suspect, and real play overflowed 63/64 slots to the full walk anyway |
— | 48 zones, per zone per frame (M5 MoltenVK, 5 rounds): clusters 0.0357 -> 0.0336 (-6%), pile 0.0243 -> 0.0215 (-12%), chain 0.0416 -> 0.0383 (-8%); BUILD 240 shader 0.042 / 0.028 / 0.047 | bit-identical to OFF and to the pre-cluster shader (mobile + GL) |
perf_fx_half_res |
fx_half_res |
FxManager’s additive v2 event looks (Blade, Scythe, Nova, Blaster, Railgun + mark) render into FxHalfResHost’s SubViewport at SCALE (0.5) of the screen px; one blend_add composite adds it back |
FxHalfResHost.SCALE (0.25-1) |
per shot: blade evo -72%, scythe evo -63%, nova p1 -69%, nova evo -73% (0.529 -> 0.142), target pass included | softer; Nova loses its fine radial streaks, Blade sector edges step. SCALE 1.0 through the target = max 1/255 vs direct |
- Firestorm union history. Nearest-K=8 lists were rejected first (inner rims + double fill in a dense pile); the exact-list design that replaced it (9d1c3c92) is gone too (BUILD 243 GPU fault suspect, see the row above). Do not reintroduce a large dynamically indexed uniform array in
weapon_zone_field.gdshaderwithout an on-device A12 Apple TV run — Mac Metal with validation did not reproduce the fault.tests/test_perf_cuts.gdpins thatzonesis the shader’s only uniform array. - Half-res host (
render/fx_half_res_host.gd). Transparent clear + the looks’ ownblend_add= the target holds exactly the summed light, so the composite adds rgb at alpha 1 and only resolution changes.use_hdr_2dfollows the main viewport (RGBA16F on Metal: overbright survives for the ONE WorldEnvironment glow);own_world_3dkeeps glow from running inside the target; on gl_compatibility both are LDR and clamp(scene + clamp(fx)) == clamp(scene + fx). Camera transform read atRenderingServer.frame_pre_draw(after Camera2D), so no frame lag. Idle =UPDATE_DISABLED+ hidden composite. Stays full-res: premultiplied-alpha looks (identity candidates;can_hostrefusesblend_premul_alpha, a transparent target cannot darken what is under it) and main-owned persistent looks (Orbit, player bolts). Layers are registered with their home parent and reparented live on a flip.pinned(-1 follow the switch, 0/1 forced) exists because look_bench renders the cut and its reference in one frame and the reference flips the global switch off; a test pins that. - PrewarmPass warms the new pipelines: family 15 (ring mesh + rect BackBufferCopy, gated by
shockwave_ring) and family 16 (the six hosted looks inside a forced-live host + the composite, gated byfx_half_res; the composite draws at the pass’s full alpha, occluded by the start menu’s opaque backdrop like the rest of the pass).
Dynamic camera zoom — CameraZoom (2026-10-07, overnight T1; DevFlags A/B, default OFF)
Section titled “Dynamic camera zoom — CameraZoom (2026-10-07, overnight T1; DevFlags A/B, default OFF)”Render-only (/sim never reads it). render/camera_zoom.gd turns three inputs into a world
scale (1.0 = the plain view, 1.5 = 50% more world per axis = camera.zoom 0.667):
the fastest live pilot’s speed as a ratio of the run hull’s own base speed (ShipBonuses: 230
frigate, 185 cruiser; set by camera_zoom.reset() at run start; linear from ratio 1 up to
SPEED_WORLD_SCALE at SPEED_FULL_RATIO), BOSS_WORLD_SCALE while any boss is alive (main’s
hud_boss_info), and 1 + extra_zoom_out, a hook other render systems raise on
main.camera_zoom (T3: tacklers engaging a pilot). They combine as the max, never the sum, clamp
to [1, min(MAX_WORLD_SCALE, spawn_safe_scale(viewport))], and the camera eases toward the target
exponentially (EASE_RATE, frame-rate independent, never overshoots). The five scale/ease numbers
are registered tunables (bounds cap the world scale at 2.0 = 4x the visible area, the fill-rate
ceiling).
Spawn-ring ceiling. Enemies spawn on Sim.SPAWN_RING (1100) around the pilot, so a view whose
corners reach that ring shows spawns popping in on screen. CameraZoom.spawn_safe_scale(vp) =
(SPAWN_RING - SPAWN_EDGE_MARGIN) / (0.5 * |vp|) (margin 80 px for body + glow + shake, floor 1),
evaluated live every frame from get_viewport_rect().size, so it follows a patched ring and the
device. It binds below the 1.6 cap on a 1280x720 TV (~1.39) and iPhone (~1.54); iPad (~1.66) is
capped by MAX_WORLD_SCALE. Known pre-existing gap it does not close: dimension hazard bodies
(sim/dimension_hazards.gd) spawn at ARENA_HALF + 200..700 and recycle at ARENA_HALF + 800
per axis, so a pilot hugging the arena wall can see them appear/vanish even at the plain view (their
radius alone is up to 740 px); zoom only widens that window.
main._drive_camera_zoom runs in _process just before threat_radar.cam_zoom is read, and writes
camera.zoom only while DevFlags.dynamic_zoom (Debug Settings “Dev: dynamic camera zoom” +
console dev_flag). Off, it never touches the camera, so marketing/capture/shoot.gd’s own
camera.zoom keeps working; switching it off mid-run writes the plain view back once. The touch
readability zoom is Window.content_scale_factor, a separate multiplier, so phones keep their larger
ships/text and simply see more arena. Consumers checked: _visible_world_rect (culling, reads
camera.zoom), ThreatRadar.cam_zoom, BossEdgeIndicator / Funzo warp / shockwave / FX half-res
host (all via the canvas transform), the Graviton lens radius (screen-UV, now scaled by camera.zoom
so its world footprint holds), ArenaBorder fog (OUTSIDE 3200 px covers a 2x view). Known
cosmetic: shockwave ripples and damage numbers keep their screen/world sizes. Pinned by
tests/test_camera_zoom.gd (pure target + easing, plus the real main._process path).
Per-enemy marker density — MarkerBudget (2026-10-07, gfx loop part 2)
Section titled “Per-enemy marker density — MarkerBudget (2026-10-07, gfx loop part 2)”Looks that put one marker on every enemy (EnemyAuraLook rings, DisruptorFieldLook lock brackets) share
fx/marker_budget.gd instead of capping by pool index. Any new per-enemy marker look should use it too.
- Priority = distance to the candidate’s nearest pilot (the
_nearest_pilotrule). The nearest N (per look:EnemyAuraLook.NEAR_N24,DisruptorFieldLook.MARK_NEAR_N12) get nearness 1; past the Nth-nearest distance R it falls to 0 acrossMarkerBudget.FADE_PX(smoothstep). - R is slew-limited in sim time (
SLEWpx/s, onsim.run_time), so a wave landing on the pilot fades the old far markers out over frames instead of popping them, and extra render frames inside a tick change nothing. - Past the band a far floor (
FAR_ALPHA/MARK_FAR_ALPHA) divided by local density (DENSITY_CELLpx cells, thinned pastDENSITY_REFper cell). Looks crossfade: full form at alpha ~nearness, cheap far form at ~floor x (1 - nearness). Aura far form = a 2-segment cap tick that still drains; Disruptor far form = two opposite corners (the other two and the mute bar fade with nearness). - Cost: one native
PackedFloat32Array.sort()of the candidate distances (already bounded by each look’s CAP)- one O(n) density pass, ~0.075 ms at 300 headless on Mac. Measured with
tools/weapon_vfx/clutter_measure.gd(300 marked): aura sync 0.50 -> ~0.40 ms, 4800 -> ~1840 segments; disruptor sync ~0.46 -> ~0.47 ms (per-held loop dominates), 3124 -> 1584 segments. Bench:tools/weapon_vfx/clutter_bench.tscn(budget off vs on).
- one O(n) density pass, ~0.075 ms at 300 headless on Mac. Measured with
- The Disruptor mute bar only shows on
DisruptorFieldLook.fire_suppressibleenemies (dedicated shooters, skirmishers, beam lancers, elite kits). Chip fire is excluded on purpose: it is one shared shot budget, so a suppressed chipper’s shot is just handed to another enemy.
Webs lite — off-screen cull + outline LOD (2026-10-07, PerfCuts.webs_lite, default ON)
Section titled “Webs lite — off-screen cull + outline LOD (2026-10-07, PerfCuts.webs_lite, default ON)”BUILD 273 Apple TV telemetry: one session ran 59.6 fps with no webs and 48.2 fps with 50+ webs (an average of
149), at similar enemy counts, with render_cpu flat (0.6 → 0.76 ms). So the drop was GPU fill, not draw calls.
The web layer was already one MultiMesh draw. PerfCuts.webs_lite (Debug Settings “Perf: Webs lite (A/B)”,
console parity through the generic perf_* toggle) adds two things in ZoneRenderer._update_web_layer:
-
Off-screen cull: a web whose bounding circle (
half_len + radius + WEB_PAD + WEB_CULL_MARGIN) misses the canvas view rect (web_view_rect(), overridable for tests viaview_rect_override) is not written into the buffer. The instance data of the webs that remain is byte-identical, and the look is untouched. -
Count LOD: v1 switches to the outline look in three cases:
- at
WEB_LOD_COUNTON-SCREEN webs (default 50; 0 = always; 401, aboveSim.WEB_CAP, = never). The hysteresisWEB_LOD_HYST(default 8) is clamped below the count at read time, so it can never latch on. - whenever
QualityManageris at L1+ (web_lod_forced). That forces it at ANY web count once the ladder is enabled. - with the old
low_detail(L2+).
The outline is the end rings plus each capsule’s two side strands. It has no 0.07 fill and no cross strand. The side strands are kept because the capsule corridor slows the pilot (
ElementalSystem._point_web_dist_sq), and the first cut drew only the rings, leaving it invisible (review blocker 2026-10-07). With the fill gone, the outline draws the side lines at RING weight and width (web_v1_side: 0.22, 1.5 px, 1 px end ramp), not the full look’s 0.18 × 1 px strands, which read as a hairline (re-review). On one full-fade web, the side line’s profile equals the ring’s (bench--single).With the cut on, the outline is drawn on thin geometry instead of the full quad:
web_lite_mesh()withrender/web_field_lite.gdshader.- The mesh is 2 annuli ×
WEB_LITE_SEGS(16) segments, outer edge circumscribed at r + PAD, plus 2 side strips (y = ±(r ± PAD), x = ±(half + PAD)). - Annuli add only
web_v1_rings, under an ownershipdiscardso overlapping rings never add twice. - Strips add only
web_v1_side, each on its own side of the axis. - The additive sum therefore equals the quad’s
web_v1_alpha(low). - The maths are shared with v1 through
render/shaders/web_field_core.gdshaderinc, mirrored in GDScript intests/test_web_lite.gd, whose sampled parity test checks the sum at every point.
Looks A/B/C keep their quad and only get
low_detail. Whether their own low_detail keeps corridors visible was not checked here. - at
-
Tunables:
ZoneRenderer.WEB_LOD_COUNT/WEB_LOD_HYST/WEB_CULL_MARGIN, all live. The outline pipeline is prewarm family 26. -
Dropped approach: a tight capsule polygon for the FULL look covered fewer pixels, but measured 6-7% SLOWER on the M5. Pixel count was not the cost, so the full look stays on its quad.
Bench (tools/perf_probe/web_lite_bench.tscn, M5 MoltenVK 1080p, game-like field with ~40% off screen;
GPU ms over an empty scene):
| Webs | Old quad | Cull only | Cull + outline LOD | Old low_detail quad |
|---|---|---|---|---|
| 50 | 0.175 | 0.174 | 0.167 | 0.170 |
| 150 | 0.241 | 0.241 | 0.183 (0.76×) | 0.218 |
| 400 | 0.473 | 0.484 | 0.249 (0.53×) | 0.402 |
- Submitted at 150 webs: 150 → 96.
- Prims at 150 webs: 300 → 6,528 in the LOD (68 triangles per web), always in 1 draw call.
- Pixel diffs (
--shot, at 150 webs):- old quad vs cull-only: 0/255 under mobile and gl_compatibility.
- quad
low_detailvs the outline mesh: 1/255 under both renderers. A hard step at the strip ends had flipped 2 gl_compat pixels, so the ends now fade over 1 px.
- Still to confirm on the Apple TV: whether the A12 gains the same fill. Check D1
sampleswithdev='tvOS/AppleTV11,1' AND webs>=50on the new build.
HDR display output — HdrOutput (2026-10-08, A/B, default OFF)
Section titled “HDR display output — HdrOutput (2026-10-08, A/B, default OFF)”Two different things are both called “HDR” here. The HDR 2D canvas (Viewport.use_hdr_2d, an
RGBA16F target) has been on for Metal since 2026-09 so overbright neon can feed bloom; it is set by
main at run setup (platform/tvos/project.godot also boots with rendering/viewport/hdr_2d=true)
and dropped at L1+ by QualityManager.hdr_on. HDR display output (Window.hdr_output_requested,
EDR on Apple) is what lets values above 1.0 reach the screen instead of clipping at the swap chain.
Until this A/B, nothing requested it, so the output was SDR everywhere.
- Switch.
HdrOutput.on()(render/hdr_output.gd) = the Debug Settings row “HDR output (A/B)” (GRAPHICS_TOGGLEShdr_output, console parity through thegraphicscommand, which like every graphics row also flips the adaptive ladder to manual) over the remote tunableHdrOutput.ENABLED(bool,"local": truebecause it is about this device’s screen; docs.dhdr_output.json, group “Glow & colour grade”). Only a real press writesHdrOutput.override(open_panelskipsNO_REPUSH_GRAPHICSwhen it re-pushes rows onto a fresh run, or merely opening the panel would pin it). The row writesHdrOutput.override(-1 follow the tunable, 0/1 forced), NOTENABLED: every run start re-applies the remote patch throughTunables.apply, which resets each registered key, so a row press written into the tunable was wiped at the next run (found on the Mac windowed check). The override lasts the session, like the other graphics rows. The row readson()live (live_graphics_state), so a patch shows on it while no row has been pressed. - Request.
main._sync_hdr_output()runs every frame, before_process’ssim == nullreturn (the start menu applies it too). ONE decision,want = HdrOutput.wanted(capable, screen_supported)(= switch on AND capable AND this screen HDR right now), goes to BOTHHdrOutput.sync(window, want)(the request, written on a real change only) andQualityManager.set_hdr_output(want, root_viewport)(the root hold), so the two can never disagree. That is load-bearing: the engine forces the root HDR whenever the request is set, whether or not the screen can show it (Windowsetter ->_update_viewport_for_hdr_output), whileuse_hdr_2dreads false, so a request without the hold puts every root mirror ~gamma-2.2 darker and pays the 16-bit target for nothing (review round 2 caught an SDR-screen variant of exactly this). So an SDR screen gets no request at all.screen_supportedis the per-window display-server answer, re-read every frame, and in the fork it does not depend on the request (macOS = max potential EDR > 1; iOS =potentialEDRHeadroom > 1; tvOS, forkefa0097= HDR display AND (HDR output now OR Match Content can switch the mode), and on enable the fork setsAVDisplayManager.preferredDisplayCriteria(HEVC BT.2020/PQ 2160p) so the TV switches itself — seedocs/research/2026-10-08-tvos-edr-hdr-output.md), sosupportedis true before any request and a TV that changes to HDR is picked up the next frame with no chicken-and-egg.capable= RenderingDevice backend withSUPPORTS_HDR_OUTPUTANDDisplayServer.FEATURE_HDR_OUTPUT(HdrOutput.capable(), read once inmain._ready; without the driver check, Windows/Vulkan would reject and re-request every frame). Headless, the editor’s compat runs and the web build (gl_compatibility, no RenderingDevice) are never capable, so nothing is requested there (a compat request only logs an engine warning). - QualityManager interaction (decision). While HDR output is on, the root canvas stays HDR at
EVERY tier:
QualityManager.root_hdr(level, hdr_output) = hdr_on(level) or hdr_output, used by_applyand byset_hdr_output. Reason: the engine forces HDR internally when output is requested, butuse_hdr_2dstill READS false (measured on Mac Metal), so every SubViewport and uniform that mirrors the root would land in the wrong colour space (the ~gamma-2.2 darkeningNebulaBgdocuments), and dropping the canvas at L1+ would quietly end HDR output exactly at the low tiers. The cost is the 16-bit target at L1+ (about 2x fill bandwidth per draw); bloom itself still sheds at L1 as before. Switching off in a run hands the root back to the current tier rule; out of a run it restoresQualityManager._base_hdr, the root WITHOUT the A/B (captured from the root when output switches on outside a run, refreshed to the tier rule by every in-run_apply), so on/off on the Mac menu before the first run leaves it LDR, and after a run it is what the run left. - SubViewport audit (game code;
tools/andmarketing/excluded).FxHalfResHost(FxManager’s andchalice_host): now follows the root every_pre_draw, on a change only. It was set once in_ready, which was already wrong after a ladder tier flip.ArenaBackgroundgalaxy target: already followed the root per frame (_sync_galaxy_hdr).ZoneRendererhdr_linearuniform: already mirrored per frame (_update_zone_layer).NebulaBg: an LDR target by design. Its Nova grade pre-compensates for an HDR root per frame. A dark backdrop (peak ~0.07) has nothing above 1.0 to lose, so it is unchanged.GlassPanelsnapshot passes (pause / level-up / results): built per screen-open from the root’s value at that moment, short-lived (UPDATE_ONCE), so they pick up a toggle on the next open.ThreatRadaraggregate glow andCodexCardpreview: LDR targets, never followed the root. Unchanged: at L0 the root is already HDR today, so HDR output does not change how they composite. The one difference is the Mac start menu (root LDR there until a run): with HDR output on it goes HDR, which is what tvOS’s menu has always been.Ship3DRenderer: a 3Down_world_3dviewport, souse_hdr_2ddoes not apply.
- Readout.
HdrOutput.status(window, capable)={on, capable, supported, requested, enabled, max_nits, ref_nits, max_linear}(max_linear=Window.get_output_max_linear_value(), the brightest linear value the screen shows; 1.0 = SDR). It goes into the console/statusashdr(menu and run), shown in the Live column (“HDR out”), and as a line under the Debug Settings title (HdrOutput.label, refreshed every frame while the panel is up, because the display server can engage a frame late or drop HDR when the screen’s mode changes). Measured on the M5 MacBook Pro (Mobile renderer, Metal; realmain.tscn, console command on the menu, then a run at L2): enabled on the menu and through the run, root and half-res target HDR at L2, both back to LDR on switch-off; max 1600 nits, reference 600-1330 nits as macOS adjusts it, so peak 1.2x-2.7x. gl_compatibility reports not capable and never requests. On an HDR TV the reference is lower and the peak higher. - Not in telemetry. Whether a player’s screen does HDR is device-capability data the perf sample does not carry today, so adding it is a privacy-page / ASC-label change, not just a field.
- Tests:
tests/test_hdr_output.gd(sync/no-op on a detached Window, since headless’s real root refuses the request; QualityManager policy; the half-res target follows a live flip; the realmain._process+ console path;/status; the Debug Settings row and line) andcontrol/test/panel.test.mjs(the Live column line).
Colour profiles: ColorProfileSdr / ColorProfileHdr + HdrLook (HDR/SDR colour systems step 1)
Section titled “Colour profiles: ColorProfileSdr / ColorProfileHdr + HdrLook (HDR/SDR colour systems step 1)”Two full colour profiles (spec docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md):
render/color_profile_sdr.gd and render/color_profile_hdr.gd hold the same names (tonemap mode/exposure/
white/AgX white/AgX contrast, glow intensity/strength/bloom/threshold/soft ramp/blend/levels 1-7, grade
brightness/contrast/saturation, and the shader-global values), all registered tunables, live via
LiveScope.HOOK_ENV. SDR’s bundled values are the old EnvironmentLook literals (pixel-identical); the
LDR_* web values are SDR-only. HDR is seeded from the research doc’s headroom mapping (neutral grade,
no constant bloom, threshold 2.5 / ramp 2.0 for the Apple TV’s assumed 4.93 headroom), tuned on device.
- Which profile:
HdrLook.update(want, max_linear)runs inmain._sync_hdr_output()with the SAMEwantasHdrOutput.syncandQualityManager.set_hdr_output; a change callsmain._apply_color_profile()in that frame. HdrLook follows the HDR output REQUEST, not whether the display engaged: when HDR is requested but not engaged the HDR profile shows withHdrLook.headroom1.0.headroom= screen max linear xHdrLook.PEAK_CALIBRATION(local tunable) when engaged, 1.0 when HDR output is off. Console/status"color"and the Debug Settings HDR line report it. - The one re-apply:
main._apply_color_profile()=EnvironmentLook.apply(env, hdr_canvas, HdrLook.active)ShaderGlobals.push(HdrLook.active, get_viewport().use_hdr_2d). Callers:_new_run, HOOK_ENV, a profile change, the tonemap selector.EnvironmentLook.applynever touchesglow_enabled(QualityManager owns it). Its HDR glow/grade branch keys on the HDR-canvas flag only: an LDR canvas (web) always takes the LDR branch even with the HDR profile forced. Web is SDR only.
- Shader globals:
render/shader_globals.gdmaps each global uniform to a profile var. Each is DECLARED in[shader_globals]of bothproject.godotandplatform/tvos/project.godotat the SDR value (the export shader baker fails on a runtime-only global and leaves the shader unbaked); the runtime only callsglobal_shader_parameter_set. Never_add(error on a declared name) or_get/_get_list(editor-only). Comments in those blocks must use;:#is NOT a comment inproject.godot(Godot glues it onto the next key). Pilot:border_halo(the arena edge + tutorial wall halo,border_look.gdshaderinc). Compile check:tools/shader_check/compile_check.gd(windowed, both renderers); it grepsERROR|WARNINGbecause an undeclared global only prints a WARNING, and Mobile reports a failed compile as “ERROR: Shader compilation failed.” - AgX reads
tonemap_agx_whiteandtonemap_agx_contrast, nottonemap_white. - Comparison: console/Debug
color_profile(auto/sdr/hdr; hdr on an SDR screen is a preview) andtonemap_sdr/tonemap_hdr(session overrides that win overTONEMAP_MODE); these three are look-only (control/src/guard.jsSAFE_KINDS, usable while a human plays). Console-onlyfreeze_scene(render/freeze_scene.gd) is guarded; see dev-tools.md. - Not verified on desktop: the export shader-baker check. The root macOS preset has no baker, so the
on-device fresh-install stall check (skill
bh-fresh-install-stall-check) is the verification.
Colour space (colour_space_fix) (HDR/SDR colour systems step 2; A/B, default OFF)
Section titled “Colour space (colour_space_fix) (HDR/SDR colour systems step 2; A/B, default OFF)”Spec § 3 of docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md; plan
docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step2.md. Render-only; nothing in /sim reads it.
With the row OFF (or on an LDR canvas) every path below is byte-for-byte the pre-step-2 game.
- The two conventions. On an HDR canvas (
use_hdr_2d) the engine linearises some colour inputs and not others (Godot forkefa0097, stock canvas path,servers/rendering/renderer_rd/):renderer_canvas_render_rd.cpp:672-694:use_linear_colorsfollows the render target’s HDR flag and the itemmodulateis linearised on the CPU (srgb_to_linear());draw_*and vertex colours are linearised inshaders/canvas.glsl:175-179, thencolor *= modulation.canvas.glsl:246-256: a MultiMesh instance colour is multiplied in RAW, and plainvec3/vec4colour uniforms reach the shader RAW. So a uservertex()seeslinear(vertex) × linear(modulate) × RAW(instance), and an authored 2.0 is ≈4.95 throughmodulatebut 2.0 through an instance colour. The row converts the RAW inputs so one authored value means the same thing on every path. The formula is the engine’s own (canvas.glsl:118-120), unclamped above 1.
ColorSpace(render/color_space.gd).FIXis a registered remote tunable (bool, not"local", label intools/tunable_docs/docs.d/color_space.json);overrideis the session A/B (Debug SettingsGRAPHICS_TOGGLES“colour_space_fix”, reached from the console’sgraphicscommand; listed inNO_REPUSH_GRAPHICS/live_graphics_stateso opening the panel never pins it over a patch).main._processcallsColorSpace.sync(root.use_hdr_2d)every frame (menu included), right after_sync_hdr_output(). The gate isactive = on() and root HDR: on gl_compatibility (web) or a QualityManager tier that drops the root HDR the canvas linearises nothing, so the fix stays inert even when switched on. A change ofactivesets the globalcs_linearize(through the injectablesetter, a spy in tests) and bumpsgeneration.main._processactually calls it throughmain._sync_color_space(root_hdr), which on a change also PUSHES it, in the same frame, to every stock-layer owner (sync_color_space()on proj / enemy_proj / gem / aim-lanceSwarmRenderers, the enemyArchetypeRendererandThreatRadar) and toarena_bg.refresh_star_comp(). The owners’ own per-frame polls sit insync()/update_radar(), which a stopped sim clock (freeze_scene, pause, level-up, dimension choice) never reaches, so without the push a flip there showed a hybrid (enemy cores converted, halos/shells still on the stock material). Owners still comparegenerationin those polls, which catches nodes built later. Nebula and the codex preview poll in their own_processand need no push.- The shader recipe.
render/shaders/color_space.gdshaderincdeclaresglobal uniform bool cs_linearize;andvec3 cs_in(vec3)(identity while false).cs_linearizeis DECLARED (bool,false) in[shader_globals]of bothproject.godotandplatform/tvos/project.godot(the export baker fails on a runtime-only global) and is NOT a profile global (not inShaderGlobals.GLOBALS: it has no SDR/HDR pair). Two uses:- MultiMesh instance colour:
COLOR.rgb = cs_in(COLOR.rgb);as the FIRST statement ofvertex(), before the engine multiplies in the texture. 45 look shaders do this: the 7 Warden looks, thefx/weapons/looks, thefx/weapons/candidates/**looks,far_stars,star_fieldandenemy_smear(every ArchetypeRenderer core). - Plain colour uniforms:
cs_in(<uniform>)where read (avec4tint converts.rgb), in 10 shaders (nebula, low-HP border, threat aggregate glow, aim lance, galaxy, death dissolve, FunZo void skin, orbit shardseh_core, glass snapshot veil, far_starstint). Explicitcs_in(), notsource_color:source_colormakes the engine convert on every HDR target and would change the row-OFF look.
- MultiMesh instance colour:
- The derived test (
tests/test_color_space_shaders.gd). The MultiMesh set is DERIVED: every.gdshaderunderfx/plus a three-entry render-side list, minus a short reasoned exclusion map. Each must includecolor_space.gdshaderincexactly once and openvertex()with the conversion; aUNIFORM_READStable pins the uniform conversions.tools/shader_check/compile_check.gdlikewise DISCOVERS every saved ShaderMaterial underrender/,fx/,ui/whose shader includescolor_space.gdshaderincorborder_look.gdshaderinc(57 materials), so no list goes stale. - The stock-material swap. MultiMesh layers drawn with no material or with
fx/additive_mat.tres(aCanvasItemMaterial, which cannot convert) swap while active torender/shaders/cs_mix_mat.tres/cs_add_mat.tres(identical to the stock blend except the conversion), assigned as the shared preloaded resource (noduplicate()); OFF swaps back to the exact originals.ColorSpace.stock_layers()finds them (ause_colorsMultiMesh whose material is null / additive / already swapped); any other material is the layer’s own shader and is left alone. Owners:SwarmRenderer(core, halo, hotcore),ArchetypeRenderer(halo/hot/glow/shell layers tocs_add; the Nova buckets’ baked glow is null-material, so it takescs_mix),ThreatRadar._sharp_mm. Each owner keeps_cs_gen, andSwarmRenderer.configure()/ArchetypeRenderer._build_meshesreset it to -1 so layers rebuilt after activation are swapped on the owner’s nextsync()(the next frame the owner is synced; a row flip itself lands at once through main’s push, above).render/prewarm_pass.gdwarms both new pipelines (an extra SwarmRenderer and ArchetypeRenderer withapply_color_space(true)), so the first toggle never stalls. - The SubViewports. Three LDR targets follow the root’s
use_hdr_2dwhile active and stay LDR (today’s default) otherwise, written only on a change (reallocates the target): the nebula (render/nebula_bg.gd; while active its Nova-groundpow(1/2.2)pre-compensation is skipped because the shader converts instead), the threat-radar aggregate glow (ui/threat_radar.gd; itsclamp(total, 0, 1)stays), and the codex preview (ui/codex_card.gd; the driver node sits INSIDE the SubViewport, so it reads the host viasv.get_parent().get_viewport()). Already following the root regardless:ui/theme/glass_panel.gd, the galaxy target andrender/fx_half_res_host.gd(follows the root live in_pre_draw; the converted fx looks render through it whenPerfCuts.fx_half_resis on). Perf caveat: the nebula target becomes an HDR (wider) target while active; its ATV cost is measured before the row can default on (an 8-bit fallback tier is a step-2b decision, not built). - The warp-dim maths (
main._drive_warp). The warp writes a greymodulatem(down to 0.65) on 16 world renderers. On a converted layer the shader computesL(inst × L(m))≈L(inst) × L(L(m)): the dim would be linearised twice (0.65 → 0.12 instead of 0.38) and the A/B would show a 3x deeper warp.- Whole renderer converted:
ColorSpace.warp_modulate(dim, active, converted)writesS(dim)(linear→sRGB), soL(m') = dim. “Converted” is inferred by class:r is SwarmRenderer or r is ArchetypeRenderer(enemy, proj, enemy_proj, gem renderers). Inactive, unconverted, ordim >= 1returns exactlyColor(dim, dim, dim)(the restore pass is exact white). - One converted child under a modulated parent (
arena_bg’s star layer): the backdrop’s ownmodulateis the per-Dimension tint (set_tint), the warp grey, or both, and is linearised once by the engine.ArenaBackground.refresh_star_comp()writesColorSpace.star_comp(modulate, active), a PER-CHANNELchild_comp(p) = S(p / L(p))of that modulate as it stands, soL(p) × L(c) = S(L(p))and a white-hot star ends atL(p)(takes the tint and the dim once; without it a Void tint renders the stars 2-8x darker). Channels above 1 (Fire 1.35) compensate down. Called fromset_tint, the star-layer build,_drive_warp(after its modulate write) andmain._sync_color_space. WHITE while inactive; an exact-1 channel stays exactly 1, so the end-of-warp restore and a white tint are exact and a mid-warp toggle never sticks. It reads the modulate rather than tracking tint and dim separately, so it stays right whether or not the warp keeps the tint (today_drive_warpoverwrites it with the grey and restores WHITE: a pre-existing, separate backlog item). - Trap:
aim_lance_rendereris a SwarmRenderer whose core shader (aim_lance.gdshader) overwritesCOLORfrom converted uniforms and never converts the instance colour. It is NOT in the warp list today. If it is ever added, the class check would treat it as converted and over-brighten its core by ~1.27x (S(0.65)/0.65) with the row on: give it its own branch instead.
- Whole renderer converted:
- Exclusion:
fx/weapons/plasma_nova.gdshaderis not converted. Its colour arrives through thedraw_texture_rectmodulate (fx/weapons/plasma_nova_node.gd), which the engine already linearises; converting it would double-linearise. It is the only entry in the test’sNOT_MULTIMESHmap. - Out of scope (step-3 item):
render/zone_field.gdshader’s double dim. It convertsCOLORitself via its ownhdr_linearuniform (render/zone_renderer.gd) whenever the canvas is HDR, independent of this row, andzone_renderergets the plain warp grey, so its warp dim is already linearised twice with the row OFF. Fixing it changes the row-OFF look, so it waits for step 3. - Expected with the row ON: boosted instance colours (ArchetypeRenderer shell boosts, SwarmRenderer
HOT_CORE_BOOST, ×1.6-2.2) become ≈ ×3-6.6 after linearisation and look blown out, clipping per channel on SDR output. That is the inconsistency made visible; step 3 re-seeds the values in the corrected space. Judge the A/B on consistency (one authored colour looks the same on every path, mid-tones read as authored, warp depth unchanged), not on glow brightness. - Adding a MultiMesh look:
#include "res://render/shaders/color_space.gdshaderinc"and makeCOLOR.rgb = cs_in(COLOR.rgb);the first statement ofvertex(). Underfx/the test enforces it automatically; a MultiMesh shader elsewhere must be added toRENDER_MULTIMESH. A new plain colour uniform getscs_in()at its read and aUNIFORM_READSrow. A new stock-material MultiMesh owner callsColorSpace.stock_layers()/swap_layers()ongeneration, like SwarmRenderer. Then run the windowed compile check under both renderers. - Tests:
tests/test_color_space.gd(gate, sync/generation, setter spy, tunable viaTunables.apply, sRGB helpers and warp-dim maths numerically, the Debug row),tests/test_color_space_shaders.gd(above),tests/test_color_space_layers.gd(material swaps and exact restore, SubViewport flags, nebula compensation, warp modulates through the real_drive_warp).
A linear canvas blends alpha in LINEAR light (the “washed out” HDR look, found 2026-10-10)
Section titled “A linear canvas blends alpha in LINEAR light (the “washed out” HDR look, found 2026-10-10)”On an 8-bit canvas, alpha blending happens on sRGB-encoded values (gamma space); on a linear
(use_hdr_2d, RGBA16F) canvas it happens in linear light. So a faint fill authored for 8-bit gets much brighter:
colour 0.78 at alpha 0.07 over black = 0.055 sRGB = 0.0043 linear on 8-bit, but 0.57 x 0.07 =
0.040 linear on the linear canvas (~9-10x). Every low-alpha translucent layer (zone fills, webs, fog, halos, nebula,
arena backdrop) lifts the dark/mid tones into a milky veil. colour_space_fix cannot fix it (it converts
colours, not alpha), and neither do glow or brightness tunables (both tried live on the ATV). Measured with
tools/cs_capture CS_PROFILE=canvas (8-bit vs HDR luminance percentiles: p75/p90 7-13x) and
CS_PROFILE=ablate (per-layer contribution; zone_renderer and arena_background ~11x HDR vs 8-bit). A
pow(alpha, 2.2) remap under cs_linearize in web_field.gdshader cut that layer’s HDR lift ~40%.
When is the root canvas linear? Whenever the window root’s use_hdr_2d is true, which is NOT only
“HDR output on”: on every RenderingDevice (Metal) backend main._new_run turns it on and the QualityManager
ladder keeps it at L0 (hdr_on(0), it feeds bloom) with HDR output OFF; HDR output holds it linear at
every tier (QualityManager.root_hdr(l, hdr_out) = hdr_on(l) or hdr_out); tvOS also boots linear
(viewport/hdr_2d=true in platform/tvos/project.godot, so its start menu is linear). It is 8-bit at L1+
without HDR output, and always on web (gl_compatibility has no HDR 2D).
Shipped fix: the alpha remap, following the LINEAR CANVAS (spec docs/superpowers/specs/2026-10-10-hdr-alpha-remap-design.md;
Chris 2026-10-11 moved the trigger from the colour profile to the canvas). Each colour profile has its own
ALPHA_GAMMA (registered tunables, independent values: ColorProfileSdr seed 2.2, ColorProfileHdr seed
2.2, range 1-3). The global uniform hdr_alpha_gamma (declared at 1.0 in [shader_globals] of both project
files, the 8-bit value) is ShaderGlobals.alpha_gamma(mode, linear): the active profile’s ALPHA_GAMMA
while the root canvas is linear, 1.0 on an 8-bit root whatever the profile (so the Debug Settings /
console color_profile “hdr” preview on an 8-bit canvas no longer double-thins). Pushed by
ShaderGlobals.push(HdrLook.active, root.use_hdr_2d) from main._apply_color_profile (profile change,
run start, tunables, LiveScope HOOK_ENV, HdrTier drift) and by ShaderGlobals.push_alpha right after every
root use_hdr_2d write in QualityManager._apply / set_hdr_output (main._new_run’s boot hold is
followed by _apply_color_profile; tools/cs_capture writes its own capture canvas through _set_canvas).
render/shaders/hdr_alpha.gdshaderinc’s hdr_alpha(a) returns a exactly when the gamma is 1.0 (a
uniform early-out: no log2/exp2 and no bit drift on 8-bit canvases), else pow(a, hdr_alpha_gamma).
It is applied as the last write of COLOR.a in every covered shader. On black, pow(a, 2.2)
reproduces exactly what the 8-bit canvas did to an additive layer. fx/additive_mat.tres (the shared
additive material, ~50 users) is a saved ShaderMaterial on render/shaders/additive.gdshader instead of a
CanvasItemMaterial, so it carries the remap. Covered shaders (the list tests/test_hdr_alpha.gd pins,
and it forbids an early return in their fragment()): additive, cs_add, cs_mix,
arena_grid_breathe, arena_aurora, star_field, web_field, web_field_lite, web_look_a/b/c,
enemy_smear, zone_field, hero_glow_mix (the player’s Nova hull glow, below). Tests: tests/test_quality_manager.gd (ladder + HDR-output flips re-push),
tests/test_color_profiles.gd (main’s per-frame path at L0/L1, HDR on/off).
Switched remaps (the unremapped-alpha batch; tests/test_alpha_remap_switches.gd pins them): far_stars,
low_hp_border (alpha only), death_dissolve, weapon_zone_field (life fade only) and the glass focus
glow (glass_common.gdshaderinc) apply <global> > 0.5 ? hdr_alpha(a) : a, each global a bool
ColorProfile{Sdr,Hdr}.*_REMAP pushed as a float by ShaderGlobals.push; the enemy Nova glow switches
its material (nova_glow_mix_mat.tres). Ships ON in both profiles: the Nova glow, far stars, low-HP border
and death dissolve (each measured closer to the 8-bit look). Ships OFF in both profiles (SHIPPED_OFF):
WEAPON_ZONE_REMAP (it over-steepens the life fade, measured 0.36 / 0.11 of 8-bit in SDR) and
GLASS_GLOW_REMAP (it over-corrects the focus glow to ~0.1x of 8-bit light with a too-tight falloff);
both stay switchable for the Task 8 device A/B. See § “Unremapped-alpha fix batch (2026-10-12)”.
Look change, Chris to eyeball: with the canvas trigger, SDR output at Metal L0 (ATV/iPhone/iPad/Mac
in a run at full quality, and the ATV menu) now gets the remap too, so its faint layers draw THINNER than the
shipped build (they were washed by the same linear blend; this matches them to the 8-bit look). Record the
device’s /status tier next to any verdict: L1+ without HDR output is unchanged (1.0).
Couplings to know: cs_mix (the default-blend MultiMesh layers’ material) only exists while
colour_space_fix is ON (ColorSpace.swap_layers; OFF they use material = null), so on a linear canvas
toggling colour_space_fix also toggles the remap on those normal-blend layers (AA edges, alpha
thickness): two variables in one A/B. And a^γ is only the exact match for an additive or mix layer over
black. The global is global: a covered shader drawing into a SubViewport that does NOT follow the root
gets the root’s gamma on an 8-bit target (over-thinned). Audit (2026-10-11): the half-res FX hosts
(FxHalfResHost, FxManager + Chalice) follow the root live, fine; the galaxy spiral, nebula and threat-radar
aggregate SubViewports draw only uncovered shaders (arena_galaxy, nebula_bg, threat_aggregate_glow),
their composites are on the root; glass snapshots run the uncovered blur. Open: the Codex/Bestiary
preview (ui/codex_card.gd: an ArchetypeRenderer with additive_mat / enemy_smear inside a
SubViewport that follows the root only while colour_space_fix is on), so on a linear root with the row off
(the ATV menu always, and other Metal devices after an L0 run) its glows draw thinned. Not fixed: making
that SubViewport follow the root unconditionally hits the HDR-SubViewport colour-space trap (nebula_bg.gd),
so it needs its own design.
Rule: a new translucent shader on the canvas includes hdr_alpha and applies it last, and goes into
tests/test_hdr_alpha.gd’s list. A/B it live on a device with
node tools/console_mcp/hdr_ab.mjs <device> --toggle alpha [--gamma 2.2] (OFF = 1.0, the washed-out look;
it toggles ColorProfileHdr.ALPHA_GAMMA, so it needs HDR output engaged; live-only: the next run start
(Tunables.apply) or a relaunch resets it, and its restore writes --gamma, overwriting a live-tuned value
because /status cannot report the current one).
The player’s Nova hull glow (fixed 2026-10-12, “the glow is over soft” under HDR). PlayerRenderer._nova_glow
(the baked render/ship_sprites/nova/*_glow.png behind the hull) drew with NO material, i.e. the default
mix blend, so it skipped the remap and its soft falloff spread into a wide bright haze on the linear canvas.
The thruster, heal glow/ring and ascension flash use fx/additive_mat.tres and were already covered.
Measured with tools/player_glow_capture (real main + PlayerRenderer, everything but the ship hidden, sim
frozen; glow contribution = frame minus the same frame with _nova_glow hidden, linear luminance per 2 px
ring; ATV headroom 4.93, Mac mobile renderer). Ring means at 10 / 12 / 14 / 16 / 18 px, then total:
| condition | 10 | 12 | 14 | 16 | 18 | total |
|---|---|---|---|---|---|---|
| 8-bit SDR canvas (reference) | 0.078 | 0.044 | 0.023 | 0.004 | 0.001 | 35 |
| 8-bit SDR, remap | identical (exact no-op, gamma 1.0) | 35 | ||||
| linear SDR (Metal L0 / tvOS, SDR output), no remap | 0.137 | 0.104 | 0.071 | 0.024 | 0.007 | 76 |
| linear SDR, remap (new default) | 0.077 | 0.042 | 0.020 | 0.002 | 0.000 | 34 |
| HDR, no remap (shipped) | 0.290 | 0.257 | 0.198 | 0.088 | 0.023 | 181 |
| HDR, no remap, bloom OFF | identical | 181 | ||||
| HDR, no remap, gain 0.35 | 0.236 | 0.203 | 0.152 | 0.061 | 0.016 | 142 |
| HDR, remap (new default) | 0.151 | 0.102 | 0.055 | 0.005 | 0.000 | 72 |
| HDR, remap, tight 1.5 | 0.120 | 0.068 | 0.031 | 0.001 | 0.000 | 52 |
Normalised to each row’s peak, the shipped HDR glow holds 0.68 / 0.30 of its peak at 14 / 16 px against the 8-bit reference’s 0.29 / 0.05; with the remap it is 0.36 / 0.03, the reference’s shape at ~1.9x its brightness. Bloom contributes nothing at shipped settings (no pixel reaches the 2.57 knee; the capture does include bloom: knee 0 x4 spreads it to 28 px), and cutting the gain alone keeps the soft shape. The alpha blend in linear light is the whole cause. Mac headroom 2.67: shipped 125 total, remap 52, same shape.
Knobs (paired profile values, registered live tunables, HOOK_ENV; PlayerRenderer._apply_nova_glow re-reads
them on every HdrTier.generation bump, read through HdrTier.value() so they follow the same profile as
the gains):
ColorProfile{Sdr,Hdr}.HERO_HULL_GLOW_REMAP(bool; SDR true, HDR true): puts the savedrender/shaders/hero_glow_mix_mat.tres(mix blend +hdr_alpha) on the glow; false =material = null, the pre-fix draw. The A/B toggle. An exact no-op on an 8-bit canvas (gamma 1.0; measured identical).ColorProfile{Sdr,Hdr}.HERO_HULL_GLOW_TIGHT(SDR 1.0, HDR 1.0, range 0.5-4): an extrapowon the glow’s alpha before the remap, skipped exactly at 1.0. Only with the remap on (no material otherwise).ColorProfile{Sdr,Hdr}.HERO_HULL_GLOW(HdrTier source, KIND_F PATH_A; SDR 1.0, HDR 0.6 = HERO_GLOW’s seed): the hull glow’s brightness, split fromHERO_GLOW, which still drives the thruster and heal aura. SDR on a linear root had the same (smaller) spread (76 vs the 8-bit 35, 0.071 / 0.024 at 14 / 16 px vs 0.023 / 0.004), so SDR remaps too (Chris 2026-10-12, “make improvements whenever we find them”): the remap row lands on the 8-bit reference (34). Widest reach: tvOS boots linear, so an SDR-output Apple TV gets the tighter glow at every tier, plus iPhone/iPad/Mac at L0. Eyeball on the ATV with HDR off. Re-measure:CS_OUT=<dir> [CS_HEADROOM=2.67] godot --path . --rendering-method mobile res://tools/player_glow_capture/player_glow_capture.tscn, then restoreproject.godot,audio/bus_layout.tres,platform/tvos/project.godot.
Not covered (known gaps): HUD StyleBoxFlats, plain draw_* on the default material, HeroLights,
GPUParticles process materials, arena_galaxy (alpha 1.0), the RGB falloff (rim, tongues, halo, embers)
of weapon_zone_field.gdshader (its alpha, the life fade, has a remap switch since the unremapped-alpha
batch Task 6, WEAPON_ZONE_REMAP, shipped OFF because it over-steepens the fade; the RGB falloff is the
real linear-SDR gap and the remap cannot reach it), and
funzo_zone_field.gdshader (not in the measured scene; funzo carries alpha past 1 into rgb, so it
needs a deliberate design, not a blind pow).
Measured (tools/cs_capture CS_PROFILE=ablate, Mac, mobile renderer, headroom 4.93, 2026-10-10). The “SDR” column is the 8-bit canvas (vp.use_hdr_2d = false: the L1+/web state, cs_capture’s a_sdr8), not the Metal L0 SDR-output state.
A run picks its scene with randi() (backdrop variant, scatter, sim layout), so before/after must be
pinned: CS_SEED=1234 (nebula backdrop) CS_ALPHA_GAMMA=1.0|2.2 (A/B in one build)
CS_RESTORE_FRAMES=10|90 (settle after re-showing a layer before the next one’s baseline; default 10). CS_ABLATE_INTO=<script>.gd ablates inside one layer (children hidden
at the RenderingServer, since renderers re-set their own layers’ visible each frame).
Layer p75 contribution, linear, HDR / SDR, nebula backdrop, seed 1234, all four runs from one build
(zone_field remapped) and the same SDR baseline (BASE p75 0.00130-0.00131). Acceptance was each
layer’s HDR p75 at most 1.5x its SDR p75:
| layer | γ 1.0, settle 10 | γ 2.2, settle 10 | γ 1.0, settle 90 | γ 2.2, settle 90 |
|---|---|---|---|---|
| BASE (whole frame) | 0.02322 / 0.00130 (18x) | 0.00235 / 0.00131 (1.8x) | 0.02391 / 0.00131 (18x) | 0.00237 / 0.00130 (1.8x) |
| arena_background | 0.02182 / 0.00138 (16x) | 0.00117 / 0.00138 (0.85x) | 0.02258 / 0.00200 (11x) | 0.00135 / 0.00200 (0.68x) |
| zone_renderer | 0.01120 / 0.00127 (8.8x) | 0.00079 / 0.00127 (0.62x) | 0.01192 / 0.00091 (13x) | 0.00050 / 0.00092 (0.54x) |
| archetype_renderer | 0.00317 / 0.00023 (14x) | 0.00030 / 0.00023 (1.3x) | 0.00443 / 0.00005 | 0.00007 / 0.00005 (1.4x) |
| enemy_aura_look | 0.00119 / 0.00000 | 0.00012 / 0.00000 | 0.00029 / 0.00012 (2.4x) | 0.00007 / 0.00010 (0.7x) |
| hud (StyleBoxes not covered; its additive_mat users are) | 0.00154 / 0.00004 | 0.00008 / 0.00004 | 0.00264 / 0.00004 | 0.00012 / 0.00005 |
All four layer rows pass at both settles (the BASE row is the whole frame, uncovered draws included, so the 1.5x bar does not apply to it: 1.8x). Before zone_field was remapped they did not: on the same seed, the
10-frame settle measured arena_background 2.35x and zone_renderer 3.9x (zone_field was the zone lift:
inside zone_renderer, p90 0.01306 HDR vs 0.00152 SDR; remapped, 0.00144 vs 0.00151). The arena miss
was most likely a scene-state difference (that run landed in the other state below), not coverage: every nebula sprite inside arena_background measures at SDR
parity. enemy_aura_look sits at the noise floor. Its p75 is 0.0001-0.0004 linear (sRGB codes ~1-3),
HDR mean 0.00505 is below SDR 0.00561, and two measurements of the same draw in one run (the node has
no children, so “self” and “whole” are identical) read SDR p75 0.00009 and 0.00041.
Caveats: the same pinned scene lands in one of two states between runs (SDR BASE p75 0.0013 or
0.0033-0.0036; the frozen scene’s shaders keep animating), so compare only within one run or across
runs on the same SDR baseline. On the starfield backdrop the p75 is the black floor for every layer;
use p90 there. The pre-plan code on a random-seed run (the original finding) measured HDR BASE p75
0.01984 vs SDR 0.00091, arena_background 0.01810, zone_renderer 0.01572.
Unremapped-alpha fix batch (2026-10-12)
Section titled “Unremapped-alpha fix batch (2026-10-12)”Plan docs/superpowers/plans/2026-10-12-unremapped-alpha-fix-batch.md, audit
docs/research/2026-10-12-unremapped-alpha-audit.md. Six draws that blended partial alpha on the linear
root canvas without the hdr_alpha remap each get one, behind a paired switch
ColorProfile{Sdr,Hdr}.<NAME>_REMAP (bool, both true except the weapon zone and glass glow, which ship
false; false = the pre-fix draw; an exact no-op on an 8-bit root). Measured with tools/alpha_fix_capture (real main.tscn, freeze scene, L0, colour_space_fix
OFF; contribution = item shown minus hidden, linear luminance, 1920x1080, Mac mobile renderer, ATV
headroom 4.93). x8 = the light the item adds (positive contribution) / the same in the 8-bit SDR row;
core / fringe = the same ratio over the pixels holding >= 30% / 1-10% of the 8-bit peak. Before = the
switch off, after = on, same build.
Repeatability: Engine.time_scale is held at 0 from the freeze to the end, and the time-driven
states that are pushed rather than ticked are pinned before every row (dissolve progress 0.3, the
low-HP border’s _border_t 0, far-star twinkle_depth 0 while measuring). A warm-up pass shows each
item once per canvas state before any grab (a pipeline’s first use compiles async and skips the draw).
Every row prints floor (two grabs of one frame) and the last row S8b repeats S8 after the whole
cycle: both must be 0.000000, and were for all six items in the baseline. Glass is the one item with a
large dark column (its dark body over the scene); x8 counts only the light it adds (glow, frost lift).
Staging vs the audit: Nova glow SL 2.60x (audit 2.6-4x) and far stars SL 4.02x (audit ~5x) land in or near its range; the far stars here are measured with the twinkle off.
Re-measure:
[AF_ONLY=item,...] [AF_HEADROOM=2.67] godot --path . --rendering-method mobile --always-on-top --resolution 320x180 res://tools/alpha_fix_capture/alpha_fix_capture.tscn,
then restore project.godot, audio/bus_layout.tres, platform/tvos/project.godot.
| item (switch) | S8 total | SL x8 (core / fringe) | SLr x8 (core / fringe) | H x8 (core / fringe) | Hr x8 (core / fringe) |
|---|---|---|---|---|---|
Nova glow (ENEMY_NOVA_GLOW_REMAP) |
1746.1 | 2.60 (1.51 / 2.81) | 1.46 (1.10 / 1.02) | 3.13 (1.37 / 4.14) | 1.62 (1.18 / 1.10) |
far stars (FAR_STARS_REMAP) |
35.8 | 3.92 (2.85 / 8.36) | 1.03 (0.94 / 1.07) | 5.95 (4.18 / 17.46) | 2.04 (1.98 / 2.10) |
low-HP border (LOW_HP_BORDER_REMAP; H = SL by construction, post-tonemap layer 20) |
5528.5 | 3.50 (2.69 / 4.37) | 1.21 (1.36 / 0.94) | 3.50 (2.69 / 4.37) | 1.21 (1.36 / 0.94) |
death dissolve (DISSOLVE_REMAP) |
692.7 | 4.34 (2.21 / 6.44) | 1.51 (1.16 / 1.37) | 1.77 (0.79 / 2.96) | 0.69 (0.58 / 0.53) |
weapon zone, fade ~0.47 (WEAPON_ZONE_REMAP, ships OFF; x8 mixes the full-life RGB gap, read the relative-fade table below) |
1065.3 | 1.70 (1.54 / 1.69) | 0.65 (0.59 / 0.64) | 1.02 (1.01 / 1.00) | 0.45 (0.49 / 0.38) |
glass, pause menu, whole panel (GLASS_GLOW_REMAP, ships OFF; core / fringe here are the grey BODY, read the glow-only table below) |
2599.3 | 13.37 (1.90 / 13.01) | 13.31 (1.90 / 13.04) | 4.41 (1.58 / 4.52) | 4.23 (1.57 / 4.40) |
Follow-ups (not in this batch). Recommendations, all unmeasured unless stated:
- Glass grey body (core 1.7-2.3x, “milky cards”): NOT fixed by the remap. Cause:
glass_surface’s authored sRGB constants (mix(base, vec3(1.0), mix(0.13, 0.035, t)), the rim0.22 + 0.4 * focus, the top edge0.25, the bottom shade) land unconverted in a linear buffer, andglass_clear’sbody_a0.72 is a DARK tint (the dark-overlay class, wherepow(a, g)goes the wrong way). Recommendation: a separate design + plan: (a) a globalui_canvas_linear(1.0 while the root is linear, pushed next tohdr_alpha_gamma), (b) convert the sheen / rim / edge mix weights to their linear-canvas equivalents when it is set (decodebaseandbodyto sRGB space, mix, re-encode), (c)glass_clear’s 0.72 tint via the inverse remap1 - pow(1 - a, g). Shader edit on every menu: A17 baker check required. - RGB-falloff family (every
fx/weapons/*.gdshaderandfx/boss/warden_*.gdshader:COLOR = vec4(col * falloff, 1.0), incl. Orbit shards and Rift Bolt): alpha is 1.0, sohdr_alphacannot reach it. Needs a per-shaderhdr_falloff(w)on the intensity scalar and Chris’s eye. - Dark dims / veils (pause dim, level-up veil,
ScreenFeedback.VignetteFrame, outlines,surface.scrim): need the inverse remap1 - pow(1 - a, g), a second helper. Never give themhdr_alpha. - Covered backdrop / zones under-shoot (arena backdrop 0.37x, zones 0.47x of the 8-bit light:
a^gis exact only over black). Candidate: a per-layer gamma below 2.2 on the backdrop; Chris’s eye on L0 SDR. - Funzo shaders (
render/funzo_*.gdshader):funzo_zone_fieldcarries alpha past 1 into rgb, so it needs its own design. - Drone panel glow (
ui/drone_panel.gdviaDroneSprites.draw): glow and body are twodraw_texture_rectcalls on the SAME CanvasItem, so a remap material would also hit the body; the glow needs its own CanvasItem first.
Before and after must come from the SAME run: across runs (and a full run vs AF_ONLY) the 8-bit
reference moves a few percent and the fringe up to ~25% (far stars: fringe 9.61 here, 7.25 on a
reviewer re-run of the same commit), while within one run every row repeats exactly. So each of Tasks
2-7 re-measures its item’s WHOLE row (S8 total, SL, SLr, H, Hr; off and on) in one run and rewrites the
row, never fills only its pending cells against these numbers.
The low-HP border draws on CanvasLayer 20, after the tonemap/grade (the environment’s canvas max layer
sits below it), and reads no profile-dependent global before Task 4; after it, both profiles feed the
same ALPHA_GAMMA 2.2. So its H row equals SL by construction and is not separate HDR evidence: an
on-device HDR look at the border stays a human check.
Nova HDR gain candidates (switch on, ColorProfileHdr.ENEMY_NOVA_GLOW; seed stays 0.5 until Chris picks):
Hr.75 and Hr1.0 rows, baseline (switch absent) 4.00 (1.53 / 5.25) and 4.80 (1.65 / 6.32).
Nova glow (Task 2). render/shaders/nova_glow_mix_mat.tres (a separate saved copy on cs_mix.gdshader,
so ColorSpace.swap_layers never matches it); ArchetypeRenderer.refresh_nova_glow_remap() owns the
glow’s material and runs from main._apply_color_profile (the frozen-sim path) and every colour-space swap.
The pre-fix glow drew with no material only while colour_space_fix is off (the store default); with the
fix on, swap_layers already gave it ColorSpace.CS_MIX, whose cs_mix.gdshader ends
COLOR.a = hdr_alpha(COLOR.a), so that path was already remapped and the switch changes nothing there
(switch off + fix on still gets CS_MIX). Measured in one run (AF_ONLY=nova_glow, switch off then on):
S8 total 1746.1 / dark -388.3 (S8r identical, maxdiff 0); SL 4537.2 / -498.9; SLr 2556.7 / -427.6;
H 5461.5 / -5.1; Hr 2830.3 / -6.7; every floor 0, S8b repeats S8. The SL fringe drops 2.81 to 1.02 (the
8-bit falloff); the HDR fringe drops 4.14 to 1.10, not below 1 as the audit’s estimate (0.28-0.71) had it. The audit’s harness
started from a different baseline (audit HDR fringe 1.51 vs this harness 4.14), so the absolute figures do not
compare; the within-run ratio (/3.8 audit vs /5.4 here) is the comparable one.
HDR gain candidates with the remap on (switch ON, ENEMY_NOVA_GLOW HDR): 0.5 (seed) 1.62 (1.18 / 1.10),
0.75 2.09 (1.34 / 1.46), 1.0 2.50 (1.47 / 1.83). The seed is NOT changed here: Chris picks on the ATV in
Task 8 (set_tunables ColorProfileHdr.ENEMY_NOVA_GLOW), then the value is committed.
Far stars (Task 3). gated in far_stars.gdshader; ShaderGlobals.push sends bool switches as 1.0 / 0.0 floats.
far_stars.gdshader includes hdr_alpha.gdshaderinc and ends far_stars_remap > 0.5 ? hdr_alpha(fa) : fa
on the dot / streak alpha; the global far_stars_remap (declared 1.0 in both project files) reads
ColorProfile{Sdr,Hdr}.FAR_STARS_REMAP. Later shader switches add a row to SWITCHED in
tests/test_alpha_remap_switches.gd. Measured in one run (AF_ONLY=far_stars, switch off then on):
S8 total 35.8 / dark -0.1 (S8r identical, maxdiff 0); SL 140.4 / -0.1; SLr 37.1 / -0.0; H 213.2 / -0.0;
Hr 73.2 / -0.0; every floor 0, S8b repeats S8. The SL fringe drops 8.36 to 1.07; the HDR fringe 17.46 to
2.10 (Hr stays ~2x the SDR 8-bit total with core and fringe alike, i.e. a uniform level offset, not
the falloff shape). Explanation: ColorProfileHdr.FAR_STARS = 0.3 is a tint gain, 1 + (4.93 - 1) x 0.3 = 2.18x,
a colour gain that lifts core and fringe equally, so it is not an alpha issue. Confirm by re-capturing with
FAR_STARS 0 (Task 8 candidate). Warp streaks go through the same remapped alpha but are unmeasured (the
harness runs with the warp off); check them by eye in Task 8.
Low-HP border (Task 4). alpha only: low_hp_border.gdshader ends
float ar = low_hp_border_remap > 0.5 ? hdr_alpha(min(a, 1.0)) : a; (the clamp: a = glow * mist * alpha can exceed 1, mist peaks ~1.6; an 8-bit target clamps source alpha but the float root does not, so an unclamped a^2.2 would BRIGHTEN the near-death rim; the harness at HP 0.1, a <= 0.72, does not exercise a > 1) and COLOR = vec4(cs_in(glow_color) * a, ar);
global low_hp_border_remap (declared 1.0 in both project files) reads ColorProfile{Sdr,Hdr}.LOW_HP_BORDER_REMAP.
Measured in one run (AF_ONLY=low_hp, switch off then on): S8 total 5528.5 / dark 0.0 (S8r identical,
maxdiff 0); SL 19329.5 / 0.0; SLr 6685.2 / 0.0; every floor 0, S8b repeats S8. The SL fringe drops 4.37
to 0.94. H / Hr equal SL / SLr (layer 20 is post-tonemap and both profiles feed the same ALPHA_GAMMA
2.2), so they are not HDR evidence. Rejected: remapping BOTH factors (vec4(cs_in(glow_color) * ar, ar),
the plan’s first form, an a^4.4 falloff that holds only over black with a pure 2.2 decode) measured
SLr 0.47 (0.69 / 0.21), about half the 8-bit light: the 8-bit add is in sRGB-encoded space over a
non-black scene, with the sRGB toe, so its falloff is shallower. The remaining core 1.36 is mostly the
colour_space_fix domain the harness holds off (the red read as linear on the linear canvas), left alone here.
Death dissolve (Task 5). death_dissolve.gdshader includes hdr_alpha.gdshaderinc and ends
float da = a * tint.a * (1.0 - progress * 0.25); and COLOR = vec4(col, dissolve_remap > 0.5 ? hdr_alpha(da) : da)
(plain alpha class: blend_add adds col * alpha, so the alpha alone carries the falloff); global
dissolve_remap (declared 1.0 in both project files) reads ColorProfile{Sdr,Hdr}.DISSOLVE_REMAP, distinct
from the dissolve_edge gain. Measured in one run (AF_ONLY=dissolve, switch off then on, progress pinned
0.3): S8 total 692.7 / dark -0.2 (S8r identical, maxdiff 0); SL 3005.0 / -0.4; SLr 1045.6 / -0.5;
H 1226.7 / -0.1; Hr 477.5 / -0.1; every floor 0, S8b repeats S8. The SL fringe drops 6.44 to 1.37. HDR
undershoots after the fix: Hr 0.69 of the 8-bit light (fringe 2.96 to 0.53), i.e. the HDR dissolve now
reads dimmer and tighter than 8-bit SDR. ColorProfileHdr.DISSOLVE_EDGE only lights the burning edge ring; the
0.69x is the disc BODY, which no HDR gain setting covers today. Task 8 candidate: an HDR body gain for the
dissolve (new tunable, unmeasured), not added here.
Weapon zones (Task 6): the switch exists and ships OFF in both profiles. weapon_zone_field.gdshader
includes hdr_alpha.gdshaderinc and ends
COLOR = vec4(edge_rgb + body_rgb, weapon_zone_remap > 0.5 ? hdr_alpha(fade) : fade); global
weapon_zone_remap (declared 0.0 in both project files) reads ColorProfile{Sdr,Hdr}.WEAPON_ZONE_REMAP,
both seeded false (one of the batch’s two exceptions with the glass glow, pinned by SHIPPED_OFF in
tests/test_alpha_remap_switches.gd). The alpha is only the life fade (smoothstep(0.0, 0.25, v_frac): 1.0
for the first 75% of a zone’s life, where the remap is a no-op), one scalar over the whole zone. A
single-frac x8 mixes that with the full-life RGB gap, so the harness’s weapon_zone item stages three fracs
in one run (wz_full frac 0.5 = fade 1.0, weapon_zone 0.12 = fade 0.470, wz_tail 0.06 = fade 0.145) and
prints each row’s tail as a fraction of the SAME row’s full-life total (rel=) and that over S8’s (vs8=,
1.00 = fades like 8-bit). One run, AF_ONLY=weapon_zone, every floor 0, S8r = S8 (maxdiff 0) at all three:
| row | full-life total (x8) | fade 0.470 rel (vs8) | fade 0.145 rel (vs8) |
|---|---|---|---|
| S8 (target) | 2655.8 (1.00) | 0.401 (1.00) | 0.089 (1.00) |
| SL (off) | 4790.4 (1.80) | 0.379 (0.94) | 0.109 (1.22) |
| SLr (on) | 4790.4 (1.80) | 0.144 (0.36) | 0.010 (0.11) |
| H (off) | 1992.1 (0.75) | 0.547 (1.36) | 0.183 (2.05) |
| Hr (on) | 1992.1 (0.75) | 0.239 (0.60) | 0.016 (0.18) |
Result: in SDR the unremapped linear fade already tracks 8-bit (0.94 / 1.22); the remap over-steepens it
(0.36 / 0.11, the zone pops out early). The zone’s linear-SDR excess (1.8x of 8-bit for its whole life) is
the RGB falloff gap, out of this batch’s scope. In HDR the unremapped tail lingers 1.4-2x (pre-existing);
the remap over-corrects (0.60 / 0.18). [Likely] why: the 8-bit curve here goes as about a^1.2 (lit backdrop,
encoded-space add), not the a^2.2 that hdr_alpha assumes over black. A milder HDR-only exponent (its own
float tunable, not the shared hdr_alpha_gamma) is a Task 8 candidate, unmeasured.
Glass glow (Task 7). Only the focus glow is remapped; the core column is the grey body, unchanged by design (out of scope: see the plan’s Scope section).
ui/theme/glass_common.gdshaderinc (shared by glass_clear / glass_frost / glass_snapshot) includes
hdr_alpha.gdshaderinc and writes the glow as
vec4(accent.rgb, glass_glow_remap > 0.5 ? hdr_alpha(g * 0.55) : g * 0.55); body_a is untouched (0.72 on
glass_clear is a dark tint, where pow(a, gamma) would thin the darkening; 1.0 on frost / snapshot).
Global glass_glow_remap (declared 0.0 in both project files) reads ColorProfile{Sdr,Hdr}.GLASS_GLOW_REMAP,
both seeded false: the switch ships OFF, the batch’s second exception after the weapon zone (pinned by
SHIPPED_OFF in tests/test_alpha_remap_switches.gd), kept for the Task 8 on-device A/B. tools/shader_check/compile_check.gd now also matches glass_common.gdshaderinc
(glass_clear’s own source names no other marker, so its material was NOT compile-checked before: 71 -> 72).
Measured in one run (AF_ONLY=glass, pause menu, every floor 0, S8r = S8 maxdiff 0, S8b repeats S8). The
whole-panel row barely moves (SL fringe 13.01 -> SLr 13.04): its S8 masks are dominated by the grey body
(mix(base, white, 0.13) is a linear-space constant on a linear canvas), so the recipe’s fringe check cannot
see the glow. dark= (the body darkening the scene): S8 -4543.0, SL -9077.4 / SLr -9087.9, H -10389.5 / Hr
-10450.9, i.e. the linear body darkens ~2x the 8-bit amount, unchanged by the switch (out of scope). The
harness therefore also prints glass-glow lines: the pixels inside each FOCUSED panel’s backdrop quad but
outside its rect (41171 px; the main panel has focus 0.20, a 36x36 panel at the viewport origin focus 0.35),
binned by distance past the panel edge (b0 1-2 px, b1 2-5, b2 5-9, b3 9-14, b4 14+). glow = signed sum
over the region; bN = that bin over the same row’s b0 (the glow’s own core, i.e. the falloff shape); vs8 =
bN over S8’s bN (1.00 = falls off like 8-bit):
| row | glow sum (x8) | b1 vs8 | b2 vs8 | b3 vs8 | b4 vs8 |
|---|---|---|---|---|---|
| S8 (target) | 144.49 (1.00) | 1.00 | 1.00 | 1.00 | 1.00 |
| SL (off) | 527.61 (3.65) | 1.10 | 1.18 | 1.31 | 1.74 |
| SLr (on) | 13.14 (0.09) | 0.75 | 0.37 | 0.03 | -0.94 |
| H (off) | 595.19 (4.12) | 1.09 | 1.16 | 1.27 | 1.70 |
| Hr (on) | 16.13 (0.11) | 0.76 | 0.39 | 0.13 | -0.18 |
Result: the remap OVER-CORRECTS in both profiles, so it ships OFF. Unremapped, the linear glow is 3.65x
(SDR) / 4.12x (HDR) the 8-bit light with a mildly wider falloff (1.1-1.7x past the core); remapped it is
0.09x / 0.11x and far too tight (the glow is gone past ~9 px), further from 8-bit than off in total and in
shape. On tvOS the focus halo is a focus affordance, so shipping it on would be a UX regression. Why
[Likely]: the glow is a normal alpha blend of the accent A over the backdrop B. Over BLACK the 8-bit
contribution is a^2.2 A_l, so hdr_alpha is exact there; over a LIT backdrop the 8-bit contribution grows
about linearly in a (slope ~2.2 B_e^1.2 (A_e - B_e)), so linear / 8-bit is roughly a constant factor, and a
power crushes it (edge alpha 0.2 x 0.55 = 0.11, a^2.2 = 0.0078: a 14x cut against a ~3.5x excess). An encoded
backdrop of ~0.13-0.15 (the veiled frozen frame) reproduces both the 3.65x and the remapped ~0.1x.
Follow-up candidates (all UNMEASURED; Task 8 or a new item):
- Exact, backdrop-aware fix for
glass_frost/glass_snapshot, which already hold the backdrop in-shader: compute the 8-bit resultdec(mix(enc(B), enc(A), a))and solve a scalara_eff = (target_lum - B_lum) / (A_lum - B_lum), clamped, on a linear canvas only (gated onhdr_alpha_gamma != 1.0so 8-bit stays an exact no-op). Caveat: the snapshot backdrop is blurred, so B is approximate at sharp edges. glass_clear(HUD, no screen read) cannot know B: a scalar gain tunable on the linear canvas (g * 0.55 * k, k ~0.27 SDR / ~0.24 HDR from these numbers) fits the lit case far better than a power, and under-corrects over pure black.- Measure a focused level-up card (focus 1.0, edge alpha 0.55) as well as the pause menu before choosing.
HDR brightness sources (HDR/SDR colour systems step 3)
Section titled “HDR brightness sources (HDR/SDR colour systems step 3)”Spec docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md § Rollout 3; plan
docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step3.md. Render-only; nothing in /sim reads
it. SDR is the pre-step-3 game byte for byte: every SDR profile value is the old literal, multiplier
sources are exactly 1.0, and GDScript sites multiply by gain / CONST (exactly 1.0 in SDR);
tests/test_color_profiles.gd SDR_BUNDLED pins them.
-
One table, one maths.
render/hdr_tier.gdHdrTier.SOURCESis the source of truth (read it, the values are not copied here): each key is a static of the same name in BOTHColorProfileSdrandColorProfileHdr, with a kind, a draw path and (kind M) a reference literal. In the HDR profile a value is a fractionfof headroomM(HdrLook.headroom= screen max linear ×PEAK_CALIBRATION): the linear peak isP = 1 + (M − 1) · f. The HDR profile forced on an SDR screen hasM = 1, so every peak is 1.0 (a dev preview, not a bug). -
Kinds.
F(fraction): fedP, converted for its draw path (below).M(multiplier on a shader’s own literal): fedP / REF, so 1.0 in SDR leaves the shader’s literal exact. These are the family shader globals.X(absolute value): the static as is in both profiles (the UI glow cap, the glass backdrop cap, andUI_FLASH_GAIN, a multiplier ongold.brightfor the meta-shop buy flash, not a clamp).
-
Draw paths: how a linear peak P becomes the fed value. Engine facts, verified in the fork (
~/Claude/godot-tvos/godot, 2026-10-09):renderer_canvas_render_rd.cpp:693-694,2427-2428,2647-2648:modulate,draw_*, polygon and rect colours are linearised withsrgb_to_linearon an HDR canvas.canvas.glsl:176-177: vertex attributes are linearised.canvas.glsl:254-255: instancing colours, i.e. MultiMesh AND GPUParticles2D (TYPE_PARTICLEStakes the same instancing path,.cpp:2802-2814), are multiplied in RAW.renderer_canvas_cull.cpp:2201-2205and.cpp:554: PointLight2D colour is stored and used raw.
Path What Fed value for linear peak P Amodulate,draw_*,source_color, Line2D, Sprite2D modulateS(P)(the engine linearises it)BMultiMesh instance COLOR, cs-converted plain uniforms S(P)whileColorSpace.active, elsePRparticle emit colours, shader scalar globals/uniforms, light energy PS(P)is the exact inverse of the engine’s sRGB curve (ColorSpace.linear_to_srgb):1.055 · P^(1/2.4) − 0.055, orP · 12.92below 0.0031308. The engine’s((S + 0.055) / 1.055)^2.4then givesPback exactly, so a white-hot gain lands on its target peak (tests/test_hdr_tier.gdround-trips it throughColor.srgb_to_linear). A gain multiplies a colour BEFORE the engine linearises the product, so a colour channel below 1 still lands a little off its proportional share (the curve is not a pure power); white-hot terms, the main consumers, are exact. The first cut fed a barepow(P, 1/2.4), which under-delivered 4-6% on white and 8-11% on a 0.5 channel at M 2.67 / 4.93, more at higher headroom (final review M1). -
The cache and
generation.main._apply_color_profile()is the ONE re-apply: it invalidates, recomputes (bumpinggeneration), re-applies the environment and shader globals, and pushesfar_stars.refresh_hdr()/arena_bg.refresh_hdr()(neither has a per-frame poll a frozen sim reaches). Its callers: run start (afterRemoteConfig.apply_for_runand after the far stars and backdrop are built), a LiveScope HOOK_ENV edit, a profile change (_sync_hdr_output: thecolor_profilecommand, the HDR output toggle), the tonemap selectors, andmain._process, which callsHdrTier.refresh(HdrLook.active, HdrLook.headroom, ColorSpace.active, _hdr_clock_s())every frame (cheap: it compares inputs) to catch the changes only it sees, a headroom drift or a colour_space_fix flip. Drift alone is thresholded AND rate-limited: it re-applies only pastHdrTier.HEADROOM_EPS(relative, 0.02) and at most once perHdrTier.HEADROOM_REAPPLY_S(0.25 s) of main’s clock, so an iPhone auto-brightness ramp re-applies ~4x/s instead of every frame and its end lands within one interval; profile, colour-space and invalidate changes are never delayed. Both are live local tunables (final review LOW-2;tests/test_hdr_headroom_drift_main.gd). Pinned by the main-booted tests intests/test_color_profiles.gd(live edit, SDR→HDR→SDR flip, run start). A never-computed cache (code running before main’s first refresh, e.g.ShaderGlobals.pushor UI at boot) computes the SDR cache on first read, so the SDR caps never bite by accident. A Mac’s live brightness slider moves the headroom, so a ramp still re-applies several times a second: consumers comparegenerationand keep their rebuild cheap, and hot paths readgain()ONCE per sync/draw. -
Glow knee (
render/environment_look.gd). SDR: the profile’s absoluteGLOW_HDR_THRESHOLD/GLOW_HDR_SCALE. HDR: both are fractions of headroom,glow_hdr_threshold = 1 + (M − 1) · thresholdandglow_hdr_scale = (M − 1) · scale(HdrTier.glow_threshold/glow_scale). The old absolute ATV-seeded knee (2.5) sat near the top of a Mac’s 2.67 headroom, the likely reason HDR glow barely showed there. -
Family shader globals.
wfx_hot,wfx_accent,boss_hot,dissolve_edge(kind M) andglass_cap(kind X) are declared inrender/shaders/hdr_globals.gdshaderinc(include guard; Godot 4.7 also dedups a repeated include) and in[shader_globals]of both project files at the SDR value;render/shader_globals.gdShaderGlobals.pushsets them, pushing an HdrTier source as its FED value. Shaders multiply ONLY their white-hot terms by them. -
Consumers. Enemy
ArchetypeRenderer(shell/halo/hot-core gains, plusENEMY_NOVA_GLOWfor the default Nova enemy look’s glow layer, a source added beyond the original plan),SwarmRenderer(player and enemy shots, gems, the aim lance: its hot core readsSWARM_NOVA_HOTunder the Nova look,ENEMY_HOT_COREwith it off),fx/fx_manager.gdwhite_core()/hit_flash_color(),fx/enemy_aura_look.gd,fx/weapons/plasma_nova_node.gd,fx/gpu_burst.gd/fx/thruster_trail.gd(sparks, path R),render/player_renderer.gd(hero glow/flash),render/far_stars.gd/render/arena_background.gd(refresh_hdr(), pushed frommain._apply_color_profile), and the UI caps inui/level_up_panel.gd,ui/meta_shop_panel.gdand the glass shaders (ui/theme/glass_*.gdshader, viaglass_cap). -
Adding a source: a static of the same name in BOTH
color_profile_sdr.gd(today’s literal, or 1.0 for a multiplier) andcolor_profile_hdr.gd(a fraction); register both innet/tunables_registry.gd(the SDR and HDR blocks, never"local"); a label intools/tunable_docs/docs.d/color_profiles.json(group “Glow & colour grade”), thennode tools/tunable_docs/generate.mjs; aSOURCESrow with its kind and path; aSDR_BUNDLEDpin; and in the consumer readHdrTier.gain(key)once per sync/draw, rebuilding onHdrTier.generation. A new shader global also needsShaderGlobals.GLOBALS, the include and both[shader_globals]blocks (;comments only), then the windowed compile check under both renderers. -
Lint (
tests/test_shader_hdr_lint.gd). A statement-based scanner overrender/,fx/,ui/shaders (skippingfx/weapons/candidates/, an archive): a white-hot literal (>= 1.6) in a colour statement, or in a scalar that feeds one, in either* LorL *form, must be scaled by a family global or carry// sdr-hdr: <reason>. Literals inside function calls are shape/coordinate arguments and are ignored. The 1.6 threshold deviates from the spec’s 1.0 (flagged to Chris). A second test requires everyvec3/vec4uniform named*col*/*color*/*tint*to besource_color, read throughcs_in(, or exempt with// sdr-hdr:. -
Comparing. The console freeze scene (
render/freeze_scene.gd) now also holds a ring of real enemy types (FreezeScene.SWARM,SWARM_RINGpx from P1, unkillable), since enemies are what steps 2 and 3 change most.tools/cs_capturewithCS_PROFILE=hdr CS_HEADROOM=<M>captures SDR, HDR, SDR on the canvas (pre-display) and prints the brightest pixel, the share above 1 / 2.7 / 4.93 and mean |Δ|; it engages HDR through main’s own test seams (a detached window +PEAK_CALIBRATION), becausemain._sync_hdr_outputre-decidesHdrLookevery frame. -
Known, left alone: additive stacking can still exceed the headroom where many hot hits overlap (a highlight density governor is a separate item);
HeroLightsenergies are unchanged (lights brighten bodies, which stay at reference).
Display P3 output — ColorGamut (2026-10-12, plan 2026-10-11-hdr-p3-palette, default ON with HDR)
Section titled “Display P3 output — ColorGamut (2026-10-12, plan 2026-10-11-hdr-p3-palette, default ON with HDR)”With HDR display output engaged, saturated gameplay colours (element palette, looks constants, shader
literals) are pushed from the sRGB edge to the Display P3 edge, and the engine presents in P3. Since
2026-10-12 the UI widens too, by its own dial (see “UI widens through ONE path” below). Spec
docs/superpowers/specs/2026-10-11-hdr-p3-palette-design.md; plan + SDD history
docs/superpowers/plans/2026-10-11-hdr-p3-palette.md (where the ledger and this section differ from
the plan, this section is what shipped).
- Engine property
Viewport.output_gamut(0 = sRGB, 1 = Display P3). Added by our fork,tvos-4.8devata8d5c0bb62(patch mirrored inplatform/engine-patches/0001-p3-output-gamut.patch): the mobile tonemapper (tonemap_mobile.glsl, the path the game uses) and the blit gain a P3 branch that clamps in P3 instead of 709. iOS AND tvOS both run the patched 4.8 build. Stock Godot (Mac editor runs, the gate, web on gl_compatibility, Android) has no setter, soColorGamut.engine_capable()(ClassDB.class_has_method(&"Viewport", &"set_output_gamut"); ClassDB has noclass_has_property) is false there and nothing below engages.render/gamut_output.gd(GamutOutput) is the only door that reads/writes it (main.gamut_output, a test swaps a fake in). - AgX note (plan Finding 2). Stock AgX already returns negative components (out-of-709 colour) and
BCS saturation 1.35 can push in-sRGB colours negative; the old 709 blit clipped them, the P3 blit
lets them through to the P3 edge. So
GAMUT_P3on withGAMUT_WIDEN0 is not byte-identical to the pre-P3 image under AgX: “today’s look” isGAMUT_P3off. AgX and ACES (channel-mixing, 709 fitted) run astonemap(p3_to_srgb * p3_clamped), which by linearity is the P3-input inset; the naive “raw P3 into the 709 inset” skews hue. Reinhard / Filmic / Linear are per-channel and run in P3. ColorGamut(render/color_gamut.gd). Pure maths inPackedFloat64Array(float32Colorbreaks a 1e-5 round trip): OKLab/OKLCH, linear sRGB <-> linear P3, a 32-step chroma-boundary search.widen(c, amount, encoding)keeps OKLCH hue + lightness and moves chroma from its relative position inside the sRGB boundary to the same relative position on the P3 boundary, blended byamount; neutrals (chroma < 1e-4) never move; overbright colours widen their unit-brightness chromaticity and scale back. Pure sRGB blue/yellow gain ~nothing; red, green, cyan, magenta gain most.- One decision:
active()= HDR output engaged AND engine capable ANDon().on()=ColorProfileHdr.GAMUT_P3unlessoverride(-1 follow, 0/1 = the Debug row / console session choice).GAMUT_P3/GAMUT_WIDENare HDR-only profile statics and remote tunables (tools/tunable_docs/docs.d/color_profiles.json); seedstrue/1.0(Chris’s verdict below). refresh(engaged, capable, cs_active)runs every frame; a change ofactive, or (while active) of the dial orColorSpace.active, clears the per-path caches and bumpsgeneration. Owners of cached colours (main’s element LUTs,GamutGlobals.push, looks) comparegeneration.out(c, path)returnscitself while inactive (byte-identical to pre-P3), otherwise the widened colour for that draw path, cached per path (CACHE_CAP4096, cleared when exceeded).- Widen once, from the sRGB source.
widen()is not idempotent: never widen an already widened value (e.g. a previous LUT entry). Main builds_element_color_lutfromcolor_for_srgb()and_element_color_vividasvivid(srgb)THENout().
- One decision:
- Draw paths (
HdrTier.PATH_*), as corrected in practice. One widened value cannot serve every path, because the engine treats them differently on the linear HDR canvas:- PATH_A (engine linearises:
modulate,draw_*,source_coloruniforms, Line2D, AND shaders that lineariseCOLORthemselves onhdr_linear, likezone_field.gdshader): fed the encoded widened value. The zone buffer is PATH_A (the plan said B; Task 6 review). - PATH_B (MultiMesh instance
COLOR, uniforms that go throughcs_in()): encoded whileColorSpace.active, linear otherwise. So PATH_A == PATH_B whilecolour_space_fixis on: a test pinning a call site to PATH_B must run with cs off AND on or it cannot tell A from B. - PATH_R (raw/linear: particle emit colour,
Light2D.color, shader literals mixed in raw): linear. Every Task 8 shader literal turned out PATH_R, not the plan’s B: onlyCOLOR/ material tints reachcs_in(); the literals are mixed in afterwards, raw. - Far-star per-Dimension tint is a MULTIPLIER, not a colour: never widened (matches
arena_background.gd/ ArenaBorder).
- PATH_A (engine linearises:
main._sync_output_gamut(want)runs inside_sync_hdr_output(), AFTER the HDR hold settles the root and_sync_color_spaceruns (so PATH_B keys on THIS frame’sColorSpace.active), with the samewantthat drives the window request, the QualityManager hold andHdrLook. Only whenrefresh()reports a change does it writeoutput_gamutthroughgamut_output(and only on a capable engine). Becauseactive()needs HDR engaged,output_gamutdrops to 0 the frame HDR disengages (the patched P3 tonemap runs whenever it is 1). While thegamut_swatchdev proof (render/gamut_swatch.gd, Dev-only) is up it owns the property: the decision goes into_swatch_prev_gamut, which the swatch restores when it leaves the tree. A change also re-runs_apply_color_profile().- Shader literals: the
gp_*globals. 28 saturated literals (render/gamut_globals.gdGamutGlobals.GLOBALS, all PATH_R) moved into global uniforms declared inrender/shaders/gamut_palette.gdshaderinc(include-guarded). Each is declared in[shader_globals]of BOTHproject.godotandplatform/tvos/project.godotat its old literal (the export baker fails on runtime-only globals; in the tvOS file comments must be;, a#swallows the next section).GamutGlobals.push()(from_apply_color_profile, once perColorGamut.generation) sends the literal while inactive and the widened value while active. Near-whites (OKLCH chroma < 0.08) stay literal.tests/test_gamut_globals.gdchecks both projects, the include and the converted shaders. - UI widens through ONE path:
ColorGamut.ui_out(Chris 2026-10-12, reversing the 2026-10-11 “UI stays sRGB” rule). Detail and the binding rules:docs/architecture/ui-tokens.md§ HDR Display P3.- Gate + dial. Same gate as gameplay (
active(): HDR engaged, P3 engine,on()), its own HDR-only dialColorProfileHdr.GAMUT_UI_WIDEN(seed 1.0, console Tunables › Glow & colour grade). The UI has its own dial because it is NOT graded: UI CanvasLayers (HUD 5, menus 10-82, debug 128) sit aboveEnvironment.canvas_max_layer(0), andRendererViewport::_draw_viewport(4.8 fork,servers/rendering/renderer_viewport.cpp~L727) runs the tonemap/glow/adjustments pass (render_empty_scene) right after the last layer <= canvas_max_layer, so higher layers draw onto the finished image: no tonemap, noSATURATION1.35, only the final blit’s P3 clamp. A widened UI colour therefore reaches the screen exactly as fed. - Path. UI Controls /
draw_*/modulate/source_coloruniforms are PATH_A, soui_out(c)defaults to ENCODED;ui_out(c, HdrTier.PATH_B)for a UI MultiMesh throughcs_in()(the threat radar’s sharp lights).refresh()keeps the UI state separate: a UI-dial change bumpsColorGamut.ui_generation, nevergeneration(no gameplay LUT rebuild), and never makesrefresh()return true. - The token filter. While the UI amount > 0,
LiveTokens.set_output_filter(ColorGamut.ui_out)is installed, so every public token colour read (Tokens.c,LiveTokens.color/value, TokenBind key sources, NeonTheme) is widened, cached per key; installing/removing it re-applies every TokenBind binding, so built screens restyle live. Inactive = no filter at all: SDR / 8-bit / web / stock-engine reads are bit-exact (tests/test_ui_gamut.gd). - Guards (
tests/test_color_gamut.gd):ui/never callswiden()/out(); every file readingTokens.c_srcalso callsui_out(or hands it to thenova_boxsink); nothing butrender/color_gamut.gdinstalls a token filter. The web console’s#rrggbbelement colours stay sRGB (ElementPalette.color_for_srgb, they are shown in a browser).
- Gate + dial. Same gate as gameplay (
- Light area swarmer tinge: a 64-hue LUT. Each Light swarmer has its own golden-ratio hue; widening
each would be a cache miss per spawn (~50 us on a Mac) and flood the PATH_B cache until a 4096 wipe
made every live swarmer miss in one frame. While active, main builds
SWARMER_TINGE_BUCKETS(64) widened hues at bucket centres (max 2.8 deg off); inactive keeps the exact per-entity colour. - Switch + console. Debug Settings row “HDR wide gamut P3 (A/B)” (
hdr_gamut, live, inNO_REPUSH_GRAPHICS) = console{"kind":"graphics","effect":"hdr_gamut","on":...}. The change note says why ON does nothing: “(no P3 engine)” or “(HDR output off)”./status(run and menu) carriesgamut: {on, engine, active};tools/console_mcphdr_ab --toggle hdr_gamutrefuses unless HDR is engaged andgamut.engineis true. Live tunables:ColorProfileHdr.GAMUT_P3/GAMUT_WIDEN. - tvOS exports on 4.8 by default (Chris, 2026-10-12):
scripts/dev/install-dev.shandscripts/release/build-tvos.shdefault to~/Claude/godot-tvos/godot-48like iOS;BH_TVOS_GODOT/GODOT_TVOS=~/Claude/godot-tvos/godot/bin/godot.macos.editor.arm64falls back to the 4.7 fork (no P3). The exporting editor MUST match the shell’s engine lib (the pack’s baked shaders come from the editor). - Device results (2026-10-12, BUILD 310, baked, patched 4.8): iPhone 15 Pro Max and Apple TV. The
BUILD 306 swatch proof passed first (sRGB pairs identical, P3 side deeper, so canvas negatives reach
the display). A17 shader-baker check (godot#124088) passed on the iPhone: menu controls + ship draw.
/status: HDR on (headroom 7.2), gamut on/engine/active,output_gamut1. iPhone ABBAhdr_gamut: on 70.06 fps vs off 70.59 (sd ~18-20, n ~115 each, 42% scene drift), inconclusive. ATV (AppleTV11,1) ABBAhdr_gamut, 4 x 20 s, gamut active confirmed: on 59.78 fps (sd 1.20) vs off 59.94 (sd 0.32), inconclusive, both at the 60 fps cap. The plan’s <= 0.1 msrender_cpu_msbudget was NOT measured: telemetry samples don’t tag the gamut state, so the two sides can’t be split. fps shows no visible cost on either device. Chris’s verdict (ATV): “everything on looks best, brightness and colour all good”, so seeds stayGAMUT_P3 = true,GAMUT_WIDEN = 1.0; no remote-config patch. Open next: the glows are a bit soft, especially on the player. - Tests:
tests/test_color_gamut.gd(maths, truth table, caches, UI-path guards),test_ui_gamut.gd(UI widening: off states bit-exact, every read path, live restyle, main’s per-frame path, derived mixes),test_live_tokens_filter.gd(the addon’s output filter),test_color_gamut_main.gd(decision, engine property drops with HDR, swatch contention, cs-flip order,/status, Debug row + change notes),test_color_gamut_looks.gd(per-call-site path pins),test_element_palette_gamut.gd,test_gamut_globals.gd,test_gamut_swatch.gd,test_hdr_tier.gd.
Ship skins — ShipSkin (2026-10-15, plan 2026-10-15-beta-skin)
Section titled “Ship skins — ShipSkin (2026-10-15, plan 2026-10-15-beta-skin)”Cosmetic, render-only skins (spec 2026-10-14-app-clip-warden-design.md § Beta skin). render/ship_skin.gd
is a child of PlayerRenderer (added right after the baked hull _baked, before _hull/_flash): an
overlay Sprite2D that copies _baked’s texture, transform and visibility every frame
(PlayerRenderer.update_visual → ShipSkin.update(_baked, tilt, dt)) and repaints it with
render/shaders/ship_skin.gdshader (saved ship_skin_mat.tres, duplicated per instance). Because it copies
whatever the hull draws, it applies over every hull, the Nova hulls and tilt-flipbook frames. One shader,
four looks selected by the look uniform (one pipeline: prewarm family 3b): 1 Prototype wireframe (the alpha
edge as the outline, plus panel seams in WIRE_COLOR over WIRE_FILL, scanline), 2 Holo-film (iridesce() from
render/shaders/thin_film.gdshaderinc, a verbatim copy of the Supernova’s, pinned equal by
tests/test_ship_skin_shader.gd; the Supernova shaders were deliberately not edited, A17 baker risk),
3 Glitch build (RGB split, pulse() on a warp or hard hit from main’s edge detects widens it and slides
rows for GLITCH_JITTER_S), 4 Void chrome (black body + reflection bands, one rim lit from a fixed sprite-space
direction, up to GHOST_CAP violet afterimages on hero_glow_mix_mat, world-fixed, normal tree children so
a hidden ship hides them). Output alpha goes through hdr_alpha(); colour uniforms through cs_in() and
are pushed as ColorGamut.out(c, PATH_B); every colour/gain has an *_HDR twin picked by HdrOutput.on().
Filtered sampling, not per-texel reads. The baked hulls are 512 px with no mipmaps and draw at ~30 px
in a run (~15 texels per screen pixel), so any per-texel luminance term aliases into speckle: a plain
gradient seam detector filled the hull solid cyan and a phase taken from per-texel luminance turned the
film into rainbow confetti (2026-10-15 captures). The wireframe’s seams compare the pixel’s own luminance
(3x3 box over its footprint) with a coarse local mean (4x4 taps over WIRE_PANEL_SCALE sprite UV, never under
~1.5 screen px), in linear light on both canvases: a narrow groove much darker than its surroundings is a
seam, a wide dark area is not. On the 8-bit canvas the shader linearises the taps itself, keyed on the
linear_canvas uniform = the drawing viewport’s use_hdr_2d (pushed by ShipSkin.push_uniforms, the same
source as zone_renderer’s hdr_linear), not cs_linearize: that is the colour-space fix’s flag and stays
false on a linear canvas while the fix is off (the shipped default). The film’s phase follows an 8x8-tap mean
over HOLO_SMOOTH (4x4 left a repeating alias in the 220 px picker thumbnail) plus a diagonal HOLO_SWEEP and
the bank, so hue bands are broad and banking slides them. Its spectrum goes through cs_in() like every colour
here, so it is linearised (more saturated on the linear canvas) only with the colour-space fix on; with the fix
off it stays raw, as the rest of the game’s colours do, and reads paler on the linear canvas.
HOLO_SAT_HDR (1.5) offsets that and the HDR profile’s greying. Cost per pixel: wireframe ~30 taps (1 + 4
edge + 16 mean + 9 footprint), Holo ~74 (1 + 64 field + 9 footprint), on a ~30 px sprite in a run or the
220 px picker thumbnail on the menu (~3.5M taps a frame for Holo there; not measured on device).
Which look draws: ShipSkin.LOOK (Dev builds only; store builds ignore it) else the equipped skin
(BETA_LOOK for “beta”) else nothing. Known limits: the Dev-only SHADER_WARP tilt mode does not warp the
overlay; WarpAfterimages ghosts show the base hull; only pilot 1’s PlayerRenderer (skinnable = true, set by main) has a skin, co-op P2 never does. The hull picker previews
the equipped skin on its thumbnail with the same material (colours via ColorGamut.ui_out; the shader multiplies the sprite’s modulate RGB in, so a locked hull’s dim survives). Capture harness: tools/skin_capture/. Pinned by
tests/test_ship_skin.gd, tests/test_ship_skin_main.gd, tests/test_ship_config_skin_row.gd.
Player death sequence — DeathSequence (2026-10-11, RELEASE-1.0.1 §3b)
Section titled “Player death sequence — DeathSequence (2026-10-11, RELEASE-1.0.1 §3b)”The ship explodes between the death tick and the results card. Beat (real seconds): 0.0 s hit, hull flickers and cracks while in-flight FX run at 0.25x and the camera pushes in to 1.3x; 0.5 s BOOM: white-hot core, a soft full-screen flash, a fireball, a chain of secondary blasts, a fire ring that bursts past the top and bottom of the screen, shards + sparks + embers, on-screen enemies blown back and dissolved, all in slow motion; 2.2 s fire dies to embers, camera eases back; results once the last element has lived out its life (3.81 s at the defaults), with the fade over the dim tail. The carried crystals spin off into space with iridescent sparks. Ramped up 2026-10-11 from the first 1.5 s cyan version (Chris: “huge explosion with lots of firey colour and slow motion effect and zoom in a bit”).
-
Two clocks.
_tis real time. Everything after the BOOM (ring, fire, core, shards, sparks, embers, enemy fade, dissolves viatime_scale()) reads_particle_t, the explosion clock: real time xboom_time_scale()(BOOM_SLOWMO_SCALE0.3 forBOOM_SLOWMO_HOLD_S, eased to 1 overBOOM_SLOWMO_RAMP_S). The full-screen flash and the shake stay on REAL time: on the slowed clock the flash lingered as a murky brown wash. Tests/harnesses convert withreal_s_after_boom(). -
Fireball.
FIRE_PUFF_COUNTGlowTexturepuffs thrown out with drag, plusSECONDARY_COUNTsmaller blasts (a short white pop +SECONDARY_PUFFSpuffs each) staggered overSECONDARY_SPREAD_S. Each puff is two offset, wobbling blobs (so the edge billows) coolingFIRE_HOT->FIRE_MID(by age 0.18) ->FIRE_COOL. The puffs overlap, soFIRE_ALPHAandFIRE_BOOSTare kept low: at full alpha and the general glow boost the fireball clipped to a white disc instead of reading orange. -
Crystals fly off into space. main hands
sim.crystals.to_dict()tostart();crystal_hues()turns the wallet into one fully saturated hue per crystal (ELEMENT_HUES, sorted so a wallet always throws the same pattern).CRYSTAL_PERspinning diamond shards per crystal (at leastCRYSTAL_MIN, capped atCRYSTAL_MAX; full spectrum when none are carried) fly out with almost no drag, so they leave the screen. Each shimmersIRIDESCENT_HUE_SWINGround its element hue and flashes white as a facet turns;IRIDESCENT_SPARK_COUNTspark streaks cycle the whole spectrum. Brightness has SDR/HDR twins (CRYSTAL_BOOST*); the hue itself comes from the element. -
Wiring.
main._post_tick’ssim.game_overbranch calls_begin_death_sequence()instead of_show_results_when_drawn(); the node’sfinishedsignal (natural end or a skip) opens the results through the same one-drawn-frame wait, guarded onsim == runso a restart mid-explosion never shows stale results.DeathSequence.ENABLEDoff is the old straight-to-results path. The node is a per-run child (swept by_sweep_run_children). -
Render-only. The sim stops ticking on the death tick, and the sequence only gets plain data (origin, visible size, on-screen enemy positions + colours) and render nodes. “Slow motion” is
main._death_fx_time_scale()scaling the dt fed tofx_layer,death_dissolveandwarden_fxuntil the boom. “Blown back” is a scale-about-the-blast transform + fade on the enemy render layers (swarm, elite bars/fx, enemy bullets, every boss renderer, aura rings, damage numbers); “dissolve” isDeathDissolve.spawn_atper on-screen enemy as the ring reaches it. -
The whole player kit goes with the hull. Orbit blades (both looks), turret bodies (both looks), decoy drones, player bullets/bolts/aim lance, momentum and scar-tissue meters, the thruster trail, warp afterimages and the HeroLights player aura are passed as
kitand hidden at the BOOM (and on a skip). Their syncs sit after_process’s game-over return, so otherwise they freeze at the blast point, fully visible, through the results backdrop. Everything inkitis rebuilt by_new_run. The kit also covers Chalice v2 pools (chalice_look+chalice_host) and the Stasis Web card. ZoneRenderer draws the player’s zones AND enemy hazards, so it is not hidden whole: thehide_player_zoneshook flipsZoneRenderer.hide_player_zones, which drops Thunder Wake / Wildfire zones and the legacy Chalice hexagon + Chronowell bubbles while spider webs, FunZo zones and reaction terrain stay. Railgun mark reticles (fx_layer.mark_layer) sit on enemies, so they are pushed and faded with the enemy layers.tests/test_death_sequence.gdpinssim.state_checksum()unchanged across the whole sequence. -
Clock. Real seconds from
main._process(clamped 0.1 s/frame), advanced just above the game-over early return and held while the voice-feedback overlay is up. -
Skip. Any key / joypad button / touch / click after
SKIP_GRACE_Sends it (_input, the press is marked handled so it does not also hit the results or pause). -
Quality. The white flash, camera shake,
ShockwaveLayerripple andScreenPunchare full-screen, so they play only whileScreenFeedback.enabled. That is shed by the QualityManager ladder at L3+ and by the Dev-only Debug Settings “Screen FX” row, but in STORE builds the ladder is off (QualityManager.ENABLED = false, pinned L0) and Debug Settings is hidden, so players ALWAYS get them. The defaults are kept gentle for that reason: a 0.35 peak flash (0.3 HDR) that is gone in 0.22 s, 8 px shake, 0.6 punch. One flash, well under the 3-per-second photosensitivity limit. Ring, shards, sparks, embers, core and fractures are neon-first procedural draws underfx/additive_mat.tresand always play. The player’s Settings “Reduce shake & flashes” (ComfortFx.reduce,render/comfort_fx.gd) turnsfull_fxoff for the death too, so with it on only the procedural draws play. -
Reduce shake & flashes (Settings, RELEASE-1.1).
ComfortFx.reduce(persisted asMetaState.reduce_fx) is the one player-facing comfort switch. When on: hit camera shake is 0 (ComfortFx.REDUCED_SHAKE), and the red hit vignette,ScreenPunchaberration and the NUKE / BOSS DOWNShockwaveLayerripples and the SupernovaSpacetimeWarpLayerwarp (per-blasttrigger(..., amount)) are scaled byComfortFx.REDUCED_FLASH(0.35); the death sequence’s full-screen pieces are skipped. The scales are consts on purpose (a remote patch must not undo a player’s comfort choice). Not covered: the low-HP border glow (slow breathing, not a flash), the warp streaks, the gravity lens and FunZo’s carnival warp. -
No new shader.
PrewarmPassstep 21b draws one sequence mid-boom so its additive primitive pipelines are warm before the first death. -
Co-op. The ring goes off at the last pilot standing (
DeathSequence.pick_origin_indexover each pilot’s hp on the frame before the death tick); every ship’s hull flickers and goes. -
Colour. Colour-bearing knobs carry SDR + HDR twins (
*_HDR), picked byHdrOutput.on()at the death. LDR targets (gl_compatibility / web, or a tier that drops HDR 2D) would clamp overbright per channel to white, soDeathSequence.glow_color()scales the boosted colour by its brightest channel there instead: the ring, shards and embers keep their hue on the web. -
Size. The ring’s final radius is
RING_RADIUS_FRACx the view’s SHORTER side (0.42). At the 1.3x push-in that bursts past the top and bottom on 1280x720 and iPad 4:3 but still turns inside the longer side, so it reads as a ring (pinned bytest_default_ring_is_huge_but_still_a_ring). -
Results wait for the whole explosion (2026-10-13, Chris: “make it so the death explosion shows fully before the run over screen”).
explosion_end_s()(explosion clock) is the max of every component’s delay + life, top of each random range, skipping zero-count components; today the in-fire glass slivers (SECONDARY_SPREAD_S + GLASS_LIFE= 2.4) are last.visual_end_s()converts it through the slowed clock (+WINDUP_S, and the real-time flash/shake): 3.71 s.sequence_end_s()= max(TOTAL_S, that +SETTLE_S) = 3.81 s (was a flat 3.2 s, which faded a still-busy frame from 2.6 s and cut crystals and glass mid-flight). Computed at eachstart(), so console edits to counts and lives move it from the next death. TheFADE_Sfade ends at the results, so it runs over the tail, which captures show is effectively empty by ~3.2 s (the bound is conservative);SETTLE_S >= FADE_Sfades only an empty frame. The fireball’s spawn shape literals are consts (FIRE_IGNITE_JITTER_S,SECONDARY_*) shared with the bound. Pinned bytest_the_results_wait_for_the_last_element(real spawn, several origins). -
Tuning. Every knob is a remote-config tunable, console group “Death sequence” (
docs/architecture/remote-config.md). Windowed capture:godot --path . --rendering-method mobile res://tools/death_capture/capture.tscn.
Colour-blind mode (issue #78)
Section titled “Colour-blind mode (issue #78)”Spec docs/superpowers/specs/2026-10-20-colourblind-mode-design.md, plan docs/superpowers/plans/2026-10-20-colourblind-mode.md.
Player-facing Settings row “Colour vision”: Off / Deutan / Protan / Tritan, saved as MetaState.cvd_preset.
CvdPalette(render/cvd_palette.gd) owns the preset and agenerationcounter. Values live in two independent tables,CvdPaletteSdrandCvdPaletteHdr(60 colours each: 13 elements + THREAT + 3 HP bands + 2 UI roles per preset), both Remote Console tunables. HDR is authored independently, not derived from SDR; a test fails if they match.CvdPalette.sync_profile(HdrLook.active)picks the table.bump()callsColorGamut.bump_generation(), so every colour cache that polls the gamut generation rebuilds (includingEnemyAuraLook).- No-vivid rule. Under a preset the element/weapon colours bypass vivid():
ElementPalette.color_for_srgbreturns the table value andElementPalette.vivid_srgb_for(the one path main’s element LUT build uses) skips the second pass, because the vivid() saturation boost would pull the hand-placed hues back together. A NEW element colour must go throughElementPalette.color_for_srgb; a NEW hostile colour throughCvdPalette.threat_out. - Shape cues (
CvdPalette.cues_on()= a preset is active, or the dev A/BCUES_FOR_ALL): a hollow ring layer under hostile shots (SwarmRenderer.enable_ring,RingTexture, additive_mat; this is a new MultiMesh+texture variant, so it needs the baked-iPhone A17 check); saw-tooth rims and dashed warning lines on hostile zones/bombs/Lancer charge/FunZo (render/cvd_cues.gd, tunablesCvdCues.*); a dashed shock aura ring; HP-bar notches at 30% / 60%. - Verification:
tools/cvd_sim/(Machado 2009 severity 1.0 on linear sRGB, OKLab dE x100;cvd.py,sheet.py,palette_opt.py,cvd_sim.gdfor GUT), and the opt-incvd/cvd_hdrscenes oftools/ui_capture/capture.gd. - Console:
cvd_presetdev command and the “Colour vision” row (session only, look-only).