fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s

Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:

1. With three LOCAL planets close together, only the last planet
   glyph stayed visible inside the bg holes — the other two were
   obscured. The previous implementation stacked the fog rectangle
   plus every bg circle onto a single `Graphics` via repeated
   `g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
   multi-shape Graphics is supported in theory, but in practice
   only the last shape's fill seems to land, dropping the earlier
   bg holes (and the planet glyphs on top look like they vanished
   along with their hole). Splitting each op onto its own
   `Graphics` inside a per-copy `Container` removes the ambiguity
   — one shape, one fill, one render pass.

2. A planet near the right world edge produced a "sector" — the
   bg circle painted into the area past the seam, but the
   neighbouring tile's fog rectangle then overpainted that bleed,
   leaving a quarter-circle hole. In torus mode each visibility
   circle is now drawn at the nine wrapped positions
   (`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
   neighbour-tile-aligned positions keep the hole continuous
   across the seam. No-wrap mode keeps a single emission per
   circle, because wrapped circles would leak into the visible
   world rectangle as unwanted holes.

The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-20 00:04:35 +02:00
parent 7ade838df8
commit 00e84579ca
2 changed files with 152 additions and 70 deletions
+63 -26
View File
@@ -253,18 +253,35 @@ export type FogPaintOp =
* overlapping circles (the symptom was a handful of disconnected * overlapping circles (the symptom was a handful of disconnected
* arc segments instead of a clean union). * arc segments instead of a clean union).
* *
* `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).
*
* Empty `circles` returns an empty list — the caller skips fog * Empty `circles` returns an empty list — the caller skips fog
* rendering entirely. Width/height ≤ 0 also returns empty so a * rendering entirely. Width/height ≤ 0 also returns empty so a
* degenerate world cannot produce a non-emit op set. * degenerate world cannot produce a non-empty op set.
*/ */
export function fogPaintOps( export function fogPaintOps(
world: { width: number; height: number }, world: { width: number; height: number },
circles: ReadonlyArray<{ x: number; y: number; radius: number }>, circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
fogColor: number, fogColor: number,
bgColor: number, bgColor: number,
mode: WrapMode,
): FogPaintOp[] { ): FogPaintOp[] {
if (circles.length === 0) return []; if (circles.length === 0) return [];
if (world.width <= 0 || world.height <= 0) return []; 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[] = [
{ {
kind: "fillRect", kind: "fillRect",
@@ -277,18 +294,22 @@ export function fogPaintOps(
}, },
]; ];
for (const c of circles) { for (const c of circles) {
ops.push({ for (const [dx, dy] of offsets) {
kind: "fillCircle", ops.push({
x: c.x, kind: "fillCircle",
y: c.y, x: c.x + dx * world.width,
radius: c.radius, y: c.y + dy * world.height,
color: bgColor, radius: c.radius,
alpha: 1, color: bgColor,
}); alpha: 1,
});
}
} }
return ops; return ops;
} }
const ORIGIN_ONLY_OFFSET: ReadonlyArray<readonly [number, number]> = [[0, 0]];
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> { export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
const theme = opts.theme ?? DARK_THEME; const theme = opts.theme ?? DARK_THEME;
const preference = opts.preference ?? ["webgpu", "webgl"]; const preference = opts.preference ?? ["webgpu", "webgl"];
@@ -347,12 +368,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// renderer-internal hit-test sites (pointer-move, clicked) and the // renderer-internal hit-test sites (pointer-move, clicked) and the
// external `handle.hitAt` thread it through `hitTest`. // external `handle.hitAt` thread it through `hitTest`.
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS; let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
// Per-copy fog Graphics for the Phase 29 visibility fog overlay. // Per-copy fog Containers for the Phase 29 visibility fog
// Created lazily when `setVisibilityFog` first receives a // overlay. Each container holds one `Graphics` per
// non-empty list; cleared (and destroyed) when the list goes // `FogPaintOp` (fog rect + one bg-coloured circle per
// empty again. Each fog Graphics is inserted at index 0 of its // visibility circle × wrap position), inserted at index 0 of
// torus copy so primitives paint on top. // the torus copy so primitives paint on top. Created lazily
let fogGraphics: Graphics[] = []; // 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[] = [];
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
const visible = !hiddenIds.has(id); const visible = !hiddenIds.has(id);
for (const g of list) g.visible = visible; for (const g of list) g.visible = visible;
@@ -778,12 +804,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}, },
isPrimitiveHidden: (id) => hiddenIds.has(id), isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => { setVisibilityFog: (circles) => {
// Drop the old fog Graphics first — every flip rebuilds // Drop the old fog Containers first — every flip rebuilds
// from scratch instead of mutating in place, so the // from scratch instead of mutating in place, so the
// implementation stays simple and Pixi-v8-residue-free. // implementation stays simple and Pixi-v8-residue-free.
for (const g of fogGraphics) { // `destroy({children: true})` propagates to every owned
g.parent?.removeChild(g); // Graphics inside the Container.
g.destroy(); for (const c of fogGraphics) {
c.parent?.removeChild(c);
c.destroy({ children: true });
} }
fogGraphics = []; fogGraphics = [];
const ops = fogPaintOps( const ops = fogPaintOps(
@@ -791,23 +819,32 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
circles, circles,
FOG_COLOR, FOG_COLOR,
theme.background, theme.background,
mode,
); );
if (ops.length === 0) return; if (ops.length === 0) return;
for (const copy of copies) { for (const copy of copies) {
const g = new Graphics(); 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) { for (const op of ops) {
const g = new Graphics();
if (op.kind === "fillRect") { if (op.kind === "fillRect") {
g.rect(op.x, op.y, op.width, op.height); g.rect(op.x, op.y, op.width, op.height);
} else { } else {
g.circle(op.x, op.y, op.radius); g.circle(op.x, op.y, op.radius);
} }
g.fill({ color: op.color, alpha: op.alpha }); g.fill({ color: op.color, alpha: op.alpha });
container.addChild(g);
} }
// Fog sits below every primitive on the same copy so // Fog sits below every primitive on the same copy so
// planet glyphs paint on top. `addChildAt(g, 0)` keeps // planet glyphs paint on top. `addChildAt(g, 0)` keeps
// the rest of the children's order intact. // the rest of the children's order intact.
copy.addChildAt(g, 0); copy.addChildAt(container, 0);
fogGraphics.push(g); fogGraphics.push(container);
} }
}, },
resize: (w, h) => { resize: (w, h) => {
@@ -833,12 +870,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
previous?.onPick(null); previous?.onPick(null);
} }
// `app.destroy({...children: true})` below would also walk // `app.destroy({...children: true})` below would also walk
// fog graphics, but we drop them eagerly so the closure // fog containers, but we drop them eagerly so the closure
// reference clears even if a future caller queries the // reference clears even if a future caller queries the
// renderer mid-dispose. // renderer mid-dispose.
for (const g of fogGraphics) { for (const c of fogGraphics) {
g.parent?.removeChild(g); c.parent?.removeChild(c);
g.destroy(); c.destroy({ children: true });
} }
fogGraphics = []; fogGraphics = [];
viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", enforceCentreWhenLarger);
+89 -44
View File
@@ -1,10 +1,12 @@
// Phase 29 unit coverage for the Phase 29 fog overlay's layered // Phase 29 unit coverage for the Phase 29 fog overlay's layered
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next // overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the // to its sole consumer (`RendererHandle.setVisibilityFog`) — the
// renderer dispatches each op straight onto a Pixi `Graphics`, so // renderer dispatches each op straight onto its own Pixi `Graphics`
// the unit test exercises the public ordering contract: a single // (one per shape) inside a per-copy `Container`, so the unit test
// fog-coloured rectangle followed by one background-coloured // exercises the public ordering contract: a single fog-coloured
// circle per visibility entry. The natural rendering order unions // rectangle followed by one background-coloured circle per
// visibility entry (multiplied by the torus wrap offsets when the
// renderer is in torus mode). The natural rendering order unions
// overlapping circles for free, replacing the earlier `cut()` // overlapping circles for free, replacing the earlier `cut()`
// implementation that produced disconnected arc segments. // implementation that produced disconnected arc segments.
@@ -15,9 +17,9 @@ import { FOG_COLOR, fogPaintOps } from "../src/map/render";
const BG_COLOR = 0x0a0e1a; const BG_COLOR = 0x0a0e1a;
const WORLD = { width: 1000, height: 800 }; const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps", () => { describe("fogPaintOps — no-wrap mode", () => {
test("empty input returns no ops", () => { test("empty input returns no ops", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).toEqual([]); expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
}); });
test("single circle emits fog rect + one bg circle in that order", () => { test("single circle emits fog rect + one bg circle in that order", () => {
@@ -26,6 +28,7 @@ describe("fogPaintOps", () => {
[{ x: 100, y: 200, radius: 50 }], [{ x: 100, y: 200, radius: 50 }],
FOG_COLOR, FOG_COLOR,
BG_COLOR, BG_COLOR,
"no-wrap",
); );
expect(ops).toEqual([ expect(ops).toEqual([
{ {
@@ -58,12 +61,12 @@ describe("fogPaintOps", () => {
], ],
FOG_COLOR, FOG_COLOR,
BG_COLOR, BG_COLOR,
"no-wrap",
); );
expect(ops.length).toBe(4); expect(ops.length).toBe(4);
expect(ops[0].kind).toBe("fillRect"); expect(ops[0].kind).toBe("fillRect");
for (let i = 1; i < ops.length; i++) { for (let i = 1; i < ops.length; i++) {
expect(ops[i].kind).toBe("fillCircle"); expect(ops[i].kind).toBe("fillCircle");
// Background-coloured circles paint on top of the fog rect.
const op = ops[i]; const op = ops[i];
if (op.kind === "fillCircle") { if (op.kind === "fillCircle") {
expect(op.color).toBe(BG_COLOR); expect(op.color).toBe(BG_COLOR);
@@ -72,43 +75,6 @@ describe("fogPaintOps", () => {
} }
}); });
test("overlapping circles are emitted independently — the rendering order unions them", () => {
// Two overlapping circles around adjacent LOCAL planets — the
// op list keeps both circles. The renderer relies on the
// overpaint to merge them visually; `cut()` (the previous
// implementation) miscomputed the union.
const ops = fogPaintOps(
WORLD,
[
{ x: 200, y: 200, radius: 100 },
{ x: 250, y: 200, radius: 100 },
],
FOG_COLOR,
BG_COLOR,
);
expect(ops.length).toBe(3);
expect(ops[1]).toMatchObject({ x: 200, y: 200, radius: 100 });
expect(ops[2]).toMatchObject({ x: 250, y: 200, radius: 100 });
});
test("the fog rect always covers the full world rectangle", () => {
const ops = fogPaintOps(
{ width: 3200, height: 1600 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
);
expect(ops[0]).toEqual({
kind: "fillRect",
x: 0,
y: 0,
width: 3200,
height: 1600,
color: FOG_COLOR,
alpha: 1,
});
});
test("zero or negative world dimensions return no ops", () => { test("zero or negative world dimensions return no ops", () => {
expect( expect(
fogPaintOps( fogPaintOps(
@@ -116,6 +82,7 @@ describe("fogPaintOps", () => {
[{ x: 0, y: 0, radius: 10 }], [{ x: 0, y: 0, radius: 10 }],
FOG_COLOR, FOG_COLOR,
BG_COLOR, BG_COLOR,
"no-wrap",
), ),
).toEqual([]); ).toEqual([]);
expect( expect(
@@ -124,7 +91,85 @@ describe("fogPaintOps", () => {
[{ x: 0, y: 0, radius: 10 }], [{ x: 0, y: 0, radius: 10 }],
FOG_COLOR, FOG_COLOR,
BG_COLOR, BG_COLOR,
"no-wrap",
), ),
).toEqual([]); ).toEqual([]);
}); });
}); });
describe("fogPaintOps — torus mode", () => {
test("each circle is emitted at nine wrapped positions", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 1 fog rect + 9 wrapped circles.
expect(ops.length).toBe(10);
expect(ops[0].kind).toBe("fillRect");
const positions = ops
.slice(1)
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : ""))
.sort();
// Every neighbour offset is emitted with width=1000 / height=800.
const expected: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expected.push(`${100 + dx * 1000},${200 + dy * 800}`);
}
}
expected.sort();
expect(positions).toEqual(expected);
});
test("multiple circles produce 9 × N wrapped circles after the fog rect", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 700, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 1 fog rect + (9 wraps × 2 circles) = 19 ops.
expect(ops.length).toBe(19);
expect(ops[0].kind).toBe("fillRect");
// Each circle keeps its own radius across every wrap.
const radii = ops
.slice(1)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0))
.filter((r) => r > 0);
expect(radii.filter((r) => r === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9);
});
test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the
// renderer also draws a wrapped circle at (950 - 1000, 400) =
// (-50, 400) so the next tile (with its own fog rect) keeps a
// matching unfogged hole at the seam — this is the fix for
// the "sector" artifact at the wrap boundary.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
const xs = ops
.slice(1)
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(xs).toContain(-50);
expect(xs).toContain(950);
expect(xs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
});
});