Merge pull request 'fix(ui-map): render-on-demand + drop pan inertia (Safari fog freeze, stage 1)' (#21) from feature/ui-map-render-on-demand into development
This commit was merged in pull request #21.
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