Files
galaxy-game/ui/frontend/tests/fog-paint-ops.test.ts
T
Ilia Denisov a08f4f55b0
Tests · UI / test (push) Successful in 1m57s
Tests · UI / test (pull_request) Successful in 1m56s
fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.

Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.

fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.

Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:53:54 +02:00

200 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 29 unit coverage for the visible-hyperspace overlay's paint
// ops. `fogPaintOps` lives in `src/map/render.ts` next to its sole
// consumer (`RendererHandle.setVisibilityFog`): the renderer draws
// the rectangle ops into a `fogLayer` container (below every
// primitive copy) and feeds the circle ops into an inverse stencil
// mask that cuts the visibility holes out of the fog. `fogPaintOps`
// only produces the ordered op list — rect(s) first, then one circle
// per visibility circle — which is what these tests pin; earlier
// renderer implementations used Pixi `cut()` (disconnected arcs) and
// then opaque overpaint (a fill-rate cliff under Safari's WebGPU).
//
// Coordinates returned by `fogPaintOps` are in world space because
// `fogLayer` and the mask have no transform — wraps for torus mode
// are baked into the ops directly.
import { describe, expect, test } from "vitest";
import { FOG_COLOR, fogPaintOps } from "../src/map/render";
const BG_COLOR = 0x0a0e1a;
const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps — no-wrap mode", () => {
test("empty input returns no ops", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
});
test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops).toEqual([
{
kind: "fillRect",
x: 0,
y: 0,
width: 1000,
height: 800,
color: FOG_COLOR,
alpha: 1,
},
{
kind: "fillCircle",
x: 100,
y: 200,
radius: 50,
color: BG_COLOR,
alpha: 1,
},
]);
});
test("multiple circles produce one fog rect followed by N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 300, y: 200, radius: 80 },
{ x: 500, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops.length).toBe(4);
expect(ops[0].kind).toBe("fillRect");
for (let i = 1; i < ops.length; i++) {
expect(ops[i].kind).toBe("fillCircle");
const op = ops[i];
if (op.kind === "fillCircle") {
expect(op.color).toBe(BG_COLOR);
expect(op.alpha).toBe(1);
}
}
});
test("zero or negative world dimensions return no ops", () => {
expect(
fogPaintOps(
{ width: 0, height: 800 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
expect(
fogPaintOps(
{ width: 1000, height: -1 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
});
});
describe("fogPaintOps — torus mode", () => {
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(18);
// The first 9 ops are fog rects, one per neighbour tile.
const rectPositions = ops
.slice(0, 9)
.map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort();
const expectedRectPositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
}
}
expectedRectPositions.sort();
expect(rectPositions).toEqual(expectedRectPositions);
// The next 9 ops are bg circles at every wrapped planet position.
const circlePositions = ops
.slice(9)
.map((op) =>
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
)
.sort();
const expectedCirclePositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedCirclePositions.push(
`${100 + dx * 1000},${200 + dy * 800}`,
);
}
}
expectedCirclePositions.sort();
expect(circlePositions).toEqual(expectedCirclePositions);
});
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 700, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(27);
expect(
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
).toBe(true);
expect(
ops.slice(9).every((op) => op.kind === "fillCircle"),
).toBe(true);
const radii = ops
.slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 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 wrapped circles at (-50, 400) and
// (1950, 400) so the circle stays continuous across the seam
// instead of appearing as a sector clipped by the neighbour
// tile's fog rectangle.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
const circleXs = ops
.filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(circleXs).toContain(-50);
expect(circleXs).toContain(950);
expect(circleXs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
});
});