Cheap Threat Radar (Option B) — Design
Cheap Threat Radar (Option B) — Design
Section titled “Cheap Threat Radar (Option B) — Design”Problem
Section titled “Problem”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.
Non-goals
Section titled “Non-goals”- No
/simchanges —ThreatMap.compute()’s per-sectorscore/angledata andThreatMap.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
GlowTextureadditive-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.
Design
Section titled “Design”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.
Removed entirely
Section titled “Removed entirely”- The second
SubViewport(_mist_viewport,UPDATE_ALWAYS) and itsTextureRectdisplay — 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’sBORDER_SHADER). - A soak sanity check (no invariant failures) and a real on-device telemetry comparison
(
samplestable, before/after) as the actual acceptance test — matching how the original performance regression was FOUND in the first place.