The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.
Stage 1 (vector-preserving, no rasterisation):
- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
single Ticker.shared flush gated on viewport.dirty (camera) plus an
internal requestRender() from every content mutation (fog / hide-set /
extras / wrap mode / resize / pick overlay). An idle map now does zero
GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
deterministic e2e assertions.
Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.
If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Map renderer
This document specifies the map renderer in ui/frontend/src/map/.
It is the source of truth for the rendering data model, the
hit-test algorithm, the torus-wrap and bounded-plane (no-wrap)
camera semantics, and the choice of dependencies. Any disagreement
between this document and the code is a bug in one of them.
galaxy/clientis deprecated. The Go module undergalaxy/client/— includingclient/world/— is no longer the reference implementation for any new code. The TypeScript renderer described here is independent: it does not importclient/worldat runtime, and it is not bound by the older module's algorithmic details (fixed-point integers, expanded canvas, incremental pan reuse, grid spatial index). The Go code remains as historical context only.
Goals
The renderer is the bottom of the rendering stack the rest of the UI sits on top of. It must:
- Render thousands of vector primitives (points, circles, lines) onto a Pixi v8 canvas at 60 fps on a mid-range laptop.
- Support pan and zoom over a toroidal world (
'torus'mode) and over a bounded plane ('no-wrap'mode), both first-class. - Run the same algorithm on web, Wails, Capacitor, and PWA targets — only the browser is supported in Phase 9, but no API in this module assumes the platform.
- Provide deterministic hit-test for cursor-to-primitive mapping, with results that are unit-testable independently of Pixi.
Coordinate model
World coordinates are TypeScript number (IEEE 754 float64). The
world is a rectangle [0, W) × [0, H) for some positive W,
H. Primitive geometry, the camera centre, and the no-wrap clamp
arithmetic all live in world coordinates.
Pixi's transform pipeline owns the world→screen mapping. We do not maintain a manual fixed-point representation: the deprecated Go renderer's fixed-point ints existed because it composited into a pixel buffer, which we do not.
The camera is { centerX, centerY, scale } with scale in pixels
per world unit. The viewport is { widthPx, heightPx } in CSS
pixels (Pixi's autoDensity handles device pixel ratio
internally).
Primitives
type Primitive = PointPrim | CirclePrim | LinePrim;
interface PrimitiveBase {
id: PrimitiveID;
priority: number;
style: Style;
hitSlopPx: number; // 0 = use kind default
}
interface PointPrim extends PrimitiveBase { kind: 'point'; x: number; y: number; }
interface CirclePrim extends PrimitiveBase { kind: 'circle'; x: number; y: number; radius: number; }
interface LinePrim extends PrimitiveBase { kind: 'line';
x1: number; y1: number; x2: number; y2: number; }
radius is in world units. style.strokeWidthPx and
style.pointRadiusPx are in screen pixels and stay constant under
zoom (Pixi's stroke width is in pixel space when the parent
container is scaled).
Default hit slop in screen pixels: point=8, circle=6, line=6.
These are touch-ergonomic defaults; per-primitive hitSlopPx > 0
overrides them.
Theme
A single dark theme ships in Phase 9. The theme is a record of
default colours; primitives whose style omits a colour fall back
to the theme. Runtime theme switching is not implemented — Phase
35 introduces light/dark and the materialise-on-theme-change
cycle.
Hit-test
Algorithm in src/map/hit-test.ts:
hitTest(world, camera, viewport, cursorPx, mode):
cursorWorld = screenToWorld(cursorPx, camera, viewport)
candidates = []
for p in world.primitives:
slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT[kind]
slopWorld = slopPx / camera.scale
delta =
mode == 'torus'
? torusShortestDelta(p, cursorWorld, world)
: euclideanDelta(p, cursorWorld)
distSq = match(delta, p.kind, p.geometry, slopWorld) // or null
if distSq != null: candidates.push({ p, distSq })
candidates.sort(by [-priority, distSq, kindOrder, id])
return candidates[0] ?? null
torusShortestDelta normalises a delta to the half-open interval
(-size/2, size/2] per axis, picking the shorter wrap direction.
At exactly size/2 it returns +size/2 (positive direction);
the lower bound is exclusive so -size/2 is normalised to
+size/2.
kindOrder is point=0, line=1, circle=2. Point wins ties over
overlapping line/circle; this matches typical UX expectations
where a point object on top of a route should be the preferred
target.
Per-primitive distance:
- Point:
distSq ≤ (pointRadiusPx + slopWorld)². The visible disc is part of the click target — a click on any pixel of the rendered planet registers as a hit, withslopWorldadding a small ergonomic margin on top.pointRadiusPxdefaults toDEFAULT_POINT_RADIUS_PX = 3when unset. - Filled circle:
distSq ≤ (radius + slopWorld)²whereradiusis in world units. The circle counts as filled whenstyle.fillColoris set andstyle.fillAlpha > 0. - Stroke-only circle:
|dist - radius| ≤ slopWorld. The squared "distance" reported is the squared ring gap, so the ordering rule prefers the closest-to-ring candidate among multiple ring-only circles. - Line: perpendicular distance to the segment, with
tclamped to[0, 1](foot beyond endpoints uses the endpoint). In torus mode the segment is taken in its torus-shortest representation: from(x1, y1)to(x1 + dx, y1 + dy)where(dx, dy)is the torus-shortest delta from end-1 to end-2.
The brute-force O(N) walk is fine for the Phase 9 target of
~1000 primitives on every pointer event. Spatial indexing is
deferred until profiling proves it necessary; PixiJS' culling and
batching handle the draw side without help.
Torus rendering
The renderer creates nine container copies of the primitive scene
at offsets (dx, dy) ∈ {-W, 0, W} × {-H, 0, H}. In torus mode
all nine copies are visible; PixiJS culls the off-viewport copies
itself. In no-wrap mode only the origin copy (0, 0) is visible.
Lines that cross a torus boundary are not split at render time: each copy renders the full line at its offset, and PixiJS' culling naturally drops the parts outside its container's reachable area.
The nine-copy upper bound assumes the visible viewport never
exceeds three tile-widths or three tile-heights of the world. To
hold this assumption in both modes, the renderer enforces
clampZoom({ minScale }) with minScale = max(viewport.W/world.W, viewport.H/world.H) regardless of wrap mode. Without this, in
torus mode the user could zoom out far enough to see the 3×3 grid
of wrap copies at once — the copies are there to fill partial slack
near a panned edge, not to be visible simultaneously. The clamp is
re-evaluated on every viewport resize so a window resize does not
strand the camera below the new minimum.
No-wrap camera
pixi-viewport's built-in clamp({ direction: 'all' }) plugin
keeps the camera inside the world rectangle by default. We layer
the project-specific centring rule on top, implemented via the
'moved' event: when the visible viewport is larger than the world
along an axis, the camera is centred on that axis.
pixi-viewport's default would pin the world to the top-left of
the screen, which is jarring at low zoom. The shared
clampZoom({ minScale }) (described above) prevents this case in
practice, but the centring rule stays as a defensive layer for
windowed-resize transients.
pivotZoom keeps the world point under the cursor stable during
zoom. The math is symmetric and tested in
tests/map-no-wrap.test.ts.
Dependencies
pixi.js@^8— vector renderer with WebGPU/WebGL backend. Async init viaapp.init({ preference, ... }). Thepreferenceoption may be a string or an array; the renderer cascades through the array and falls back to whichever backend initialises successfully.pixi-viewport@^6— pan/zoom/pinch plugin layer over a PixiContainer. Provides drag, mobile gestures, and theclamp/clampZoomplugins out of the box. We disable the plugins we do not need (bounce,snap,follow,mouse-edges) and deliberately omitdecelerate: a released drag stops immediately instead of coasting, which also lets render-on-demand (below) go idle the moment the pointer is up.
No additional dependencies are necessary. The deprecated
pixi.js-v7 era pixi-viewport v5 contracts have been replaced
in v6 (notably events: renderer.events is now mandatory in the
constructor).
Renderer preference selection
The playground page reads ?renderer=webgpu|webgl from the URL
and passes it to Application.init. Without the parameter the
preference defaults to ['webgpu', 'webgl']. Playwright projects
use the URL parameter to force a specific backend per browser:
chromium-desktop→?renderer=webgpuwebkit-desktop→?renderer=webgl(WebKit does not implement WebGPU yet)- mobile projects → no parameter, accept whichever Pixi picks
The selected backend is exposed via [data-backend] on the
playground page header so the e2e spec can assert it without
poking Pixi internals.
Render-on-demand
Pixi's continuous auto-render loop is stopped right after
Application.init (app.stop()). Frames are painted explicitly by
a single gated flush added to Ticker.shared — the same ticker
pixi-viewport already drives, so no second timer is created:
if (viewport.dirty || contentDirty) { app.render(); /* reset both */ }
viewport.dirtyis maintained by pixi-viewport's own update and covers every camera change (drag / wheel / pinch, the torus and no-wrapmovedlisteners, programmaticmoveCenter).contentDirtyis set by an internalrequestRender()from every scene-graph mutation that does not move the camera:setVisibilityFog,setHiddenPrimitiveIds,setExtraPrimitives,applyMode,resize, and the pick-mode overlay redraw.- Plain hover mutates no
Graphics, so moving the cursor over the map paints nothing.
An idle map therefore does zero GPU work per frame. This matters
for the visibility fog: its layered overpaint is fill-heavy, and a
continuously re-rendered fog froze the whole UI on large reports in
Safari (Pixi's WebGPU backend). RendererHandle.getRenderCount()
exposes the painted-frame count; the map-toggles e2e spec asserts
with it that an idle map does not repaint and that a released drag
does not coast.
Performance acceptance
The "60 fps with 1000 primitives" criterion is documented but
manually verified, not asserted in CI. CI runners vary too much
in CPU/GPU to make wall-clock fps reliable. Manual gate: open
/__debug/map, drag continuously for 5 seconds, and watch the
frame rate in the browser DevTools rendering meter (the app ticker
is stopped under render-on-demand, so app.ticker.FPS no longer
tracks paints — frames land via the Ticker.shared flush only
while the camera is moving).
If a future regression requires a programmatic perf gate, the right place is a Tier 2 (release-line) Playwright trace measuring average frame time over a scripted drag.
Pick mode
Phase 16 introduced a generic map-driven destination pick the inspector uses for cargo routes and that ship-group dispatch (Phase 19/20) will reuse. The renderer owns the visual lifecycle; the Svelte side wraps it in a promise-shaped service.
Lifecycle (RendererHandle.setPickMode(opts)):
- Open (
opts !== null): renderer markspickModeActive, setsalpha = 0.3on every primitive whose id is neither the source nor inreachableIds, mounts an overlayGraphicsin the origin tile, and subscribes to pointer-move + hover-change- viewport
clicked+ documentkeydown.
- viewport
- Tick (every pointer-move and hover transition): the
renderer asks
computePickOverlay(opts, cursorWorld, hoveredId, points, allIds)(src/map/pick-mode.ts) for a draw spec — anchor ring + cursor line + optional hover outline + dim set — and re-paints the overlay. - Resolve: a click on a primitive whose id is in
reachableIdscallsopts.onPick(id)and tears down. A click on empty space or a non-reachable primitive is a no-op (forgiving for accidental taps mid-pan). Escape (or the imperativecancel()on the returned handle) callsopts.onPick(null). - Tear down: alpha overrides are restored, the overlay
Graphicsis destroyed, every listener is detached, andpickModeActivereturns tofalse. ExistingonClicksubscriptions are gated onpickModeActive, so the standard planet-selection path does not fire on the destination click.
The pure overlay-spec helper lives in src/map/pick-mode.ts and
is covered by tests/map-pick-mode.test.ts without booting Pixi.
The Pixi side (alpha mutation, Graphics overlay, listener
hookup) is exercised in the in-browser e2e specs.
The Svelte adapter MapPickService (src/lib/map-pick.svelte.ts)
turns the callback contract into pick(request) → Promise<id | null>. The map active view (lib/active-view/map.svelte)
constructs the service, sets MAP_PICK_CONTEXT_KEY, and binds a
resolver that translates sourcePlanetNumber to the underlying
PickModeOptions (looking up the source coordinates from the
current report). Inspector subsections call service.pick(...)
and react to the resolved id.
Hidden primitives
RendererHandle.setHiddenPrimitiveIds(ids) replaces the current
hide-by-id set. Every primitive whose id sits in ids has its
per-copy Graphics.visible flipped to false and is skipped by
hitAt, so a click on its former area falls through to the next
visible primitive. An empty set restores everything. Repeated
calls are diff-free idempotent — g.visible assignments are
cheap.
The hide set is propagated to hitTest through a new optional
hiddenIds parameter so internal hit-test sites (pointer-move,
clicked dispatcher) stay in lock-step with the visible scene.
After setExtraPrimitives the hide set is re-applied so a
freshly-pushed extras layer (cargo-route overlay, pending-Send
tracks) does not silently un-hide a primitive whose id is in the
current set.
The Phase 29 map view (src/lib/active-view/map.svelte) computes
the set from the per-game MapToggles rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount.
Visible-hyperspace overlay (the "fog")
RendererHandle.setVisibilityFog(circles) draws (or removes) the
Phase 29 fog overlay used to highlight the player's visible
hyperspace. Each entry describes a circle around a LOCAL planet
where the player has scanner / visibility coverage:
- An empty list destroys the existing fog
Graphics. - A non-empty list rebuilds a single viewport-level
fogLayer(a sibling that sits below the nine torus copies, not a child of them).fogPaintOpsreturns an ordered op list — one world-sized rectangle filled withFOG_COLOR(two shades lighter than the dark theme background), then an opaque background-coloured circle for every visibility circle — and the renderer dispatches each op onto its ownGraphics. The overpaint order naturally unions overlapping circles — earlier iterations used Pixi v8'sGraphics.cut()to subtract holes, butcut()produces incorrect unions for multiple overlapping holes; layered repainting trades one extra fill per circle for a predictable, geometry-free union. - The ops carry world-space positions, so wrap mode is baked into
the op list rather than into copy visibility:
torusemits the rectangle and every circle at the nine{-1,0,1}²tile offsets;no-wrapemits only the central tile.fogLayerhas no transform. - The fog layer sits below every primitive copy in z-order, so primitives paint on top.
- The fog never participates in hit-test. Planet glyphs sit on top of fog, so clicks on visible planets work unchanged.
The map view recomputes the fog input only when the report or the
visibleHyperspace toggle changes, and under render-on-demand a
static fog paints no frames at all — the layered overpaint cost is
only paid on the frames where the camera is actually moving.
Debug surface
The DEV-only __galaxyDebug object (defined in
routes/__debug/store/+page.svelte) exposes
getMapPrimitives(), getMapPickState(), getMapCamera(),
getMapFog(), getMapMode(), and getMapRenderCount() so e2e
specs can assert the renderer's current state without scraping
pixels:
getMapPrimitives()returns a snapshot of every primitive in the active world: id, kind, priority, current alpha (post-overlay), the explicit fill / stroke colour from itsStyle(no theme fallback), and the Phase 29visibleflag mirroring the renderer's hide set.getMapPickState()returns{ active, sourcePlanetNumber, reachableIds, hoveredId }— the renderer's view of the current pick session.getMapCamera()returns the current camera + viewport + canvas-origin snapshot, used by Phase 29 e2e specs to assert camera preservation across wrap-mode flips.getMapFog()returns the most recent fog input (the list of circles last passed tosetVisibilityFog). Empty when thevisibleHyperspacetoggle is off.getMapMode()returns the renderer's currentWrapMode('torus'or'no-wrap'), used to await the remount after a wrap-mode flip.getMapRenderCount()returns the painted-frame count. Under render-on-demand it stays flat while the map is idle and advances only on camera moves or content mutations, so e2e specs can prove the idle map is not repainting.
The active map view registers providers on mount via
registerMapPrimitivesProvider / registerMapPickStateProvider
/ registerMapCameraProvider / registerMapFogProvider /
registerMapModeProvider / registerMapRenderCountProvider in
src/lib/debug-surface.svelte.ts, deregisters on dispose, and
the surface invokes them lazily on every read.
Tests
tests/map-math.test.ts—clamp,torusShortestDelta,distSqPointToSegment,screenToWorld/worldToScreen.tests/map-no-wrap.test.ts—clampCameraNoWrap,minScaleNoWrap,pivotZoom(point-under-cursor invariant verified within float64 precision).tests/map-hit-test.test.ts— hand-built cases covering every rule from the algorithm above: hit/miss with default and custom slop (now includingpointRadiusPx), torus wrap copies, filled vs stroked circles, line endpoint clamping, priority/kind/id ordering, scale effect on slop.tests/map-pick-mode.test.ts— pure-state coverage forcomputePickOverlay: anchor / line / hover-outline / dim-set shape against representative pick configurations.tests/e2e/playground-map.spec.ts— Pixi mount in real browsers, mode toggle, wheel zoom, no-wrap clamp after drag, hit-test plumbing.
The unit tests run in jsdom and never touch Pixi or
pixi-viewport, so a refactor of the renderer cannot silently
break them.