Skip to content

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).

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.

  • No change to Ace’s own decision-making. SkilledAutoPlayer’s engage force / proactive dash / threat-aware aim all read ThreatMap.compute()’s sector-aggregated best() — 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 existing ThreatMap test 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’s GlowTexture.shared() (a pre-built, cached, soft radial white→transparent texture, already used by SwarmRenderer’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.gd harness) before calling any task done, same as every other visual change tonight. Expect the plan’s constants to move after actually seeing it.

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): one QuadMesh instance per enemy, GlowTexture.shared(), CanvasItemMaterial.BLEND_MODE_ADD — the exact pattern render/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-resolution SubViewport, composited back via a full-screen TextureRect with 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 relationship nebula_bg.gd already has between its shader rect and the real display). Reuses the same nebula_bg.gd SubViewport- downres pattern, but pushed to a considerably lower resolution scale than NebulaBg’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, not NebulaBg’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.

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).

  • ThreatMap.enemy_weight(): re-verify the existing ThreatMap weight 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 live MultiMesh/SubViewport node — testable without touching rendering, same separation ThreatRadar.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.gd harness (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 (see bullet-heaven-ui-look memory) 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, /sim untouched).

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.