fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s

Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-20 00:26:06 +02:00
parent 00e84579ca
commit 53b892ae00
2 changed files with 130 additions and 117 deletions
+67 -76
View File
@@ -243,29 +243,26 @@ export type FogPaintOp =
/**
* fogPaintOps returns the ordered sequence of paint operations that
* draw the Phase 29 visible-hyperspace overlay on a single torus
* copy. The first op is the fog-coloured rectangle covering the
* full world; subsequent ops are background-coloured circles, one
* per visibility circle, painted on top of the fog rectangle. The
* natural rendering order unions overlapping circles for free —
* earlier iterations relied on Pixi v8's `Graphics.cut()` to
* subtract holes, but `cut()` produced incorrect unions for
* overlapping circles (the symptom was a handful of disconnected
* arc segments instead of a clean union).
* draw the Phase 29 visible-hyperspace overlay. The renderer
* dispatches each op onto its own Pixi `Graphics` inside a single
* `fogLayer` that sits below every primitive copy, so the natural
* rendering order paints fog underneath the world.
*
* Coordinates are in world space (the `fogLayer` has no transform),
* which means the wrap offsets are baked directly into the
* positions — there is no per-tile dispatch on the renderer side.
*
* `mode` controls the torus-wrap behaviour:
*
* - `"torus"`: every visibility circle is also drawn at the eight
* wrapped positions (±width, ±height) so the circle remains
* visually continuous when its painted area extends past the
* world rectangle into a neighbouring tile — without the wraps
* the next tile's fog rectangle overpaints the bleed, producing
* a "sector" artifact at the seam.
* - `"no-wrap"`: only the planet's own position is drawn. The
* wrapped positions would create extra holes inside the world
* rectangle when a planet sits near an edge (the user can never
* pan past the boundary in no-wrap mode, but the wrapped circle
* could still leak into the visible area).
* - `"torus"`: every fog rect AND every visibility circle is
* emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so
* the fog covers all nine torus tiles and a planet near a seam
* keeps a continuous visibility hole across it.
* - `"no-wrap"`: only the central tile is emitted. The user can
* never pan past the boundary in no-wrap mode, so the
* additional wraps would just be wasted paint — worse, a
* wrapped circle from a planet near an edge would leak into
* the visible world rectangle as an unwanted hole.
*
* Empty `circles` returns an empty list — the caller skips fog
* rendering entirely. Width/height ≤ 0 also returns empty so a
@@ -282,17 +279,18 @@ export function fogPaintOps(
if (world.width <= 0 || world.height <= 0) return [];
const offsets: ReadonlyArray<readonly [number, number]> =
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
const ops: FogPaintOp[] = [
{
const ops: FogPaintOp[] = [];
for (const [dx, dy] of offsets) {
ops.push({
kind: "fillRect",
x: 0,
y: 0,
x: dx * world.width,
y: dy * world.height,
width: world.width,
height: world.height,
color: fogColor,
alpha: 1,
},
];
});
}
for (const c of circles) {
for (const [dx, dy] of offsets) {
ops.push({
@@ -343,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
app.stage.addChild(viewport);
// Phase 29 fog layer: a single Container sharing the viewport's
// coordinate space, populated by `setVisibilityFog`. Added to
// the viewport BEFORE the nine torus copies so the layered
// repaint (fog rectangles + background-coloured circles) always
// renders underneath every primitive. An earlier per-copy
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
// top in practice — moving the fog to a sibling of the copies
// avoids any reorder ambiguity.
const fogLayer = new Container();
viewport.addChild(fogLayer);
// Create nine torus copies, each holding its own primitive
// graphics. Origin copy is always visible; the other eight
// follow the active wrap mode.
@@ -368,17 +377,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// renderer-internal hit-test sites (pointer-move, clicked) and the
// external `handle.hitAt` thread it through `hitTest`.
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
// Per-copy fog Containers for the Phase 29 visibility fog
// overlay. Each container holds one `Graphics` per
// `FogPaintOp` (fog rect + one bg-coloured circle per
// visibility circle × wrap position), inserted at index 0 of
// the torus copy so primitives paint on top. Created lazily
// when `setVisibilityFog` first receives a non-empty list and
// destroyed wholesale on every subsequent call — Pixi v8's
// multi-shape Graphics is supported in theory, but stacking
// each fill on its own Graphics removes any risk of an
// internal-state regression dropping a layer.
let fogGraphics: Container[] = [];
// `fogLayer` (declared above) is repopulated every time
// `setVisibilityFog` runs. We track the dispatched ops only
// implicitly via the layer's children; on every flip we drop
// the previous children and rebuild from the new op list.
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
const visible = !hiddenIds.has(id);
for (const g of list) g.visible = visible;
@@ -804,16 +806,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
},
isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => {
// Drop the old fog Containers first — every flip rebuilds
// from scratch instead of mutating in place, so the
// implementation stays simple and Pixi-v8-residue-free.
// `destroy({children: true})` propagates to every owned
// Graphics inside the Container.
for (const c of fogGraphics) {
c.parent?.removeChild(c);
c.destroy({ children: true });
// Drop the previous fog children — every flip rebuilds
// from scratch instead of mutating in place. Pixi v8's
// `Container.removeChildren()` returns the detached
// children so we can destroy each one explicitly.
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
fogGraphics = [];
const ops = fogPaintOps(
opts.world,
circles,
@@ -822,29 +821,21 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
mode,
);
if (ops.length === 0) return;
for (const copy of copies) {
const container = new Container();
// One Graphics per op — the fog rect first, then every
// background-coloured circle on top. Per-shape Graphics
// removes any risk of multi-shape Pixi quirks dropping a
// layer (the previous all-in-one Graphics implementation
// surfaced exactly that symptom in DEV — only the last
// planet's glyph stayed visible inside the bg holes).
for (const op of ops) {
const g = new Graphics();
if (op.kind === "fillRect") {
g.rect(op.x, op.y, op.width, op.height);
} else {
g.circle(op.x, op.y, op.radius);
}
g.fill({ color: op.color, alpha: op.alpha });
container.addChild(g);
// Each op gets its own Graphics so any multi-shape Pixi
// quirks cannot drop a layer (an earlier all-in-one
// implementation surfaced exactly that symptom in DEV —
// only the last planet's glyph stayed visible inside the
// bg holes). The ops carry world-space positions; the
// `fogLayer` has no transform.
for (const op of ops) {
const g = new Graphics();
if (op.kind === "fillRect") {
g.rect(op.x, op.y, op.width, op.height);
} else {
g.circle(op.x, op.y, op.radius);
}
// Fog sits below every primitive on the same copy so
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
// the rest of the children's order intact.
copy.addChildAt(container, 0);
fogGraphics.push(container);
g.fill({ color: op.color, alpha: op.alpha });
fogLayer.addChild(g);
}
},
resize: (w, h) => {
@@ -869,15 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
teardownPickMode();
previous?.onPick(null);
}
// `app.destroy({...children: true})` below would also walk
// fog containers, but we drop them eagerly so the closure
// reference clears even if a future caller queries the
// renderer mid-dispose.
for (const c of fogGraphics) {
c.parent?.removeChild(c);
c.destroy({ children: true });
// `app.destroy({...children: true})` below recursively
// destroys every container in the scene graph, fogLayer
// included. The explicit removeChildren()/destroy here
// drops the fog children eagerly so a future caller
// querying the renderer mid-dispose does not see stale
// fog instances still parented under the layer.
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
fogGraphics = [];
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);