Files
galaxy-game/ui/docs/renderer.md
T
Ilia Denisov f6e4a4f6bd
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): map canvas follows light/dark theme; fix invisible gear control
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.

This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.

The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.

Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:49:37 +02:00

20 KiB
Raw Blame History

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/client is deprecated. The Go module under galaxy/client/ — including client/world/ — is no longer the reference implementation for any new code. The TypeScript renderer described here is independent: it does not import client/world at 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:

  1. Render thousands of vector primitives (points, circles, lines) onto a Pixi v8 canvas at 60 fps on a mid-range laptop.
  2. Support pan and zoom over a toroidal world ('torus' mode) and over a bounded plane ('no-wrap' mode), both first-class.
  3. Run the same algorithm on web, Wails, Capacitor, and PWA targets — no API in this module assumes the platform.
  4. 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 Theme is the renderer's full colour palette: the canvas background and fog veil, the generic fallbacks for primitives whose style omits a colour, and the semantic colours every primitive builder paints with (planets, ship groups, cargo routes, battle / bombing markers, reach + selection rings, pending-Send tracks, and the pick-mode overlay). Two palettes ship in src/map/world.tsDARK_THEME and LIGHT_THEME — and the builders take the active palette so the whole canvas follows the user's light / dark choice.

active-view/map.svelte selects the palette from theme.resolved ($lib/theme/theme.svelte.ts) and threads it into reportToWorld, the overlay builders, and createRenderer({ theme }). A theme flip is handled by a remount that preserves the camera: Pixi bakes the background at Application.init and every primitive bakes its colour at build time, so the palette cannot be swapped live on an existing instance. The debug playground (routes/__debug/map) omits the option and keeps the DARK_THEME default.

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, with slopWorld adding a small ergonomic margin on top. pointRadiusPx defaults to DEFAULT_POINT_RADIUS_PX = 3 when unset.
  • Filled circle: distSq ≤ (radius + slopWorld)² where radius is in world units. The circle counts as filled when style.fillColor is set and style.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 t clamped 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 current 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 via app.init({ preference, ... }). The preference option 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 Pixi Container. Provides drag, mobile gestures, and the clamp/clampZoom plugins out of the box. We disable the plugins we do not need (bounce, snap, follow, mouse-edges) and deliberately omit decelerate: 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=webgpu
  • webkit-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.dirty is maintained by pixi-viewport's own update and covers every camera change (drag / wheel / pinch, the torus and no-wrap moved listeners, programmatic moveCenter).
  • contentDirty is set by an internal requestRender() 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

The renderer provides a generic map-driven destination pick that the inspector uses for cargo routes and ship-group dispatch. The renderer owns the visual lifecycle; the Svelte side wraps it in a promise-shaped service.

Lifecycle (RendererHandle.setPickMode(opts)):

  1. Open (opts !== null): renderer marks pickModeActive, sets alpha = 0.3 on every primitive whose id is neither the source nor in reachableIds, mounts an overlay Graphics in the origin tile, and subscribes to pointer-move + hover-change
    • viewport clicked + document keydown.
  2. 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.
  3. Resolve: a click on a primitive whose id is in reachableIds calls opts.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 imperative cancel() on the returned handle) calls opts.onPick(null).
  4. Tear down: alpha overrides are restored, the overlay Graphics is destroyed, every listener is detached, and pickModeActive returns to false. Existing onClick subscriptions are gated on pickModeActive, 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 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 fog overlay that highlights 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 rectangles and mask.
  • A non-empty list rebuilds a single viewport-level fogLayer (a sibling below the nine torus copies). fogPaintOps returns an ordered op list — one world-sized rectangle filled with the active palette's fog colour (a faint shade off the theme background) plus one circle per visibility circle. The renderer draws the rectangle ops into fogLayer and collects the circle ops into a single Graphics set as fogLayer's inverse stencil mask (setMask({ mask, inverse: true })), so the fog shows everywhere EXCEPT inside the union of the circles. Overlapping circles union for free in the stencil.
  • Why a mask: earlier iterations subtracted holes with Pixi v8's Graphics.cut() (incorrect unions for overlapping holes), then with opaque background-coloured overpaint. The overpaint was a fill-rate cliff — on a large report it painted dozens of near-world-sized opaque circles every frame, which froze panning under Safari's WebGPU backend. An inverse stencil mask rasterises the same circles far cheaper (no blended colour writes, friendly to Apple's tile-based GPU) and stays fully vector, so the fog edge is crisp at any zoom.
  • The ops carry world-space positions, so wrap mode is baked into the op list rather than into copy visibility: torus emits the rectangle and every circle at the nine {-1,0,1}² tile offsets; no-wrap emits only the central tile. fogLayer and the mask are both untransformed children of the viewport, so the coordinates line up.
  • 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 mask-cheap fog 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 its Style (no theme fallback), and the visible flag 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 e2e specs to assert camera preservation across wrap-mode flips.
  • getMapFog() returns the most recent fog input (the list of circles last passed to setVisibilityFog). Empty when the visibleHyperspace toggle is off.
  • getMapMode() returns the renderer's current WrapMode ('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.tsclamp, torusShortestDelta, distSqPointToSegment, screenToWorld/worldToScreen.
  • tests/map-no-wrap.test.tsclampCameraNoWrap, 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 including pointRadiusPx), 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 for computePickOverlay: 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.