Skip to content

Cheap Threat Radar (Option B) — Design

The current per-enemy threat radar (ui/threat_radar.gd, shipped BUILD 173) correlates with a real, measured FPS regression on Apple TV — see docs/research/2026-07-18-threat-radar-cost-redesign-research.md for the full telemetry finding and technique survey. Its cost (primitive count + a second SubViewport render pass) scales with live enemy count, up to ~800 quads / 400+ draws at high swarm sizes. Chris directed an adversarial investigation into cheaper alternatives; the chosen direction (Option B) combines a fixed-cost analytic aggregate-glow shader with a small, bounded set of individual “worst threat” markers.

  • No /sim changes — ThreatMap.compute()’s per-sector score/angle data and ThreatMap.enemy_weight()’s per-enemy O(1) cost already exist and are reused as-is.
  • Not bringing back per-enemy sharp dots for EVERY enemy — Chris explicitly confirmed “we dont definitely need every single dot.” Only the top-K most dangerous get individual markers.
  • Not using GlowTexture additive-billboard overlap for the aggregate glow — already tried and rejected for this exact use case (see research doc’s cited sources on why sprite-composited glow doesn’t merge in dense clusters). The aggregate layer must be a genuine analytic shader.

Layer 1: analytic aggregate glow (replaces the SubViewport mist entirely)

Section titled “Layer 1: analytic aggregate glow (replaces the SubViewport mist entirely)”

A single shader on one screen-space mesh (extending fx/screen_feedback.gd’s existing BORDER_SHADER falloff technique — smoothstep-based distance falloff, render_mode blend_add, no SubViewport). Instead of edge-distance, the shader computes a function of ANGLE around the screen center, fed by a small uniform array of per-sector scores/angles from ThreatMap.compute() (reusing its existing sector count, e.g. 8 — no new sim-side computation).

For each screen pixel (or each point along the radar ring, if implemented as a ring mesh rather than a full-screen quad — an implementation choice for the prototype step to settle), the glow intensity is the SUM, across all sectors, of that sector’s score weighted by an angular Gaussian centered on the sector’s own angle:

float total_glow = 0.0;
for (int i = 0; i < SECTOR_COUNT; i++) {
float angle_diff = angular_distance(pixel_angle, sector_angle[i]);
total_glow += sector_score[i] * exp(-(angle_diff * angle_diff) / (KERNEL_WIDTH * KERNEL_WIDTH));
}

This produces genuine continuous blending BETWEEN adjacent sectors (a sector with a real threat naturally “bleeds” into its neighbors proportional to angular distance, exactly the effect a real blur would produce on discrete sector blobs) with ZERO additional render pass, ZERO per-enemy cost, and ZERO discrete sprite-edge seams (it’s a pure analytic function, not composited billboards — the specific failure mode the additive-overlap approach hit doesn’t apply here). KERNEL_WIDTH is a tunable constant controlling how “wide” the blend is; this is exactly the parameter to iterate on during the visual prototype step, alongside overall intensity/color, matching the spirit of the original mist’s MIST_RESOLUTION_SCALE/SCORE_NORMALIZE tuning constants.

Layer 2: top-K sharp dots (individual “this specific enemy is dangerous” markers)

Section titled “Layer 2: top-K sharp dots (individual “this specific enemy is dangerous” markers)”

A single small-capacity MultiMeshInstance2D (K≈24, a genuinely fixed cap, not scaled to swarm size), populated each frame by a single O(enemy count) sweep using the ALREADY-EXISTING ThreatMap.enemy_weight() (no new per-enemy cost), maintaining a bounded top-K via insertion into a small fixed buffer (cheap for small K — no full sort of all enemies needed). Rendered via the CURRENT sharp layer’s exact existing code path (edge_point(), the same GlowTexture-based MultiMesh pattern) — only the SELECTION changes (top-K instead of every enemy above a negligible- alpha threshold), not the rendering mechanism itself.

  • The second SubViewport (_mist_viewport, UPDATE_ALWAYS) and its TextureRect display — gone, replaced by Layer 1’s single shader pass.
  • The per-enemy mist MultiMesh (_mist_mm) — gone, same reason.
  • build_light_data()’s unbounded per-enemy sharp-instance population — replaced by the top-K selection above.

Cost ceiling (fixed, independent of live enemy count)

Section titled “Cost ceiling (fixed, independent of live enemy count)”
  • Layer 1: 1 draw call (a single shader-mesh), reading a small (≤16-element) uniform array — negligible per-pixel cost since KERNEL_WIDTH’s exponential falls off fast, no per-enemy work.
  • Layer 2: 1 draw call, ≤24 quads.
  • Total: ~2 draw calls, ≤24 quads, ZERO SubViewport passes — versus the current design’s uncapped-with-swarm-size cost (confirmed via telemetry: up to ~400+ draws, ~26,000+ prims at high enemy counts).

Visual verification plan (before writing the implementation plan)

Section titled “Visual verification plan (before writing the implementation plan)”

Per this project’s own established practice (the ORIGINAL per-enemy mist design’s visual claim was independently verified with a real capture + pixel sampling, not just trusted as described) — build a small throwaway prototype (following the tools/ship_preview-style pattern: a minimal scene booting just the radar against a synthetic enemy cluster) to directly SEE whether Layer 1’s angular-Gaussian blending genuinely reads as “a gentle wide mist” rather than a flat pie-wedge or an unconvincing smear, before committing to the full implementation plan. Tune KERNEL_WIDTH and sector count against what’s actually visible, not assumed.

Testing (for the eventual implementation plan)

Section titled “Testing (for the eventual implementation plan)”
  • Pure-function unit tests for the top-K selection helper (correct top-K regardless of input order, ties handled sanely, K larger than enemy count degrades to “all enemies” cleanly).
  • Pure-function tests for the angular-Gaussian sum, if it’s computed in GDScript for anything test-visible (the shader itself isn’t directly GUT-testable — same established limitation as every other shader in this project, e.g. fx/screen_feedback.gd’s BORDER_SHADER).
  • A soak sanity check (no invariant failures) and a real on-device telemetry comparison (samples table, before/after) as the actual acceptance test — matching how the original performance regression was FOUND in the first place.