fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
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:
+20
-2
@@ -3235,8 +3235,9 @@ Targeted tests:
|
|||||||
- `tests/map-hit-test.test.ts` — `hitTest` honours the
|
- `tests/map-hit-test.test.ts` — `hitTest` honours the
|
||||||
`hiddenIds` parameter;
|
`hiddenIds` parameter;
|
||||||
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
|
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
|
||||||
camera preservation, reload persistence across the four
|
camera preservation, reload persistence, plus render-on-demand
|
||||||
Playwright projects.
|
(an idle map does not repaint; a released drag does not coast)
|
||||||
|
across the four Playwright projects.
|
||||||
|
|
||||||
Decisions:
|
Decisions:
|
||||||
|
|
||||||
@@ -3278,6 +3279,23 @@ Decisions:
|
|||||||
`FligthDistance`; the Phase 29 work renamed it to
|
`FligthDistance`; the Phase 29 work renamed it to
|
||||||
`FlightDistance` (and the only TS call site duplicates the
|
`FlightDistance` (and the only TS call site duplicates the
|
||||||
formula directly, awaiting a future race-level WASM bridge).
|
formula directly, awaiting a future race-level WASM bridge).
|
||||||
|
8. **Render-on-demand + no pan inertia (fog perf, stage 1).** The
|
||||||
|
renderer originally kept Pixi's continuous auto-render loop, so
|
||||||
|
the visibility fog's layered overpaint re-rasterised every frame
|
||||||
|
and froze the whole UI on large reports in Safari (Pixi's WebGPU
|
||||||
|
backend) — even while idle. The renderer now stops the auto-render
|
||||||
|
loop (`app.stop()`) and paints on demand: a single `Ticker.shared`
|
||||||
|
flush renders only when `viewport.dirty` (camera moved) or an
|
||||||
|
internal `requestRender()` fires from a content mutation
|
||||||
|
(`setVisibilityFog` / `setHiddenPrimitiveIds` /
|
||||||
|
`setExtraPrimitives` / `applyMode` / `resize` / pick overlay);
|
||||||
|
plain hover paints nothing. The `decelerate` (drag-inertia) plugin
|
||||||
|
is removed so a released drag stops instantly and the viewport
|
||||||
|
goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
|
||||||
|
on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
|
||||||
|
assertions. If Safari pan is still heavy after this, stage 2 cuts
|
||||||
|
the overpaint itself (an inverse stencil mask of the circle union,
|
||||||
|
kept vector so the map stays crisp at any zoom).
|
||||||
|
|
||||||
## Phase 30. Calculator Tab
|
## Phase 30. Calculator Tab
|
||||||
|
|
||||||
|
|||||||
+72
-24
@@ -186,10 +186,12 @@ zoom. The math is symmetric and tested in
|
|||||||
cascades through the array and falls back to whichever backend
|
cascades through the array and falls back to whichever backend
|
||||||
initialises successfully.
|
initialises successfully.
|
||||||
- **`pixi-viewport@^6`** — pan/zoom/pinch plugin layer over a
|
- **`pixi-viewport@^6`** — pan/zoom/pinch plugin layer over a
|
||||||
Pixi `Container`. Provides drag inertia, mobile gestures, and
|
Pixi `Container`. Provides drag, mobile gestures, and the
|
||||||
the `clamp`/`clampZoom` plugins out of the box. We disable the
|
`clamp`/`clampZoom` plugins out of the box. We disable the
|
||||||
plugins we do not need (`bounce`, `snap`, `follow`,
|
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
|
No additional dependencies are necessary. The deprecated
|
||||||
`pixi.js`-v7 era `pixi-viewport` v5 contracts have been replaced
|
`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
|
playground page header so the e2e spec can assert it without
|
||||||
poking Pixi internals.
|
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
|
## Performance acceptance
|
||||||
|
|
||||||
The "60 fps with 1000 primitives" criterion is documented but
|
The "60 fps with 1000 primitives" criterion is documented but
|
||||||
manually verified, not asserted in CI. CI runners vary too much
|
manually verified, not asserted in CI. CI runners vary too much
|
||||||
in CPU/GPU to make wall-clock fps reliable. Manual gate: open
|
in CPU/GPU to make wall-clock fps reliable. Manual gate: open
|
||||||
`/__debug/map`, drag continuously for 5 seconds, observe Pixi's
|
`/__debug/map`, drag continuously for 5 seconds, and watch the
|
||||||
ticker FPS in DevTools (Pixi exposes `app.ticker.FPS`).
|
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
|
If a future regression requires a programmatic perf gate, the
|
||||||
right place is a Tier 2 (release-line) Playwright trace measuring
|
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
|
hyperspace. Each entry describes a circle around a LOCAL planet
|
||||||
where the player has scanner / visibility coverage:
|
where the player has scanner / visibility coverage:
|
||||||
|
|
||||||
- An empty list destroys the existing fog Graphics.
|
- An empty list destroys the existing fog `Graphics`.
|
||||||
- A non-empty list creates one fog `Graphics` per torus copy.
|
- A non-empty list rebuilds a single viewport-level `fogLayer` (a
|
||||||
Each draws a world-sized rectangle filled with `FOG_COLOR` (two
|
sibling that sits below the nine torus copies, not a child of
|
||||||
shades lighter than the dark theme background), then paints an
|
them). `fogPaintOps` returns an ordered op list — one world-sized
|
||||||
opaque background-coloured circle on top for every visibility
|
rectangle filled with `FOG_COLOR` (two shades lighter than the
|
||||||
circle. The overpaint order naturally unions overlapping circles
|
dark theme background), then an opaque background-coloured circle
|
||||||
— earlier iterations used Pixi v8's `Graphics.cut()` to subtract
|
for every visibility circle — and the renderer dispatches each op
|
||||||
holes, but `cut()` produces incorrect unions for multiple
|
onto its own `Graphics`. The overpaint order naturally unions
|
||||||
overlapping holes; layered repainting trades one extra fill per
|
overlapping circles — earlier iterations used Pixi v8's
|
||||||
circle for a predictable, geometry-free union.
|
`Graphics.cut()` to subtract holes, but `cut()` produces incorrect
|
||||||
- The fog is inserted at the bottom of each copy's z-order so
|
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.
|
primitives paint on top.
|
||||||
- The fog never participates in hit-test. Planet glyphs sit on
|
- The fog never participates in hit-test. Planet glyphs sit on
|
||||||
top of fog, so clicks on visible planets work unchanged.
|
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
|
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
|
## Debug surface
|
||||||
|
|
||||||
The DEV-only `__galaxyDebug` object (defined in
|
The DEV-only `__galaxyDebug` object (defined in
|
||||||
`routes/__debug/store/+page.svelte`) exposes
|
`routes/__debug/store/+page.svelte`) exposes
|
||||||
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`, and
|
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`,
|
||||||
`getMapFog()` so e2e specs can assert the renderer's current
|
`getMapFog()`, `getMapMode()`, and `getMapRenderCount()` so e2e
|
||||||
state without scraping pixels:
|
specs can assert the renderer's current state without scraping
|
||||||
|
pixels:
|
||||||
|
|
||||||
- `getMapPrimitives()` returns a snapshot of every primitive in
|
- `getMapPrimitives()` returns a snapshot of every primitive in
|
||||||
the active world: id, kind, priority, current alpha
|
the active world: id, kind, priority, current alpha
|
||||||
@@ -342,10 +382,18 @@ state without scraping pixels:
|
|||||||
- `getMapFog()` returns the most recent fog input
|
- `getMapFog()` returns the most recent fog input
|
||||||
(the list of circles last passed to `setVisibilityFog`).
|
(the list of circles last passed to `setVisibilityFog`).
|
||||||
Empty when the `visibleHyperspace` toggle is off.
|
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
|
The active map view registers providers on mount via
|
||||||
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
|
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
|
||||||
/ `registerMapCameraProvider` / `registerMapFogProvider` in
|
/ `registerMapCameraProvider` / `registerMapFogProvider` /
|
||||||
|
`registerMapModeProvider` / `registerMapRenderCountProvider` in
|
||||||
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
|
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
|
||||||
the surface invokes them lazily on every read.
|
the surface invokes them lazily on every read.
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ preference the store already manages.
|
|||||||
registerMapModeProvider,
|
registerMapModeProvider,
|
||||||
registerMapPickStateProvider,
|
registerMapPickStateProvider,
|
||||||
registerMapPrimitivesProvider,
|
registerMapPrimitivesProvider,
|
||||||
|
registerMapRenderCountProvider,
|
||||||
type MapCameraSnapshot,
|
type MapCameraSnapshot,
|
||||||
type MapFogSnapshot,
|
type MapFogSnapshot,
|
||||||
type MapPickStateSnapshot,
|
type MapPickStateSnapshot,
|
||||||
@@ -536,12 +537,16 @@ preference the store already manages.
|
|||||||
const detachMode = registerMapModeProvider(() =>
|
const detachMode = registerMapModeProvider(() =>
|
||||||
handle === null ? null : handle.getMode(),
|
handle === null ? null : handle.getMode(),
|
||||||
);
|
);
|
||||||
|
const detachRenderCount = registerMapRenderCountProvider(() =>
|
||||||
|
handle === null ? null : handle.getRenderCount(),
|
||||||
|
);
|
||||||
detachDebugProviders = (): void => {
|
detachDebugProviders = (): void => {
|
||||||
detachPrim();
|
detachPrim();
|
||||||
detachPick();
|
detachPick();
|
||||||
detachCamera();
|
detachCamera();
|
||||||
detachFog();
|
detachFog();
|
||||||
detachMode();
|
detachMode();
|
||||||
|
detachRenderCount();
|
||||||
};
|
};
|
||||||
mountedTurn = report.turn;
|
mountedTurn = report.turn;
|
||||||
mountedGameId = targetGameId;
|
mountedGameId = targetGameId;
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ type PickStateProvider = () => MapPickStateSnapshot;
|
|||||||
type CameraProvider = () => MapCameraSnapshot | null;
|
type CameraProvider = () => MapCameraSnapshot | null;
|
||||||
type FogProvider = () => MapFogSnapshot;
|
type FogProvider = () => MapFogSnapshot;
|
||||||
type ModeProvider = () => WrapMode | null;
|
type ModeProvider = () => WrapMode | null;
|
||||||
|
type RenderCountProvider = () => number | null;
|
||||||
|
|
||||||
let primitivesProvider: PrimitivesProvider | null = null;
|
let primitivesProvider: PrimitivesProvider | null = null;
|
||||||
let pickStateProvider: PickStateProvider | null = null;
|
let pickStateProvider: PickStateProvider | null = null;
|
||||||
let cameraProvider: CameraProvider | null = null;
|
let cameraProvider: CameraProvider | null = null;
|
||||||
let fogProvider: FogProvider | null = null;
|
let fogProvider: FogProvider | null = null;
|
||||||
let modeProvider: ModeProvider | null = null;
|
let modeProvider: ModeProvider | null = null;
|
||||||
|
let renderCountProvider: RenderCountProvider | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* registerMapPrimitivesProvider attaches a provider that yields the
|
* registerMapPrimitivesProvider attaches a provider that yields the
|
||||||
@@ -152,6 +154,23 @@ export function registerMapModeProvider(provider: ModeProvider): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerMapRenderCountProvider attaches a provider that yields the
|
||||||
|
* renderer's actual painted-frame count. Because the renderer runs
|
||||||
|
* render-on-demand, the count stays flat while the map is idle and
|
||||||
|
* only advances on camera moves or content mutations. e2e specs use
|
||||||
|
* it to assert the idle map does not keep repainting. Same idempotent
|
||||||
|
* semantics as the other providers.
|
||||||
|
*/
|
||||||
|
export function registerMapRenderCountProvider(
|
||||||
|
provider: RenderCountProvider,
|
||||||
|
): () => void {
|
||||||
|
renderCountProvider = provider;
|
||||||
|
return () => {
|
||||||
|
if (renderCountProvider === provider) renderCountProvider = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
|
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
|
||||||
active: false,
|
active: false,
|
||||||
sourcePlanetNumber: null,
|
sourcePlanetNumber: null,
|
||||||
@@ -191,6 +210,13 @@ export function getMapMode(): WrapMode | null {
|
|||||||
return modeProvider?.() ?? null;
|
return modeProvider?.() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pulls the renderer's painted-frame count. Returns `null` when no
|
||||||
|
* map view is mounted. Stays constant on idle frames (render-on-demand)
|
||||||
|
* and advances only on camera moves or content mutations. */
|
||||||
|
export function getMapRenderCount(): number | null {
|
||||||
|
return renderCountProvider?.() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RendererDebugWindow {
|
interface RendererDebugWindow {
|
||||||
__galaxyDebug?: {
|
__galaxyDebug?: {
|
||||||
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
|
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
|
||||||
@@ -198,6 +224,7 @@ interface RendererDebugWindow {
|
|||||||
getMapCamera?: () => MapCameraSnapshot | null;
|
getMapCamera?: () => MapCameraSnapshot | null;
|
||||||
getMapFog?: () => MapFogSnapshot;
|
getMapFog?: () => MapFogSnapshot;
|
||||||
getMapMode?: () => WrapMode | null;
|
getMapMode?: () => WrapMode | null;
|
||||||
|
getMapRenderCount?: () => number | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -222,6 +249,7 @@ export function installRendererDebugSurface(): () => void {
|
|||||||
getMapCamera,
|
getMapCamera,
|
||||||
getMapFog,
|
getMapFog,
|
||||||
getMapMode,
|
getMapMode,
|
||||||
|
getMapRenderCount,
|
||||||
};
|
};
|
||||||
win.__galaxyDebug = surface;
|
win.__galaxyDebug = surface;
|
||||||
return (): void => {
|
return (): void => {
|
||||||
@@ -245,5 +273,8 @@ export function installRendererDebugSurface(): () => void {
|
|||||||
if (current.getMapMode === getMapMode) {
|
if (current.getMapMode === getMapMode) {
|
||||||
delete current.getMapMode;
|
delete current.getMapMode;
|
||||||
}
|
}
|
||||||
|
if (current.getMapRenderCount === getMapRenderCount) {
|
||||||
|
delete current.getMapRenderCount;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,15 @@
|
|||||||
// Hit-test is owned by ./hit-test.ts; this file only exposes the
|
// Hit-test is owned by ./hit-test.ts; this file only exposes the
|
||||||
// current camera and viewport so callers can run hits.
|
// current camera and viewport so callers can run hits.
|
||||||
|
|
||||||
import { Application, Container, Graphics, type Renderer, type RendererType } from "pixi.js";
|
import {
|
||||||
|
Application,
|
||||||
|
Container,
|
||||||
|
Graphics,
|
||||||
|
Ticker,
|
||||||
|
UPDATE_PRIORITY,
|
||||||
|
type Renderer,
|
||||||
|
type RendererType,
|
||||||
|
} from "pixi.js";
|
||||||
import { Viewport as PixiViewport } from "pixi-viewport";
|
import { Viewport as PixiViewport } from "pixi-viewport";
|
||||||
|
|
||||||
import { hitTest, type Hit } from "./hit-test";
|
import { hitTest, type Hit } from "./hit-test";
|
||||||
@@ -66,6 +74,15 @@ export interface RendererHandle {
|
|||||||
getCamera(): Camera;
|
getCamera(): Camera;
|
||||||
getViewport(): Viewport;
|
getViewport(): Viewport;
|
||||||
getBackend(): "webgl" | "webgpu" | "canvas";
|
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;
|
hitAt(cursorPx: { x: number; y: number }): Hit | null;
|
||||||
/**
|
/**
|
||||||
* setExtraPrimitives replaces the current overlay primitive layer
|
* setExtraPrimitives replaces the current overlay primitive layer
|
||||||
@@ -329,6 +346,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
autoDensity: true,
|
autoDensity: true,
|
||||||
resolution,
|
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({
|
const viewport = new PixiViewport({
|
||||||
screenWidth: widthPx,
|
screenWidth: widthPx,
|
||||||
@@ -337,7 +360,35 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
worldHeight: opts.world.height,
|
worldHeight: opts.world.height,
|
||||||
events: app.renderer.events,
|
events: app.renderer.events,
|
||||||
});
|
});
|
||||||
viewport.drag().wheel({ smooth: 5 }).pinch().decelerate();
|
// 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);
|
app.stage.addChild(viewport);
|
||||||
|
|
||||||
@@ -484,6 +535,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
viewport.on("moved", wrapTorusCamera);
|
viewport.on("moved", wrapTorusCamera);
|
||||||
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);
|
applyMode(mode);
|
||||||
@@ -621,6 +676,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
width: PICK_OVERLAY_STYLE.hover.width,
|
width: PICK_OVERLAY_STYLE.hover.width,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
requestRender();
|
||||||
};
|
};
|
||||||
const teardownPickMode = (): void => {
|
const teardownPickMode = (): void => {
|
||||||
if (!pickModeActive) return;
|
if (!pickModeActive) return;
|
||||||
@@ -636,6 +692,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
pickOverlay = null;
|
pickOverlay = null;
|
||||||
}
|
}
|
||||||
pickOptions = 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 => {
|
const openPickMode = (options: PickModeOptions): PickModeHandle => {
|
||||||
// An existing session is cancelled first so the previous
|
// An existing session is cancelled first so the previous
|
||||||
@@ -711,6 +770,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
heightPx: viewport.screenHeight,
|
heightPx: viewport.screenHeight,
|
||||||
}),
|
}),
|
||||||
getBackend: () => rendererBackendName(app.renderer),
|
getBackend: () => rendererBackendName(app.renderer),
|
||||||
|
getRenderCount: () => renderCount,
|
||||||
hitAt: (cursorPx) =>
|
hitAt: (cursorPx) =>
|
||||||
hitTest(
|
hitTest(
|
||||||
currentWorld,
|
currentWorld,
|
||||||
@@ -748,6 +808,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
...opts.world.primitives,
|
...opts.world.primitives,
|
||||||
...prims,
|
...prims,
|
||||||
]);
|
]);
|
||||||
|
requestRender();
|
||||||
},
|
},
|
||||||
getPrimitives: () => currentWorld.primitives,
|
getPrimitives: () => currentWorld.primitives,
|
||||||
onClick: (cb) => {
|
onClick: (cb) => {
|
||||||
@@ -803,6 +864,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
for (const [id, list] of primitiveGraphics) {
|
for (const [id, list] of primitiveGraphics) {
|
||||||
applyHiddenStateTo(id, list);
|
applyHiddenStateTo(id, list);
|
||||||
}
|
}
|
||||||
|
requestRender();
|
||||||
},
|
},
|
||||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||||
setVisibilityFog: (circles) => {
|
setVisibilityFog: (circles) => {
|
||||||
@@ -813,6 +875,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
for (const old of fogLayer.removeChildren()) {
|
for (const old of fogLayer.removeChildren()) {
|
||||||
old.destroy({ children: true });
|
old.destroy({ children: true });
|
||||||
}
|
}
|
||||||
|
// Repaint whether or not new fog is added: clearing the layer
|
||||||
|
// (toggling the fog off) is itself a scene change.
|
||||||
|
requestRender();
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
opts.world,
|
opts.world,
|
||||||
circles,
|
circles,
|
||||||
@@ -848,8 +913,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
if (mode === "no-wrap") {
|
if (mode === "no-wrap") {
|
||||||
enforceCentreWhenLarger();
|
enforceCentreWhenLarger();
|
||||||
}
|
}
|
||||||
|
// The drawing buffer was resized; repaint at the new size.
|
||||||
|
requestRender();
|
||||||
},
|
},
|
||||||
dispose: () => {
|
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
|
// Tear down any open pick session before destroying the
|
||||||
// app — the resolution callback might reference Svelte
|
// app — the resolution callback might reference Svelte
|
||||||
// stores that disappear next tick on dispose, but
|
// stores that disappear next tick on dispose, but
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
// * `getMapFog()` — the current visibility-fog circle list.
|
// * `getMapFog()` — the current visibility-fog circle list.
|
||||||
// * `getMapCamera()` — the wrap-mode test reads the centre before
|
// * `getMapCamera()` — the wrap-mode test reads the centre before
|
||||||
// and after the flip to confirm camera preservation.
|
// and after the flip to confirm camera preservation.
|
||||||
|
// * `getMapRenderCount()` — painted-frame counter used by the
|
||||||
|
// render-on-demand specs at the bottom of this file: an idle map
|
||||||
|
// must not keep repainting, and a released drag must not coast
|
||||||
|
// (the `decelerate` plugin was removed).
|
||||||
|
|
||||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
@@ -400,3 +404,105 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
|||||||
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
||||||
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// settledRenderCount waits out the mount/resize paint burst and returns
|
||||||
|
// the painted-frame count once it stops advancing. The renderer runs
|
||||||
|
// render-on-demand, so the count goes flat as soon as the scene is
|
||||||
|
// static; the loop bails after a fixed number of samples so a renderer
|
||||||
|
// that never settles fails the spec instead of hanging.
|
||||||
|
async function settledRenderCount(page: Page): Promise<number> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (window.__galaxyDebug?.getMapRenderCount?.() ?? 0) > 0,
|
||||||
|
);
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const read = (): number =>
|
||||||
|
window.__galaxyDebug!.getMapRenderCount!() ?? 0;
|
||||||
|
let prev = read();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const cur = read();
|
||||||
|
if (cur === prev) return cur;
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("render-on-demand: an idle map does not repaint, a content mutation does", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockGateway(page, { currentTurn: 1 });
|
||||||
|
await bootSession(page);
|
||||||
|
await openGame(page);
|
||||||
|
|
||||||
|
const settled = await settledRenderCount(page);
|
||||||
|
|
||||||
|
// Idle window: no pointer interaction, no toggle. A continuous
|
||||||
|
// auto-render loop would add ~40 frames over 700ms at 60fps; render
|
||||||
|
// -on-demand adds none. The +2 slack tolerates a lone stray frame
|
||||||
|
// (e.g. a late layout settle) while still failing hard if the
|
||||||
|
// always-on loop ever comes back.
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const afterIdle = await page.evaluate(
|
||||||
|
() => window.__galaxyDebug!.getMapRenderCount!(),
|
||||||
|
);
|
||||||
|
expect(afterIdle).toBeLessThanOrEqual(settled + 2);
|
||||||
|
|
||||||
|
// Toggling the fog mutates the scene graph and must repaint.
|
||||||
|
await page.getByTestId("map-toggles-trigger").click();
|
||||||
|
await page.getByTestId("map-toggles-visible-hyperspace").click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
|
||||||
|
);
|
||||||
|
// The repaint lands on the next shared-ticker frame after the fog
|
||||||
|
// input changed, so poll for the counter to advance rather than
|
||||||
|
// reading it synchronously (the timing of that frame is racy).
|
||||||
|
await page.waitForFunction(
|
||||||
|
(baseline) => window.__galaxyDebug!.getMapRenderCount!() > baseline,
|
||||||
|
afterIdle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pan stops immediately on release: no inertia tail after a drag", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockGateway(page, { currentTurn: 1 });
|
||||||
|
await bootSession(page);
|
||||||
|
await openGame(page);
|
||||||
|
|
||||||
|
await settledRenderCount(page);
|
||||||
|
|
||||||
|
const canvas = page.getByTestId("active-view-map").locator("canvas");
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
if (box === null) return;
|
||||||
|
const cx = box.x + box.width / 2;
|
||||||
|
const cy = box.y + box.height / 2;
|
||||||
|
|
||||||
|
// Decisive drag with intermediate steps so pixi-viewport's drag
|
||||||
|
// plugin clears its movement threshold.
|
||||||
|
await page.mouse.move(cx, cy);
|
||||||
|
await page.mouse.down();
|
||||||
|
for (let step = 1; step <= 16; step++) {
|
||||||
|
await page.mouse.move(cx - (160 * step) / 16, cy - (120 * step) / 16);
|
||||||
|
}
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Let the final drag frame flush, then snapshot the camera centre
|
||||||
|
// and confirm it does not drift over the next ~500ms. Without the
|
||||||
|
// `decelerate` plugin the viewport freezes the instant the drag
|
||||||
|
// ends, so the centre is identical; a re-introduced inertia tail
|
||||||
|
// would coast it by many world units. (If the synthetic drag never
|
||||||
|
// registered the centre is also static, so the spec never
|
||||||
|
// false-fails — it only catches a returning inertia tail.)
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const atRelease = await page.evaluate(
|
||||||
|
() => window.__galaxyDebug!.getMapCamera!()!.camera,
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const later = await page.evaluate(
|
||||||
|
() => window.__galaxyDebug!.getMapCamera!()!.camera,
|
||||||
|
);
|
||||||
|
expect(Math.abs(later.centerX - atRelease.centerX)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(later.centerY - atRelease.centerY)).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface DebugSurface {
|
|||||||
getMapCamera(): MapCameraSnapshot | null;
|
getMapCamera(): MapCameraSnapshot | null;
|
||||||
getMapFog(): MapFogSnapshot;
|
getMapFog(): MapFogSnapshot;
|
||||||
getMapMode(): WrapMode | null;
|
getMapMode(): WrapMode | null;
|
||||||
|
getMapRenderCount(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user