Per-Enemy Threat Radar Mist — Design
Per-Enemy Threat Radar Mist — Design
Section titled “Per-Enemy Threat Radar Mist — Design”Date: 2026-07-17
Status: Approved (Chris, 2026-07-17, live in chat)
Origin: Chris played build 172/173 with the sector-based ThreatRadar (8 wide sectors,
Gaussian distance falloff, see 2026-07-17-ace-ai-pilot-threat-radar-design.md) and asked for a
rework: “make every enemy have a radar threat light and an average light effect that connects
above them all so if there are tons in one area there will be a gentle wide mist above all the
tiny lights.” Confirmed in chat: this REPLACES the sector-blob radar (not additive), stays at the
screen edge (not floating over enemies in-world — the off-screen-direction property is the whole
point of an edge radar and must survive), and should use a real blur/merge pass rather than naive
additive overlap alone (research below explains why).
Problem
Section titled “Problem”The current ThreatRadar buckets every enemy into one of 8 fixed 45° sectors and draws one
glow blob per non-empty sector (ui/threat_radar.gd). This coarsens “where is the danger” down
to 8 possible directions and one blob per direction — losing the texture of “a lot of enemies
scattered loosely across a wide arc” vs. “a lot of enemies tightly clustered in one spot,” both of
which currently render as one similarly-sized blob in the same sector. Chris wants every enemy to
register individually, with density itself — not sector membership — determining how the radar
reads: isolated enemies as small distinct lights, dense clusters as one soft merged glow.
Research: how to actually merge many small lights into “mist”
Section titled “Research: how to actually merge many small lights into “mist””Investigated before committing to an approach (full findings in chat; summary here for the
record). Confirmed via slembcke.net/blog/2DLightingTechniques/ (additive light accumulation) and
john-wigg.dev/2DMetaballs/ (metaball render-to-viewport pipeline): pure additive overlap
brightens intersection zones but does NOT smooth the outer silhouette — each light’s own
gradient falloff boundary stays visible, so a dense cluster reads as “many overlapping bright
dots,” not one continuous cloud. Genuine spatial merging needs a blur/downsample pass on top of
the additive sum — confirmed independently by the metaball literature (a threshold or blur pass is
exactly why those pipelines render to an offscreen viewport first) and directly matching this
codebase’s own render/nebula_bg.gd, which already renders into a reduced-resolution SubViewport
and displays it upscaled with bilinear filtering specifically to get a soft result cheaply. No
single published source combines “MultiMesh instancing + additive blend + downres blur” as one
named recipe — this design is a synthesis of three independently-proven techniques, not a copy of
an existing tutorial.
Non-goals
Section titled “Non-goals”- No change to Ace’s own decision-making.
SkilledAutoPlayer’s engage force / proactive dash / threat-aware aim all readThreatMap.compute()’s sector-aggregatedbest()— already tuned and fixed earlier tonight (the engage-vs-flee regression). This spec touches that file only to extract an existing formula into its own named function (see Component 1) — behavior-preserving, verified by the existingThreatMaptest suite passing unchanged. - No true metaball thresholding. Chris asked for “gentle mist,” not hard merged shape outlines — a blur/downsample pass reads as soft mist without needing an SDF threshold step.
- No new texture asset.
render/glow_texture.gd’sGlowTexture.shared()(a pre-built, cached, soft radial white→transparent texture, already used bySwarmRenderer’s own additive halo layer for exactly this look) is reused directly for both new layers. - Exact tuning constants (light radius, mist blur strength, brightness caps) are starting points,
not final. This is a visual effect — the implementation plan’s own steps include a real
capture-and-look pass (the established
_shot.gdharness) before calling any task done, same as every other visual change tonight. Expect the plan’s constants to move after actually seeing it.
Architecture
Section titled “Architecture”1. ThreatMap.enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float (extraction)
Section titled “1. ThreatMap.enemy_weight(sim: Sim, pilot: PlayerState, i: int) -> float (extraction)”ai/threat_map.gd’s compute() currently inlines this per-enemy formula directly in its sector-
accumulation loop: 1.0 + (ELITE_BONUS if elite) + (BOSS_BONUS if boss) + (hp/max_hp)*HP_LIVE_WEIGHT,
times a Gaussian distance falloff (exp(-(dist²)/(DIST_SIGMA²))). Pulled out into its own static
function so both compute() (unchanged behavior, now just calling the extracted function) and the
new radar (a genuinely new consumer) share one formula instead of two copies drifting apart. Pure,
already covered by ThreatMap’s existing per-enemy-weight test cases once the extraction lands
(re-verify those pass unchanged, not new tests needed for the math itself — the formula doesn’t
change, only where it lives).
2. ThreatRadar rewrite — two MultiMeshInstance2D layers, one shared per-frame sweep
Section titled “2. ThreatRadar rewrite — two MultiMeshInstance2D layers, one shared per-frame sweep”Every frame (update_radar(sim, pilot)), one for i in range(sim.enemies.count) sweep computes,
per enemy: ThreatMap.enemy_weight(sim, pilot, i) and its edge-projected screen position (the
existing edge_point(direction_to_enemy, vp, MARGIN) — same math the sector version already used,
just evaluated per-enemy instead of per-sector-center). Both layers below consume the SAME per-
enemy arrays built in this one sweep — no duplicate O(n) work.
- Sharp layer (
_sharp_mm: MultiMeshInstance2D, full screen resolution, drawn directly): oneQuadMeshinstance per enemy,GlowTexture.shared(),CanvasItemMaterial.BLEND_MODE_ADD— the exact patternrender/swarm_renderer.gd’s halo layer already uses. Per-instance transform = the enemy’s edge position; per-instance color alpha = its normalized weight (weight / SCORE_NORMALIZE, clamped 0..1, mirroring the old sector version’s intensity mapping). This is what a lone, isolated enemy looks like: one small, mostly-faint dot. - Mist layer (
_mist_mm, inside a reduced-resolutionSubViewport, composited back via a full-screenTextureRectwith additive blending): the identical per-enemy data, but positions pre-scaled by the viewport’s resolution factor before being written as instance transforms (the SubViewport’s internal coordinate space is smaller, same relationshipnebula_bg.gdalready has between its shader rect and the real display). Reuses the samenebula_bg.gdSubViewport- downres pattern, but pushed to a considerably lower resolution scale thanNebulaBg’s own 0.5 — here the downscale blur is the entire point (deliberately soft), not a barely-perceptible efficiency trick, so a much smaller scale (starting point: 0.15–0.2, tune from the capture) is the right target, notNebulaBg’s value. Where enemies are sparse this stays faint and tight (a single low-res dot upscaled is just a slightly softer small dot); where they’re dense, many overlapping low-res-rendered contributions sum brighter AND the upscale spreads that brightness spatially — the “gentle wide mist” Chris described.
Both layers read visible = true at the top of update_radar() (unchanged from the current
self-repairing-visibility fix), and both are torn down/hidden exactly where the current
ThreatRadar already is in main.gd (_return_to_menu()) — no main.gd wiring changes at all,
since update_radar(sim, pilot)’s external signature and call site are unchanged.
3. Performance
Section titled “3. Performance”One for i in range(sim.enemies.count) sweep per frame (same O(n) shape the current sector version
already does inside its own ThreatMap.compute() call — not new cost), plus two MultiMesh uploads
(cheap, set_instance_transform_2d/set_instance_color calls, same as SwarmRenderer.sync()
already does for the whole swarm every frame) and two draw calls total (sharp layer + mist layer,
the mist layer’s SubViewport render is a separate but tiny render pass at a fraction of screen
resolution). No new per-entity nodes, no Control._draw() loop (which the OLD sector version used
for its ≤8 blobs — fine at that count, would not have scaled to per-enemy at 200-345 count, which
is exactly why this design uses MultiMesh instead).
Testing
Section titled “Testing”ThreatMap.enemy_weight(): re-verify the existingThreatMapweight tests still pass unchanged after extraction (behavior-preserving refactor, not new behavior).- The per-enemy sweep that builds the two instance-array sets (positions + weights → transforms +
colors) should be factored as a pure function taking
(sim, pilot, vp, margin, resolution_scale)and returning the two arrays, independent of any liveMultiMesh/SubViewportnode — testable without touching rendering, same separationThreatRadar.edge_point()already models. - The actual visual result (do isolated enemies read as small dots, does a real cluster visibly
blur into wide mist) is verified with a real capture via the
_shot.gdharness (same technique used for tonight’s border-glow mist effect and the original sector radar), not just asserted numerically — this is a visual effect, and this project’s own convention (seebullet-heaven-ui-lookmemory) is that a capture which “looks plausible” isn’t proof; diff against expectations directly, and where useful, against the old sector-blob screenshot. - Full suite + count guard + boot smoke per the standard ritual; no determinism re-pin expected
(this is
ai/extraction +ui/rendering only,/simuntouched).
Approval note
Section titled “Approval note”Approved live in chat across three targeted exchanges (replace-vs-add-to-the-existing-radar →
edge-of-screen-vs-in-world placement → additive-only-vs-additive-plus-blur render technique),
each informed by real investigation (a read of the shipped ThreatMap/ThreatRadar code, then a
research pass on merging techniques) before being presented as a question — same pattern as the
two AI-tooling specs earlier tonight. Proceeding straight to writing-plans.