fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s

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>
This commit is contained in:
Ilia Denisov
2026-05-20 16:28:18 +02:00
parent 0da2f4b6fb
commit 51902b995f
7 changed files with 307 additions and 28 deletions
+72 -24
View File
@@ -186,10 +186,12 @@ zoom. The math is symmetric and tested in
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 inertia, mobile gestures, and
the `clamp`/`clampZoom` plugins out of the box. We disable the
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`).
`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
@@ -212,13 +214,45 @@ 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:
```ts
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, observe Pixi's
ticker FPS in DevTools (Pixi exposes `app.ticker.FPS`).
`/__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
@@ -299,34 +333,40 @@ 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 creates one fog `Graphics` per torus copy.
Each draws a world-sized rectangle filled with `FOG_COLOR` (two
shades lighter than the dark theme background), then paints an
opaque background-coloured circle on top for every visibility
circle. The overpaint order naturally unions overlapping circles
— earlier iterations used Pixi v8's `Graphics.cut()` to subtract
holes, but `cut()` produces incorrect unions for multiple
overlapping holes; layered repainting trades one extra fill per
circle for a predictable, geometry-free union.
- The fog is inserted at the bottom of each copy's z-order so
- 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). `fogPaintOps` returns an ordered op list — one world-sized
rectangle filled with `FOG_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 own `Graphics`. The overpaint order naturally unions
overlapping circles — earlier iterations used Pixi v8's
`Graphics.cut()` to subtract holes, but `cut()` 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: `torus` emits the
rectangle and every circle at the nine `{-1,0,1}²` tile offsets;
`no-wrap` emits only the central tile. `fogLayer` has 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.
- Wrap mode is honoured for free — `applyMode` hides every
non-origin copy in `no-wrap`, so the fog inherits the same
behaviour because the fog Graphics is a child of each copy.
The map view recomputes the fog input only when the report or the
`visibleHyperspace` toggle changes — per-frame cost stays at zero.
`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()`, and
`getMapFog()` so e2e specs can assert the renderer's current
state without scraping pixels:
`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
@@ -342,10 +382,18 @@ state without scraping pixels:
- `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` in
/ `registerMapCameraProvider` / `registerMapFogProvider` /
`registerMapModeProvider` / `registerMapRenderCountProvider` in
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
the surface invokes them lazily on every read.