Skip to content

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

  • 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_CAP 340 → 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), nova ring.
    • Disposable juice (gated by enabled + caps): death, reaction, chain, pickup.
  • Replace the top-level if not enabled: return with 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. SwarmRenderer draws a second MultiMesh at HALO_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_SCALE to ~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.
  • 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 slots 0..j, then instance_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 logs tvOS/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 (and Sim.lethality_scale := 1.0). _spawn_enemies uses int(SOFT_ENEMY_CAP * enemy_budget) for the cap and scales contact damage by 1/enemy_budgetlethality_scale) so fewer-but-deadlier keeps the threat ≈ constant. main sets these from the perf class BEFORE the run; the determinism test never sets them (stays 1.0, exact IEEE-754 no-op) → baseline holds. Note LETHALITY_MULT already exists — compose with it.
  • Tune “a little”: start at enemy_budget ≈ 0.8 on 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.count exceeds 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_compatibility on 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 editing bible.json now; 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_mode ACES/AgX for filmic highlights, adjustment_enabled saturation/contrast bump (one cheap full-screen pass), glow_hdr_threshold tuned 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.

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 it sim.player each 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”:
    1. 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.
    2. 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.

  1. Re-measure build ≥51 (post 140-cap rework). Tells us how much perf headroom the look can spend.
  2. Item 1 (weapons always-on) — independent of perf, fixes a clear bug. Ship regardless.
  3. 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).
  4. Item 2a + 2b (halo overdraw / selective halos / baked glow) — lets the device hold a richer tier.
  5. Item 7 (player evolution) — high delight, render-only; procedural-neon version first.
  6. Item 3 (culling) — if gems/projectiles still cost; mainly a story-mode / spread win.
  7. Item 4 (platform budget) — only if the re-measure still shows sub-50 dips on the A12.
  8. 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.