Skip to content

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.

“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): a PRESET_FULL_RECT ColorRect running a 4-sine-term shader, changed 2026-07-16 (commit 3b6a29b) from a hard visible toggle to “always visible, alpha- driven” — advance() never touches .visible or checks enabled. 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, no TIME, no external input of any kind — output is byte-identical every frame. It’s only “gated” via enabled, 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
  • MSAA/SSAO/SSR/TAA/shadow-atlas/VRS — none configured anywhere, confirmed by direct inspection.
  • Thermal throttling — render_ms stays 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’s config/features tag 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.
  1. 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.
  2. 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.
  3. 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.
  4. Medium, needs an A/B measurement first: HeroLights on/off comparison before deciding whether to gate it or wire it into the ladder.
  5. 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.
  6. Structural decision needed from Chris: whether to re-enable ADAPTIVE_QUALITY now (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.