Files
galaxy-game/ui/frontend/src/map/render.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

1057 lines
37 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.
// PixiJS map renderer with pan/zoom, torus and no-wrap modes.
//
// Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance
// configured for the active wrap mode. Torus mode renders nine
// container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the
// user a seamless toroidal world while panning past the edge — and
// keeps the camera centre snapped into the central tile via a
// `moved` listener so the fixed 3×3 layout is sufficient for any
// distance of pan. No-wrap mode hides eight of the nine copies and
// pins the camera with `pixi-viewport`'s `clamp` plugin plus a
// `moved` listener that recentres the camera when the visible
// viewport exceeds the world along an axis. Both modes share a
// `clampZoom({ minScale })` so the world (origin copy) always fills
// at least the viewport — without it torus mode would expose all
// nine copies at once.
//
// Hit-test is owned by ./hit-test.ts; this file only exposes the
// current camera and viewport so callers can run hits.
import {
Application,
Container,
Graphics,
Ticker,
UPDATE_PRIORITY,
type Renderer,
type RendererType,
} from "pixi.js";
import { Viewport as PixiViewport } from "pixi-viewport";
import { hitTest, type Hit } from "./hit-test";
import { screenToWorld } from "./math";
import { minScaleNoWrap } from "./no-wrap";
import {
computePickOverlay,
PICK_OVERLAY_STYLE,
type PickModeHandle,
type PickModeOptions,
} from "./pick-mode";
import { wrapCameraTorus } from "./torus";
import {
DARK_THEME,
DEFAULT_POINT_RADIUS_PX,
World,
type Camera,
type CirclePrim,
type LinePrim,
type PointPrim,
type Primitive,
type PrimitiveID,
type Theme,
type Viewport,
type WrapMode,
} from "./world";
// RendererPreference matches Pixi's accepted values for backend
// selection. The map renderer always restricts to webgpu/webgl.
export type RendererPreference = "webgpu" | "webgl";
export interface RendererOptions {
canvas: HTMLCanvasElement;
world: World;
mode: WrapMode;
preference?: RendererPreference | RendererPreference[];
theme?: Theme;
resolution?: number; // device pixel ratio override; defaults to window.devicePixelRatio
}
export interface RendererHandle {
app: Application;
viewport: PixiViewport;
getMode(): WrapMode;
setMode(mode: WrapMode): void;
getCamera(): Camera;
getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas";
/**
* getRenderCount returns how many frames the renderer has actually
* painted since creation. The renderer runs render-on-demand (the
* Pixi auto-render loop is stopped), so this counter only advances
* when the camera moved or a content mutation requested a repaint —
* never on idle frames. Exposed for the debug surface so e2e specs
* can assert that an idle map does not keep repainting.
*/
getRenderCount(): number;
hitAt(cursorPx: { x: number; y: number }): Hit | null;
/**
* setExtraPrimitives replaces the current overlay primitive layer
* with `prims`. The base world (passed to `createRenderer`) is
* preserved; only the extras layer changes. Used by the in-game
* shell to project order-overlay-driven artefacts (Phase 16
* cargo-route arrows) onto the live renderer without disposing
* and recreating the Pixi `Application` — which Pixi 8 does not
* reliably support on the same canvas.
*
* Hit-test, `getPrimitives`, and pick mode all see the union of
* base + extras after the call returns. Repeated calls
* remount-replace the extras atomically.
*/
setExtraPrimitives(prims: readonly Primitive[]): void;
/**
* getPrimitives returns the live union of base + extras. The
* order is base-first, extras-last (mirroring the draw order).
* Reads stay in sync with `setExtraPrimitives`.
*/
getPrimitives(): readonly Primitive[];
/**
* onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor
* is reported in canvas pixel coordinates so callers can hand it
* straight to `hitAt`. Returns a function that detaches the
* listener; the returned disposer is idempotent.
*
* Built on `pixi-viewport`'s `clicked` event, which already
* applies the same drag threshold the pan plugin uses, so a
* click here will not race a pan gesture.
*/
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onPointerMove subscribes `cb` to every pointer-move event on
* the canvas. The callback receives the cursor in canvas-local
* pixel coordinates so callers can hand it straight to `hitAt`.
* Touch drags also emit pointer-move while a finger is pressed.
* The returned function detaches the listener; idempotent.
*/
onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onHoverChange subscribes `cb` to changes in the primitive
* currently under the cursor. The callback fires only when the
* id transitions (deduped) and is invoked with `null` when the
* cursor moves into empty space. Driven by the same pointer-move
* stream as `onPointerMove`, so subscribing to both does not
* double-cost the pointer event.
*/
onHoverChange(cb: (id: PrimitiveID | null) => void): () => void;
/**
* setPickMode opens (or, with `null`, closes) a map-driven
* destination pick. While a session is active the renderer dims
* primitives outside `reachableIds`, mounts an overlay drawing
* the source-anchor ring, the cursor line, and the
* hover-highlight ring, suppresses regular `onClick` consumers,
* and listens for Escape on `document`. The session resolves via
* `opts.onPick(id)` on a click hitting a reachable planet, or
* `opts.onPick(null)` on Escape / handle.cancel().
*
* Returns the imperative cancel handle when a session was opened
* (i.e. `opts !== null`), otherwise `null`. Calling the function
* again with `null` closes any active session and is idempotent.
*/
setPickMode(opts: PickModeOptions | null): PickModeHandle | null;
/**
* isPickModeActive reports whether a `setPickMode` session is
* currently open. The standard `onClick` path is suppressed
* while this returns `true`.
*/
isPickModeActive(): boolean;
/**
* getPickState returns a defensive snapshot of the pick-mode
* session for debugging surfaces. `sourcePrimitiveId` and
* `reachableIds` are `null` while no session is open.
*/
getPickState(): {
active: boolean;
sourcePrimitiveId: PrimitiveID | null;
reachableIds: ReadonlySet<PrimitiveID> | null;
hoveredId: PrimitiveID | null;
};
/**
* getPrimitiveAlpha returns the current rendered alpha of the
* primitive `id` (in the central tile). Used by the debug
* surface to report dimmed-state for e2e assertions. Returns 1
* for unknown ids.
*/
getPrimitiveAlpha(id: PrimitiveID): number;
/**
* setHiddenPrimitiveIds replaces the set of primitives the
* renderer should hide. Hidden primitives have their per-copy
* `Graphics.visible` flipped to `false` and are skipped by
* `hitAt`, so a click on the area they used to cover falls
* through to the next primitive. Empty input clears the hide
* set. Called every effect run by the Phase 29 map view to
* materialise the `MapToggles` flags + planet-cascade rule
* without a Pixi remount.
*/
setHiddenPrimitiveIds(ids: ReadonlySet<PrimitiveID>): void;
/**
* isPrimitiveHidden reports whether the supplied primitive id is
* in the current hide set. Used by the debug surface so e2e
* specs can assert toggle behaviour without poking at Pixi
* internals.
*/
isPrimitiveHidden(id: PrimitiveID): boolean;
/**
* setVisibilityFog draws (or removes) the Phase 29 visibility
* fog overlay. Each entry describes a circle around a LOCAL
* planet that the player has scanner / visibility coverage on;
* the overlay fills the world rectangle with a slightly lighter
* fog colour and "punches" each circle out, leaving the
* intelligence-covered area in the regular background. Empty
* input destroys the existing fog Graphics.
*/
setVisibilityFog(
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
): void;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
const TORUS_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[-1, -1],
[0, -1],
[1, -1],
[-1, 0],
[0, 0],
[1, 0],
[-1, 1],
[0, 1],
[1, 1],
];
const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS
// EMPTY_HIDDEN_IDS is the default state of the Phase 29 hide set
// (no primitive is hidden). Shared by every renderer instance so a
// frequent `setHiddenPrimitiveIds(EMPTY_HIDDEN_IDS)` call from the
// debug surface stays allocation-free.
const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = new Set();
// FOG_COLOR is the Phase 29 visibility-fog colour. Two shades
// lighter than the dark theme background (`0x0a0e1a`) so it reads
// as a faint fog without contrasting against the rest of the map.
// The colour is tunable in Phase 35 polish.
export const FOG_COLOR = 0x12162a;
/**
* FogPaintOp is one item in the ordered draw sequence produced by
* `fogPaintOps`. The renderer dispatches each op directly onto a
* Pixi `Graphics`; the indirection exists so the Phase 29 layered
* overpaint (fog rect then background-coloured circles on top) can
* be unit-tested without a Pixi context.
*/
export type FogPaintOp =
| {
readonly kind: "fillRect";
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly color: number;
readonly alpha: number;
}
| {
readonly kind: "fillCircle";
readonly x: number;
readonly y: number;
readonly radius: number;
readonly color: number;
readonly alpha: number;
};
/**
* fogPaintOps returns the ordered sequence of paint operations that
* 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 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
* degenerate world cannot produce a non-empty op set.
*/
export function fogPaintOps(
world: { width: number; height: number },
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
fogColor: number,
bgColor: number,
mode: WrapMode,
): FogPaintOp[] {
if (circles.length === 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[] = [];
for (const [dx, dy] of offsets) {
ops.push({
kind: "fillRect",
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({
kind: "fillCircle",
x: c.x + dx * world.width,
y: c.y + dy * world.height,
radius: c.radius,
color: bgColor,
alpha: 1,
});
}
}
return ops;
}
const ORIGIN_ONLY_OFFSET: ReadonlyArray<readonly [number, number]> = [[0, 0]];
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
const theme = opts.theme ?? DARK_THEME;
const preference = opts.preference ?? ["webgpu", "webgl"];
const resolution = opts.resolution ?? globalThis.devicePixelRatio ?? 1;
const canvas = opts.canvas;
const widthPx = canvas.clientWidth || canvas.width || 800;
const heightPx = canvas.clientHeight || canvas.height || 600;
const app = new Application();
await app.init({
canvas,
width: widthPx,
height: heightPx,
preference,
backgroundColor: theme.background,
backgroundAlpha: 1,
antialias: true,
autoDensity: true,
resolution,
});
// Render-on-demand: stop Pixi's continuous auto-render loop. Frames
// are painted explicitly by `renderFlush` below, only when the
// camera moved or a content mutation requested a repaint. This is
// what stops the heavy visibility-fog overlay from re-rasterising
// every frame and freezing the whole UI on large reports.
app.stop();
const viewport = new PixiViewport({
screenWidth: widthPx,
screenHeight: heightPx,
worldWidth: opts.world.width,
worldHeight: opts.world.height,
events: app.renderer.events,
});
// No `.decelerate()`: panning stops the instant the drag is
// released instead of coasting. Besides matching the requested feel,
// it means the viewport stops mutating its transform as soon as the
// pointer is up, so render-on-demand goes idle immediately after a
// drag rather than repainting through an inertia tail.
viewport.drag().wheel({ smooth: 5 }).pinch();
// Render-on-demand wiring. `viewport.dirty` is maintained by
// pixi-viewport's own `Ticker.shared` update and flips true on any
// camera move (drag / wheel / pinch / programmatic `moveCenter` /
// the torus + no-wrap `moved` listeners). `contentDirty` is flipped
// by `requestRender` from every scene-graph mutation that does not
// move the camera (fog, hide-set, extras, wrap mode, resize, pick
// overlay). The flush runs at LOW priority so it observes the
// viewport's freshly updated `dirty` flag within the same shared
// tick. Plain hover mutates no Graphics, so it never repaints.
let contentDirty = true; // force the first paint after mount
let renderCount = 0;
const requestRender = (): void => {
contentDirty = true;
};
const renderFlush = (): void => {
if (!viewport.dirty && !contentDirty) return;
app.render();
viewport.dirty = false;
contentDirty = false;
renderCount++;
};
Ticker.shared.add(renderFlush, undefined, UPDATE_PRIORITY.LOW);
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 fog always
// renders underneath every primitive. The layer holds the
// fog-coloured world rectangle(s); the visibility holes are cut by
// an inverse stencil mask (`fogMask`, the union of the visibility
// circles) rather than by opaque overpaint — see `setVisibilityFog`.
// 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);
// Inverse mask for `fogLayer`, rebuilt by `setVisibilityFog`. Pixi
// requires a mask to live in the display list, so it sits as a
// sibling under the viewport; `null` whenever the fog is off.
let fogMask: Graphics | null = null;
// Create nine torus copies, each holding its own primitive
// graphics. Origin copy is always visible; the other eight
// follow the active wrap mode.
const copies: Container[] = TORUS_OFFSETS.map(([dx, dy]) => {
const c = new Container();
c.x = dx * opts.world.width;
c.y = dy * opts.world.height;
viewport.addChild(c);
return c;
});
// Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup
// indexes the full set of `Graphics` instances per primitive id.
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
let currentWorld: World = opts.world;
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
// every map-view effect run replaces it with the current
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
// renderer-internal hit-test sites (pointer-move, clicked) and the
// external `handle.hitAt` thread it through `hitTest`.
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
// `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;
};
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = buildGraphics(prim, theme);
c.addChild(g);
let list = primitiveGraphics.get(prim.id);
if (list === undefined) {
list = [];
primitiveGraphics.set(prim.id, list);
}
list.push(g);
}
allPrimitiveIds.push(prim.id);
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
if (isExtra) extraPrimitiveIds.add(prim.id);
// Fresh primitives honour the current hide set so cargo-route
// or pending-Send extras pushed after `setHiddenPrimitiveIds`
// inherit the right visibility.
const list = primitiveGraphics.get(prim.id);
if (list !== undefined) applyHiddenStateTo(prim.id, list);
};
for (const p of opts.world.primitives) {
populatePrimitives(p, false);
}
let mode: WrapMode = opts.mode;
const enforceCentreWhenLarger = (): void => {
const halfW = viewport.screenWidth / (2 * viewport.scaled);
const halfH = viewport.screenHeight / (2 * viewport.scaled);
const overX = halfW * 2 >= opts.world.width;
const overY = halfH * 2 >= opts.world.height;
if (!overX && !overY) return;
viewport.moveCenter(
overX ? opts.world.width / 2 : viewport.center.x,
overY ? opts.world.height / 2 : viewport.center.y,
);
};
// Reentry guard for the torus wrap handler: `viewport.moveCenter`
// fires the same `'moved'` event that triggered the wrap, so a
// naive callback would loop forever.
let wrappingCamera = false;
const wrapTorusCamera = (): void => {
if (mode !== "torus" || wrappingCamera) return;
const wrapped = wrapCameraTorus(
{
centerX: viewport.center.x,
centerY: viewport.center.y,
scale: viewport.scaled,
},
opts.world,
);
if (wrapped.centerX === viewport.center.x && wrapped.centerY === viewport.center.y) {
return;
}
wrappingCamera = true;
try {
viewport.moveCenter(wrapped.centerX, wrapped.centerY);
} finally {
wrappingCamera = false;
}
};
const applyMode = (newMode: WrapMode): void => {
mode = newMode;
for (let i = 0; i < copies.length; i++) {
copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX;
}
// Always reset clamp plugins; reattach per mode.
viewport.plugins.remove("clamp");
viewport.plugins.remove("clamp-zoom");
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world,
);
// Both modes enforce minScale on zoom-out: the world (origin
// copy) always fills at least the viewport. Without this, in
// torus mode the user would zoom out far enough to see the
// 3×3 grid of wrap copies at once; the copies are there to
// fill the partial slack near a panned edge, not to be
// visible simultaneously.
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (newMode === "no-wrap") {
viewport.clamp({ direction: "all" });
viewport.on("moved", enforceCentreWhenLarger);
enforceCentreWhenLarger();
} else {
// Torus mode keeps free pan: the user can drag in any
// direction indefinitely. To keep the fixed 3×3 wrap
// layout sufficient, snap the camera back into the
// `[0, W) × [0, H)` central tile whenever it walks past
// the edge — toroidal coordinates are equivalent modulo
// world dimensions, so the user sees no jump.
viewport.on("moved", wrapTorusCamera);
wrapTorusCamera();
}
// Toggling `copy.visible` does not move the camera, so request a
// repaint explicitly; any camera change above also sets
// `viewport.dirty`, which is harmless to request twice.
requestRender();
};
applyMode(mode);
// Pointer-move + hover plumbing. Listening on the underlying
// canvas keeps the renderer agnostic of pixi-viewport's plugin
// chain (drag/pinch can swallow Pixi-level pointer events while
// a gesture is in progress; the DOM event still fires).
const pointerMoveCallbacks = new Set<
(cursorPx: { x: number; y: number }) => void
>();
const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>();
let lastHoveredId: PrimitiveID | null = null;
let lastCursorPx: { x: number; y: number } | null = null;
const handlePointerMove = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
const cursorPx = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
lastCursorPx = cursorPx;
for (const cb of pointerMoveCallbacks) cb(cursorPx);
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
hiddenIds,
);
const hoveredId = hit?.primitive.id ?? null;
if (hoveredId === lastHoveredId) return;
lastHoveredId = hoveredId;
for (const cb of hoverChangeCallbacks) cb(hoveredId);
};
const handlePointerLeave = (): void => {
lastCursorPx = null;
if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return;
lastHoveredId = null;
for (const cb of hoverChangeCallbacks) cb(null);
};
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
// Click dispatch. The renderer owns one `viewport.clicked`
// listener and fans the event out to either the pick-mode
// resolver (when a session is open) or the standard `onClick`
// subscribers — never both. Routing through one listener makes
// the gating race-proof: a pick-mode resolution + teardown runs
// in the same tick as the click, and the standard subscribers
// do not see the post-teardown state.
const clickSubscribers = new Set<
(cursorPx: { x: number; y: number }) => void
>();
// Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in
// `pick-mode.ts`.
let pickModeActive = false;
let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null;
const dimmedAlphaBackup = new Map<Graphics, number>();
const dimmedTintBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = [];
const handleViewportClicked = (e: {
screen: { x: number; y: number };
}): void => {
const cursorPx = { x: e.screen.x, y: e.screen.y };
if (pickModeActive) {
const session = pickOptions;
if (session === null) return;
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hitId = hit?.primitive.id ?? null;
if (hitId === null) return;
if (hitId === session.sourcePrimitiveId) return;
if (!session.reachableIds.has(hitId)) return;
const cb = session.onPick;
teardownPickMode();
cb(hitId);
return;
}
for (const cb of clickSubscribers) cb(cursorPx);
};
viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return;
const cursorWorld =
lastCursorPx === null
? null
: screenToWorld(
lastCursorPx,
handle.getCamera(),
handle.getViewport(),
);
const spec = computePickOverlay(
pickOptions,
cursorWorld,
lastHoveredId,
pointPrimitivesById,
allPrimitiveIds,
);
const g = pickOverlay;
g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({
color: PICK_OVERLAY_STYLE.anchor.color,
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
width: PICK_OVERLAY_STYLE.anchor.width,
});
if (spec.line !== null) {
g.moveTo(spec.line.x1, spec.line.y1);
g.lineTo(spec.line.x2, spec.line.y2);
g.stroke({
color: PICK_OVERLAY_STYLE.line.color,
alpha: PICK_OVERLAY_STYLE.line.alpha,
width: PICK_OVERLAY_STYLE.line.width,
});
}
if (spec.hoverOutline !== null) {
g.circle(
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
);
g.stroke({
color: PICK_OVERLAY_STYLE.hover.color,
alpha: PICK_OVERLAY_STYLE.hover.alpha,
width: PICK_OVERLAY_STYLE.hover.width,
});
}
requestRender();
};
const teardownPickMode = (): void => {
if (!pickModeActive) return;
pickModeActive = false;
for (const detach of detachPickListeners) detach();
detachPickListeners.length = 0;
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
dimmedAlphaBackup.clear();
for (const [g, tint] of dimmedTintBackup) g.tint = tint;
dimmedTintBackup.clear();
if (pickOverlay !== null) {
pickOverlay.destroy();
pickOverlay = null;
}
pickOptions = null;
// Un-dimming primitives and removing the overlay are scene
// changes that do not move the camera.
requestRender();
};
const openPickMode = (options: PickModeOptions): PickModeHandle => {
// An existing session is cancelled first so the previous
// `onPick(null)` is delivered before the new one starts.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
pickOptions = options;
pickModeActive = true;
// Dim every primitive that's not the source and not reachable.
for (const [id, list] of primitiveGraphics) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
for (const g of list) {
dimmedAlphaBackup.set(g, g.alpha);
dimmedTintBackup.set(g, g.tint as number);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
g.tint = PICK_OVERLAY_STYLE.dimTint;
}
}
// Overlay graphic. Lives in the origin copy so the central
// tile owns it; the camera always wraps back into this tile
// (`wrapTorusCamera`), so the user sees the overlay
// regardless of how far they have panned.
pickOverlay = new Graphics();
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing
// callback registries.
detachPickListeners.push(handle.onPointerMove(redrawPickOverlay));
detachPickListeners.push(handle.onHoverChange(redrawPickOverlay));
// Click resolution is handled by the shared
// `handleViewportClicked` dispatcher above; pick mode does
// not subscribe its own `clicked` listener — see the
// rationale in the dispatcher's comment.
const keyHandler = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
if (pickOptions === null) return;
event.preventDefault();
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
};
document.addEventListener("keydown", keyHandler);
detachPickListeners.push(() =>
document.removeEventListener("keydown", keyHandler),
);
return {
cancel: (): void => {
if (pickOptions === null) return;
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
},
};
};
const handle: RendererHandle = {
app,
viewport,
getMode: () => mode,
setMode: applyMode,
getCamera: () => ({
centerX: viewport.center.x,
centerY: viewport.center.y,
scale: viewport.scaled,
}),
getViewport: () => ({
widthPx: viewport.screenWidth,
heightPx: viewport.screenHeight,
}),
getBackend: () => rendererBackendName(app.renderer),
getRenderCount: () => renderCount,
hitAt: (cursorPx) =>
hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
hiddenIds,
),
setExtraPrimitives: (prims) => {
// Drop the previous extras layer.
for (const id of extraPrimitiveIds) {
const list = primitiveGraphics.get(id);
if (list !== undefined) {
for (const g of list) {
g.parent?.removeChild(g);
g.destroy();
}
primitiveGraphics.delete(id);
}
pointPrimitivesById.delete(id);
const idx = allPrimitiveIds.indexOf(id);
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
}
extraPrimitiveIds.clear();
// Add the new extras.
for (const p of prims) {
populatePrimitives(p, true);
}
// Rebuild the snapshot World hit-test reads from. The
// renderer keeps `currentWorld` mutable so the live
// extras participate in click/hover tests on the same
// frame they're drawn.
currentWorld = new World(opts.world.width, opts.world.height, [
...opts.world.primitives,
...prims,
]);
requestRender();
},
getPrimitives: () => currentWorld.primitives,
onClick: (cb) => {
clickSubscribers.add(cb);
return () => {
clickSubscribers.delete(cb);
};
},
onPointerMove: (cb) => {
pointerMoveCallbacks.add(cb);
return () => {
pointerMoveCallbacks.delete(cb);
};
},
onHoverChange: (cb) => {
hoverChangeCallbacks.add(cb);
// Fire the current state once so subscribers do not have to
// wait for the next pointer movement to learn what's under
// the cursor.
cb(lastHoveredId);
return () => {
hoverChangeCallbacks.delete(cb);
};
},
setPickMode: (options) => {
if (options === null) {
if (!pickModeActive) return null;
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
return null;
}
return openPickMode(options);
},
isPickModeActive: () => pickModeActive,
getPickState: () => ({
active: pickModeActive,
sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null,
reachableIds: pickOptions?.reachableIds ?? null,
hoveredId: lastHoveredId,
}),
getPrimitiveAlpha: (id) => {
const list = primitiveGraphics.get(id);
if (list === undefined || list.length === 0) return 1;
// All copies share the same alpha (dim is applied to every
// torus tile), so the central-tile entry is representative.
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
},
setHiddenPrimitiveIds: (ids) => {
// Snapshot the input so a later mutation by the caller does
// not silently un-hide primitives on the next hit-test.
hiddenIds = new Set(ids);
for (const [id, list] of primitiveGraphics) {
applyHiddenStateTo(id, list);
}
requestRender();
},
isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => {
// Detach the old mask before destroying its Graphics, then
// drop the previous fog rectangles. Every flip rebuilds from
// scratch instead of mutating in place.
fogLayer.mask = null;
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
if (fogMask !== null) {
fogMask.destroy();
fogMask = null;
}
// Repaint whether or not new fog is added: clearing the layer
// (toggling the fog off) is itself a scene change.
requestRender();
const ops = fogPaintOps(
opts.world,
circles,
FOG_COLOR,
theme.background,
mode,
);
if (ops.length === 0) return;
// The fog is the fog-coloured rectangle(s); the visibility
// holes are cut by an INVERSE stencil mask built from the
// union of the visibility circles. This replaces the earlier
// opaque-circle overpaint, which on a large report painted
// dozens of near-full-world opaque circles every frame — a
// fill-rate cliff that froze panning under Safari's WebGPU
// backend. A stencil mask rasterises the same circles far
// cheaper (no blended colour writes) and stays fully vector,
// so the fog edge is crisp at any zoom. The circle ops carry
// `bgColor`, unused here — a mask only needs the shape.
const mask = new Graphics();
let hasHole = false;
for (const op of ops) {
if (op.kind === "fillRect") {
const g = new Graphics();
g.rect(op.x, op.y, op.width, op.height);
g.fill({ color: op.color, alpha: op.alpha });
fogLayer.addChild(g);
} else {
// One fill per circle keeps each shape independent;
// overlapping fills simply union in the stencil, which
// is exactly the visibility coverage we want to cut.
mask.circle(op.x, op.y, op.radius);
mask.fill(0xffffff);
hasHole = true;
}
}
if (hasHole) {
// The mask must be in the display list for its transform
// to resolve; it shares the viewport's untransformed world
// space with `fogLayer`, so the world-space op coordinates
// line up. `inverse: true` shows the fog everywhere EXCEPT
// inside the circle union.
viewport.addChild(mask);
fogLayer.setMask({ mask, inverse: true });
fogMask = mask;
} else {
mask.destroy();
}
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
viewport.plugins.remove("clamp-zoom");
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (mode === "no-wrap") {
enforceCentreWhenLarger();
}
// The drawing buffer was resized; repaint at the new size.
requestRender();
},
dispose: () => {
// Detach the render-on-demand flush first so nothing tries
// to paint a half-destroyed scene on the next shared tick.
Ticker.shared.remove(renderFlush);
// Tear down any open pick session before destroying the
// app — the resolution callback might reference Svelte
// stores that disappear next tick on dispose, but
// `onPick(null)` here is a synchronous notification the
// caller is responsible for handling.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
// `app.destroy({...children: true})` below recursively
// destroys every container in the scene graph, fogLayer
// included. The explicit teardown here drops the fog
// rectangles + the inverse mask eagerly so a future caller
// querying the renderer mid-dispose does not see stale fog
// instances still parented under the layer.
fogLayer.mask = null;
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
if (fogMask !== null) {
fogMask.destroy();
fogMask = null;
}
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
pointerMoveCallbacks.clear();
hoverChangeCallbacks.clear();
clickSubscribers.clear();
app.destroy({ removeView: false }, { children: true });
},
};
return handle;
}
function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
const t = r.type as RendererType;
// 1=WEBGL, 2=WEBGPU, 4=CANVAS per RendererType enum.
if (t === 2) return "webgpu";
if (t === 4) return "canvas";
return "webgl";
}
function buildGraphics(p: Primitive, theme: Theme): Graphics {
const g = new Graphics();
if (p.kind === "point") drawPoint(g, p, theme);
else if (p.kind === "circle") drawCircle(g, p, theme);
else drawLine(g, p, theme);
return g;
}
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
g.circle(p.x, p.y, radiusPx);
g.fill({ color, alpha });
}
function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void {
g.circle(p.x, p.y, p.radius);
if (p.style.fillColor !== undefined) {
g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 });
}
const strokeColor = p.style.strokeColor ?? theme.circleStroke;
const strokeAlpha = p.style.strokeAlpha ?? 1;
const strokeWidth = p.style.strokeWidthPx ?? 1;
g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth });
}
function drawLine(g: Graphics, p: LinePrim, theme: Theme): void {
const color = p.style.strokeColor ?? theme.lineStroke;
const alpha = p.style.strokeAlpha ?? 1;
const width = p.style.strokeWidthPx ?? 1;
const dash = p.style.strokeDashPx;
if (dash === undefined || dash <= 0) {
g.moveTo(p.x1, p.y1);
g.lineTo(p.x2, p.y2);
g.stroke({ color, alpha, width });
return;
}
// PixiJS v8 has no native dashed-line API; segment the path into
// equal-length dashes (dash and gap both `dash` units).
const dx = p.x2 - p.x1;
const dy = p.y2 - p.y1;
const length = Math.hypot(dx, dy);
if (length === 0) return;
const ux = dx / length;
const uy = dy / length;
const step = dash * 2;
for (let t = 0; t < length; t += step) {
const segEnd = Math.min(t + dash, length);
g.moveTo(p.x1 + ux * t, p.y1 + uy * t);
g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd);
}
g.stroke({ color, alpha, width });
}