Graphics performance plan — hold ≥50fps while looking as good as possible
Graphics performance plan — hold ≥50fps while looking as good as possible
Section titled “Graphics performance plan — hold ≥50fps while looking as good as possible”Status: Items 1, 2a, 3, 6 (free tier), 7 IMPLEMENTED + shipped @ BUILD 52 (2026-06-25).
Deferred: Item 4 (platform entity budget — gated on a fresh on-device ATV re-measure; the
cycle-21 140-cap already designs away the failing case) and Item 6 full palette overhaul
(subjective hue re-spacing — wants Chris’s eye + bible.json coordination). All shipped items are
render-side only → survival determinism baseline 2300319179 byte-identical throughout.
Author: Opus (2026-06-25). Driven by real ATV telemetry (build 50, 50Hz Apple TV 4K 2nd-gen / A12).
Context (what the data told us)
Section titled “Context (what the data told us)”- The Apple TV is a 50Hz panel → vsync caps fps at 50 → frame budget is 20ms.
- Build 50 (adaptive graphics) holds 50fps at <250 enemies (80–97% of frames) and dynamically restores fidelity, but at 250–340 enemies even the lowest tier (L5) can’t hold 50 (46%, dips to 31fps). The bottleneck there is the un-toggleable core cost of drawing ~340 entities (~16ms render) — fidelity tiers are exhausted.
- The other agent already cut
SOFT_ENEMY_CAP340 → 140 (+LETHALITY_MULT) in cycle 21. Peak ~140 lands in the band that already held ≥50fps, so the failing case is largely designed away. → Re-measure first. The items below are ordered by value GIVEN the 140 cap.
Determinism rule (applies to every sim-touching item)
Section titled “Determinism rule (applies to every sim-touching item)”The survival determinism baseline (state_checksum 2325839371) is a fixed Sim.new(seed,content)
run that sets NO platform/perf flags. So any new perf knob MUST default to a true no-op (×1.0 /
null) and be set only by main (render-side) AFTER construction — same null-object seam as
enable_story and meta.apply_to. Then the baseline is byte-identical by construction. Re-run the
determinism test after each chunk regardless.
Item 1 — Player weapons always render (HIGH value, LOW effort, render-only) ✅ do first
Section titled “Item 1 — Player weapons always render (HIGH value, LOW effort, render-only) ✅ do first”Problem: weapon visuals route through FxManager, which the quality system disables at L5 — so
at the lowest tier your pulse bolt, beam, blade slash, and nova ring vanish. (Orbit shards + turret
bodies + scatter/turret projectiles are NOT fx and already stay on.)
Answer to “can they be changed so they always stay on”: yes. Keep the pooled-fx infrastructure;
just exempt weapon-feedback event kinds from the quality gate.
- In
FxManager.consume, split the event kinds:- Essential weapon feedback (ALWAYS spawn):
bolt(pulse),beam,slash(blade),novaring. - Disposable juice (gated by
enabled+ caps):death,reaction,chain,pickup.
- Essential weapon feedback (ALWAYS spawn):
- Replace the top-level
if not enabled: returnwith a per-kind check (juice kinds short-circuit; weapon kinds always run). - Revise the QualityManager tier table: L5 = “juice fx off”, NOT “all fx off”. Weapon feedback is never in the turn-off set. (Damage numbers stay gated — they’re not weapon feedback.) Cost: weapon fx are a few/sec vs up to 8 death pops/frame — keeping them on is cheap. Determinism: render-only, untouched.
Item 2 — Cut halo/glow overdraw so the NEON look survives at a cheaper tier (HIGH value, LOW-MED effort, render-only)
Section titled “Item 2 — Cut halo/glow overdraw so the NEON look survives at a cheaper tier (HIGH value, LOW-MED effort, render-only)”This is the “improve graphics without losing performance” lever — make the cheap tiers look better so the adaptive system can sit higher.
- The additive halo layer is the single biggest overdraw source.
SwarmRendererdraws a second MultiMesh atHALO_SCALE = 3.0→ each halo quad is 3× wide = 9× the pixel area, additive, for EVERY entity. At 140 enemies that’s a huge fill cost, and it’s why the quality system turns halos off so early (L2).- 2a. Lower
HALO_SCALEto ~1.8–2.0 (4× area, not 9×) — keeps the glow, ~halves halo fill. - 2b. Or selective halos: only big/important entities (bosses, elites, the player, gems) get a halo; swarmers don’t. Most overdraw comes from the many small swarmers, which don’t need individual glows. Big perceptual win-per-pixel.
- 2a. Lower
- 2c. Bake the glow into the core sprite so entities read as neon WITHOUT a halo pass or
full-screen bloom: the swarm already uses
GlowTexture(soft radial). Give the core quad a soft glowing texture/edge so “it glows” is intrinsic. Then bloom (WorldEnvironment, full-screen, constant cost) and the halo layer become optional polish rather than load-bearing for the look — letting the device hold a richer baseline at 50fps. (The 50Hz tax means bloom rarely fits anyway.) Determinism: render-only. Effort: 2a trivial; 2b/2c moderate. Recommend 2a + 2b first, evaluate 2c.
Item 3 — Viewport culling of the swarm (MED value, MED effort, render-only, determinism-safe)
Section titled “Item 3 — Viewport culling of the swarm (MED value, MED effort, render-only, determinism-safe)”SwarmRenderer.sync currently uploads + draws ALL pool.count instances (core + halo) every frame,
on/off-screen alike.
- Compute the camera’s visible rect (
camera.global_position±viewport_size/2 / zoom+ a margin for entity radius/halo). Camera follows the player; arena is ±2000. - In
sync, skip entities outside the rect, compacting visible ones into instance slots0..j, theninstance_count = j. Colors stay indexed by pool index; write with a separate output cursor. - Where it pays off most: gems (they litter the whole arena where enemies died → many off-screen) and projectiles. Enemies CHASE the player so most cluster on-screen — culling helps less for the enemy swarm specifically (be honest: not a silver bullet for the chase-swarm, but still trims spawn-ring + kiting stragglers).
- Apply to all three swarm renderers (enemy / proj / gem); gems are the clear win. Determinism: render-only (reads positions, changes nothing). Risk: a too-tight margin pops entities at screen edge — size the margin to max entity radius × halo scale.
Item 4 — Platform-aware entity budget: fewer + deadlier on weak hardware (MED value post-140-cap, MED effort, sim-touching)
Section titled “Item 4 — Platform-aware entity budget: fewer + deadlier on weak hardware (MED value post-140-cap, MED effort, sim-touching)”What you asked for. With the 140 cap this is now a fine-tune for the weakest devices, not a necessity — confirm need from the re-measure first.
- Detect the system:
OS.get_name()+OS.get_model_name()already identify the device (our telemetry logstvOS/AppleTV11,1). Map to a coarse perf class (high = desktop/web/A15+, low = A12 ATV). Keep a tiny known-device table; default unknown → high. - Self-calibrating safety net (recommended): the QualityManager already knows when it’s pinned at
L5 and STILL under 50fps (fidelity exhausted). Persist that observation (in the
user://save) and drop the perf class a notch next launch — so unknown weak hardware auto-detects without a device table to maintain. Set the budget per-RUN at start (not mid-fight) to avoid enemies thinning out mid-combat. - Apply (determinism-safe): add
Sim.enemy_budget := 1.0(andSim.lethality_scale := 1.0)._spawn_enemiesusesint(SOFT_ENEMY_CAP * enemy_budget)for the cap and scales contact damage by1/enemy_budget(×lethality_scale) so fewer-but-deadlier keeps the threat ≈ constant.mainsets these from the perf class BEFORE the run; the determinism test never sets them (stays 1.0, exact IEEE-754 no-op) → baseline holds. NoteLETHALITY_MULTalready exists — compose with it. - Tune “a little”: start at
enemy_budget ≈ 0.8on the A12 ATV (140 → ~112) with damage ×1.25. Let telemetry confirm it holds 50 and the feel is right. Keep it subtle per your steer.
Item 5 — Cheaper specifics (LOW effort, opportunistic)
Section titled “Item 5 — Cheaper specifics (LOW effort, opportunistic)”- Pre-render the arena grid to a texture instead of per-frame
draw_line(minor; already quality-gated). - Auto-drop halos when
enemies.countexceeds a threshold (a targeted version of Item 2, independent of the global tier) — cheap insurance for big packs.
Item 6 — Ultra-neon look: saturation, HDR-feel, colour differentiation, extra effects
Section titled “Item 6 — Ultra-neon look: saturation, HDR-feel, colour differentiation, extra effects”Goal: maximally saturated, ultra-neon, HDR-feeling, with every colour clearly differentiated — WITHOUT losing the 50fps fight. The win is choosing techniques whose cost is ~free or scales with the tiers.
Renderer reality (decide this first — it gates the “HDR” parts)
Section titled “Renderer reality (decide this first — it gates the “HDR” parts)”- Project runs
gl_compatibilityon BOTH web and tvOS (project.godot). - True HDR 2D (
rendering/viewport/hdr_2d, colours >1.0 → bloom catches the overflow) needs the Forward+ or Mobile (Vulkan) renderer. The web build can ONLY use Compatibility (no Vulkan in browsers), so a true-HDR pipeline is NOT portable, and moving the A12 ATV onto a heavier Vulkan renderer works against the 50fps goal. Recommendation: stay on gl_compatibility and fake the HDR look (below). A renderer switch (e.g. Mobile on tvOS only) is possible but: breaks web parity, needs full re-test on all platforms, and likely costs ATV fps — only revisit if the cheap path proves insufficient. Verify empirically what 2D glow / tonemap / colour-adjustment the gl_compatibility renderer actually supports in Godot 4.6 before relying on any Environment post-effect (support has improved version-to-version but is still narrower than Forward+).
Free / cheap (portable, ON even on the ATV — pursue these first)
Section titled “Free / cheap (portable, ON even on the ATV — pursue these first)”- Palette overhaul for max differentiation + saturation (DATA, free): redesign the element + enemy
colours (
bible.json) so every hue is perceptually distinct and maximally saturated. Space hues evenly around the wheel in OKLCH (perceptually uniform) so no two read alike (today’s teal-vs-green near-collisions go away), push chroma/lightness to neon levels, all on a near-black field for contrast. Colours are render-only (sim hashes element indices, not colours) → determinism-safe. Coordinate with the other agent — they’re editingbible.jsonnow; do this after their cycle 21 lands. - Hot cores + saturated additive halos = the “bloom” on this renderer. Push the core toward white-hot and the halo toward the saturated element hue (additive). This is the HDR-glow look without an HDR buffer. (Compose with Item 2 — baked glow / selective halos so it stays cheap.)
- Near-black background + dark grade so neon pops (max perceived saturation comes from contrast).
- Hit-flash: enemies flash bright white for ~1 frame when damaged (drive off the existing
dmgnum/ damage path, render-only). Cheap, massively improves impact/readability.
Richer, tier-scaled (high tiers only; the adaptive system sheds them first on weak HW)
Section titled “Richer, tier-scaled (high tiers only; the adaptive system sheds them first on weak HW)”- Environment glow + tonemap + saturation grading IF gl_compatibility supports it:
tonemap_modeACES/AgX for filmic highlights,adjustment_enabledsaturation/contrast bump (one cheap full-screen pass),glow_hdr_thresholdtuned so only hot cores bloom. Gate at a high tier (it’s full-screen cost). - Additive trails on projectiles + the player (short ribbon). Pure overdraw → strictly tier-gated.
- Chromatic aberration at screen edges (tiny fragment shader) — premium sci-fi feel; tier-gated polish.
- Reaction colour pops / brief screen tint on big elemental reactions (extends existing reaction fx).
- Subtle dither / film grain (cheap shader) — smooths neon gradients so they don’t band on 8-bit output; also masks the lack of a true HDR buffer.
- Region / dominant-element background tint that slowly shifts the field colour by what you’re fighting.
- Boss-spawn screen-edge pulse + brighter boss aura for “oh no” readability.
Cost discipline
Section titled “Cost discipline”Saturation, palette, tonemap-grade, hit-flash, baked glow = ~free and ALWAYS on (incl. ATV). Trails,
chromatic aberration, extra additive layers, full HDR bloom = overdraw → wired into the QualityManager
tiers so the ATV sheds them under load while desktop/high-end keeps the full show. New dmgnum-style
fx kinds must follow Item 1’s split (essential vs juice) and get a tier gate.
Determinism: all of Item 6 is render/data only; colours + post are not in the checksum.
Item 7 — Player look + level-up visual evolution (HIGH delight, MED effort, render-only)
Section titled “Item 7 — Player look + level-up visual evolution (HIGH delight, MED effort, render-only)”Today the player is built INLINE in main.gd (lines ~279–292): one Polygon2D triangle + an additive
glow Sprite2D. Goal: a better-looking player whose sprite/visual upgrades as the level climbs — a
felt reward for progressing.
- Extract into
render/player_renderer.gd(Node2D) so the look is owned in one place and can manage level tiers + the quality gate. main just feeds itsim.playereach frame (position/rotation/level). - Level-tier evolution, keyed on
sim.player.level(render-only → determinism-safe). Bands e.g. L1–4 → L5–9 → L10–14 → L15–19 → L20+. Each tier accretes detail: simple dart → finned interceptor → multi-hull craft → ornate flagship; plus growing glow, extra orbiting accent orbs, a hotter/brighter core, and a stronger thruster plume. Crossing a tier could pop a brief flash/ring (“ascension”) for payoff. Smooth/lerp the transition so it doesn’t snap. - Two ways to do the “sprites”:
- Procedural neon (recommended): draw the hull as polygons + additive glow, adding facets/accents per tier. Zero art pipeline, dead-on the abstract-neon aesthetic, cheap, and the ornamentation can itself be quality-gated (ATV drops accent orbs/trail under load; the core hull always stays). Fits Item 6 — make the player the single most HDR-neon thing on screen.
- Authored sprite sheet: one PNG per tier (could be generated via the image-gen tooling). More literally “new sprites,” but heavier, needs an asset pipeline, and risks clashing with the abstract vector look. Offer as optional polish on top of the procedural base.
- Build-reactive accent (nice-to-have): tint the player’s accent/glow by dominant element or by weapon evolutions taken, so the craft visibly reflects YOUR build, not just level.
- Determinism: reads
player.level(render only); changes nothing in the sim.
Recommended order (after re-measure)
Section titled “Recommended order (after re-measure)”- Re-measure build ≥51 (post 140-cap rework). Tells us how much perf headroom the look can spend.
- Item 1 (weapons always-on) — independent of perf, fixes a clear bug. Ship regardless.
- Item 6 free tier (palette differentiation + saturation/grade + hit-flash + hot cores) — the biggest look upgrade per watt; ~free and portable. Do the palette AFTER the other agent’s bible.json edits land. Decide the renderer question here (recommend: stay gl_compatibility).
- Item 2a + 2b (halo overdraw / selective halos / baked glow) — lets the device hold a richer tier.
- Item 7 (player evolution) — high delight, render-only; procedural-neon version first.
- Item 3 (culling) — if gems/projectiles still cost; mainly a story-mode / spread win.
- Item 4 (platform budget) — only if the re-measure still shows sub-50 dips on the A12.
- Item 6 tier-scaled extras (trails, chromatic aberration, dither, region tint) — last, as polish that the QualityManager sheds first on weak hardware.
Guiding principle: spend the perf headroom the 140-cap rework frees up on the FREE look wins (palette, grade, hit-flash, player evolution) before any new overdraw; gate every new additive effect behind the tiers so the ATV degrades gracefully and desktop/web get the full ultra-neon show.
Test/verify per chunk (bh-dev-chunk ritual)
Section titled “Test/verify per chunk (bh-dev-chunk ritual)”TDD the pure bits (QualityManager tier table change, perf-class mapping, enemy_budget cap math via
a seam that doesn’t touch user://). Render changes verified by boot-check + on-device telemetry
(the qual a/m + per-enemy-band fps split). Re-run determinism after Items 1–5; only Item 4 touches
sim, and only behind the enemy_budget != 1.0 seam.