Apple TV performance — adversarial investigation (2026-07-22)
Apple TV performance — adversarial investigation (2026-07-22)
Section titled “Apple TV performance — adversarial investigation (2026-07-22)”Trigger: after the BUILD 176 ThreatRadar idle-render fix (34.1fps→48.1fps at low enemy count,
commit 5f2b35c), Chris reported the ceiling is still only ~50fps sitting still, dropping to
~40fps with “very little action” — he expected the Apple TV hardware to manage more, and asked
for “a full adversarial workflow to investigate everything we can do to boost performance.”
Method: 8 parallel finder agents, each covering a different render subsystem (see angles below), each producing 1-4 evidence-backed candidate findings. Every candidate then went through a 3-lens adversarial skeptic panel (performance plausibility / risk-of-regression / code-accuracy) — a finding needed ≥2 of 3 skeptics NOT refute it to count as confirmed. 101 total agents, ~9.6M tokens, ~34 minutes wall-clock. 21 findings confirmed, 10 refuted (several refutations were themselves informative — see “Ruled out” below).
Full per-finding text (including verifier reasoning) is preserved in this session’s transcript; this document is the synthesized, actionable version.
Headline reframe
Section titled “Headline reframe”“50fps sitting still” is the TV’s actual 50Hz panel refresh cap, not wasted headroom — this
game can never observe fps > 50 on Chris’s Living Room Apple TV (AppleTV11,1), confirmed both
by an existing doc note (docs/architecture/rendering-and-performance.md) and live: BUILD 176
tvOS samples show max_fps=50 across every row, never higher. The real, actionable signal is the
drop from ~50 to ~40 under light load — that’s genuine per-frame budget overrun, not a display
ceiling.
render_ms (telemetry + the F2 overlay) is CPU script time, not GPU time. It’s
Performance.TIME_PROCESS — every _process()-side script cost (HUD updates, swarm-sync,
threat-radar, debug overlay) bundled with genuine GPU-adjacent work (shader param uploads,
MultiMesh writes). “Render dominates sim” is real, but doesn’t by itself prove the GPU/fill-rate
is the bottleneck — CPU-side script overhead is an equally live suspect, and needs a different
fix (reduce script work / dirty-check, not just cut shader complexity).
The F2 overlay itself was on in every one of Chris’s readings, and its own update_overlay()
does real unconditional per-frame work (an O(enemy) loop, ~14 lines of string formatting, a
Label.text reassignment forcing a full TextServer re-shape) — folded directly into the exact
render_ms number being read off the screenshots. Every fps comparison so far may be slightly
worse than the true in-game number because of the overlay measuring itself.
Do this right now — zero code, zero redeploy
Section titled “Do this right now — zero code, zero redeploy”ui/debug_settings_panel.gd already exposes controller-reachable bloom/halo/grid/screen_fx
toggles via Pause → Debug Settings, live on Chris’s current dev-install Apple TV. F4 (the only
override wired in main.gd) is keyboard-only with no joypad binding — but this panel has the
same knobs as D-pad-navigable rows, and it’s present because bh-deploy exports with the
tvOS-Dev preset (custom_features="dev"). Chris can toggle Bloom off from the pause menu
right now and see whether fps recovers, before any code changes — directly testing the
single most-suspected cost (see below) with a real on-device before/after in under a minute.
The dominant suspect: HDR bloom/glow is always on, tvOS-exclusive, and has no escape hatch
Section titled “The dominant suspect: HDR bloom/glow is always on, tvOS-exclusive, and has no escape hatch”platform/tvos/project.godot sets viewport/hdr_2d=true — a tvOS-only setting (root
project.godot has none). main.gd:679-708 then enables glow_enabled, tunes HDR glow
threshold/scale, and layers a full-screen colour-grade pass, gated only on
RenderingServer.get_rendering_device() != null (true on tvOS/Metal, false on the web
gl_compatibility build). The existing quality ladder (render/quality_manager.gd) was
explicitly designed to shed bloom FIRST (bloom_on(l) = l<=0) because it’s the single most
expensive full-screen pass on this class of GPU — but ADAPTIVE_QUALITY := false since
2026-06-28 means that shedding logic never runs, and F4 has no controller binding, so on tvOS
this pass currently cannot be turned off by any means except the Debug Settings workaround
above (dev builds only — real customers have no escape hatch at all).
Fresh BUILD 176 telemetry (post-ThreatRadar-fix, enemies<5): avg_render_ms=21.07 against a
20ms/frame budget at 50Hz — render time alone is already at or over budget doing essentially
nothing. Strong circumstantial evidence bloom, not per-entity rendering, is the dominant
remaining fixed cost — the Debug Settings toggle above is how to confirm this directly rather
than inferring it.
Confirmed bugs — same class as the ThreatRadar fix, found elsewhere
Section titled “Confirmed bugs — same class as the ThreatRadar fix, found elsewhere”Two more full-screen shaders in fx/screen_feedback.gd never adopted the visibility-gating
pattern ThreatRadar was just fixed to use (and which screen_feedback.gd itself is the cited
reference implementation for):
- Low-HP border (
_border): aPRESET_FULL_RECTColorRect running a 4-sine-term shader, changed 2026-07-16 (commit3b6a29b) from a hard visible toggle to “always visible, alpha- driven” —advance()never touches.visibleor checksenabled. Runs full-screen, every frame, for the entire game, regardless of HP. Worse than the bug just fixed — it has no gate at all, not even the broken quality-ladder one. - Vignette (
_vig_shader): its fragment shader has no uniforms, noTIME, no external input of any kind — output is byte-identical every frame. It’s only “gated” viaenabled, which is tied to the same always-stuck-at-tier-0 quality ladder, so in practice it has never turned off on tvOS. A live per-pixel shader recomputing a mathematically constant result, 40-60+ times a second, forever.
An inventory of every other full-screen shader (gravity_lens, screen_punch,
shockwave_layer, funzo_carnival_warp) confirmed they’re all correctly content-gated
(visible=false until triggered) — these two screen_feedback.gd instances are the only other
occurrences of the bug class found.
Other confirmed findings, roughly by leverage
Section titled “Other confirmed findings, roughly by leverage”| Finding | Impact | Effort | Confidence |
|---|---|---|---|
HeroLights (player’s real-time PointLight2D) always active, zero gating anywhere — not even wired into the quality ladder. Compounds with HDR (light needs use_hdr_2d to bloom) and forces an extra redraw pass on everything within its ~384px radius. |
high | small | medium |
| NebulaBg’s 0.5x SubViewport downres is relative to an uncapped native resolution — if the TV outputs 4K, “halved” could still be ~2K. Nothing in the project caps render resolution on tvOS at all. | high | small | high |
Spider-web hazards: 3.2x the draw calls of a full 58-enemy swarm, despite ~3x fewer enemies (avg 231.5 webs bucket: 1190 draws/33ms render vs webs=0 baseline: 367 draws/23.5ms). zone_renderer.gd issues 4 immediate-mode draw calls per web, unlike every entity-pool renderer, which is MultiMesh-batched. |
high | medium | high |
~20 renderer scripts (one per boss/hazard type) call queue_redraw() unconditionally every frame even when their content is entirely inactive (e.g. all 4 boss renderers redraw every frame even though only one boss is ever active per run) — CPU scheduling/re-entry cost, not GPU. |
medium | small | medium |
NebulaBg updates its SubViewport every frame (UPDATE_ALWAYS) despite explicitly “subtle drift” content — no throttling exists. |
medium | trivial | medium |
ArenaBackground has no camera-view culling (unlike SwarmRenderer, which already solved this) — draws the whole 4000×4000 arena every frame regardless of what’s on-screen; its set_low() shedding hook exists but was never wired into the (currently dead) quality ladder either. |
medium | medium | high |
viewport.use_hdr_2d is never dropped by the quality ladder even at its lowest tier — gap confirmed, but one skeptic found the “doubles backbuffer bandwidth” magnitude claim doesn’t hold on this project’s actual Mobile/gl_compatibility rendering method (RGB10_A2 is same bit-depth as RGBA8, not float) — real gap, weaker cost than first stated. |
medium | small | medium |
Hud/WeaponPanel/etc: several Label.text reassignments with no dirty-check run every frame regardless of change — including a fully-invisible-since-2026-07-16 _hp_label that’s still computed and reshaped every frame for nothing. |
low | small | high |
Ruled out (don’t re-chase)
Section titled “Ruled out (don’t re-chase)”- MSAA/SSAO/SSR/TAA/shadow-atlas/VRS — none configured anywhere, confirmed by direct inspection.
- Thermal throttling —
render_msstays flat across session length, not a heat-degradation curve. - Player ship’s own sprite/hull rendering — baked single-sprite, one draw call, halo already
disabled, procedural bevel-bake path is dead code (
USE_BAKED_HULL=true). Not the bottleneck. - Ship-tilt shader — defaults OFF (
TiltMode.OFF), not live on the current build regardless. - Ship hull PNG compression — technically true (lossless/uncompressed, no mipmaps) but judged likely-marginal impact, not prioritized.
platform/tvos/project.godot’sconfig/featurestag says"4.6"while root says"4.7"— not shown to cause any actual rendering-default drift, but flagged as worth a quick sanity check given it’s clearly stale bookkeeping.
Recommended order of attack
Section titled “Recommended order of attack”- Right now, zero cost: Chris tests Pause → Debug Settings → Bloom off on the current TV, compares F2 fps/render_ms against BUILD 176’s baseline. Confirms or refutes the #1 suspect in under a minute.
- Trivial, same-day mirror of the ThreatRadar fix: gate
screen_feedback.gd’s border + bake the vignette to a static texture. Same file, same pattern, same day’s precedent — lowest risk, fast to review. - Small, high-confidence: cap NebulaBg’s actual render resolution (log real viewport size to telemetry first to confirm the 4K suspicion), throttle its update rate, add ArenaBackground culling.
- Medium, needs an A/B measurement first: HeroLights on/off comparison before deciding whether to gate it or wire it into the ladder.
- Medium-large, real batching work: web rendering via MultiMesh, matching the swarm renderer’s existing pattern — the single largest confirmed content-driven cost in the dataset.
- Structural decision needed from Chris: whether to re-enable
ADAPTIVE_QUALITYnow (data suggests tiers 1-4 already saturate the 50Hz cap historically — only tier 0, the one permanently stuck on, doesn’t), wire a tvOS-reachable manual toggle for real customers (not just dev builds), or some combination.
Everything above is investigation only — no code was changed as part of this workflow.