diff --git a/ui/PLAN.md b/ui/PLAN.md index a42f28a..f9b7a3e 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3235,8 +3235,9 @@ Targeted tests: - `tests/map-hit-test.test.ts` — `hitTest` honours the `hiddenIds` parameter; - `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode - camera preservation, reload persistence across the four - Playwright projects. + camera preservation, reload persistence, plus render-on-demand + (an idle map does not repaint; a released drag does not coast) + across the four Playwright projects. Decisions: @@ -3278,6 +3279,23 @@ Decisions: `FligthDistance`; the Phase 29 work renamed it to `FlightDistance` (and the only TS call site duplicates the 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 diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index fca2463..30b60d9 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -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. diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 914415d..6b0ec37 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -72,6 +72,7 @@ preference the store already manages. registerMapModeProvider, registerMapPickStateProvider, registerMapPrimitivesProvider, + registerMapRenderCountProvider, type MapCameraSnapshot, type MapFogSnapshot, type MapPickStateSnapshot, @@ -536,12 +537,16 @@ preference the store already manages. const detachMode = registerMapModeProvider(() => handle === null ? null : handle.getMode(), ); + const detachRenderCount = registerMapRenderCountProvider(() => + handle === null ? null : handle.getRenderCount(), + ); detachDebugProviders = (): void => { detachPrim(); detachPick(); detachCamera(); detachFog(); detachMode(); + detachRenderCount(); }; mountedTurn = report.turn; mountedGameId = targetGameId; diff --git a/ui/frontend/src/lib/debug-surface.svelte.ts b/ui/frontend/src/lib/debug-surface.svelte.ts index 41b4afb..1c0d0c4 100644 --- a/ui/frontend/src/lib/debug-surface.svelte.ts +++ b/ui/frontend/src/lib/debug-surface.svelte.ts @@ -74,12 +74,14 @@ type PickStateProvider = () => MapPickStateSnapshot; type CameraProvider = () => MapCameraSnapshot | null; type FogProvider = () => MapFogSnapshot; type ModeProvider = () => WrapMode | null; +type RenderCountProvider = () => number | null; let primitivesProvider: PrimitivesProvider | null = null; let pickStateProvider: PickStateProvider | null = null; let cameraProvider: CameraProvider | null = null; let fogProvider: FogProvider | null = null; let modeProvider: ModeProvider | null = null; +let renderCountProvider: RenderCountProvider | null = null; /** * 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 = { active: false, sourcePlanetNumber: null, @@ -191,6 +210,13 @@ export function getMapMode(): WrapMode | 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 { __galaxyDebug?: { getMapPrimitives?: () => readonly MapPrimitiveSnapshot[]; @@ -198,6 +224,7 @@ interface RendererDebugWindow { getMapCamera?: () => MapCameraSnapshot | null; getMapFog?: () => MapFogSnapshot; getMapMode?: () => WrapMode | null; + getMapRenderCount?: () => number | null; [key: string]: unknown; }; } @@ -222,6 +249,7 @@ export function installRendererDebugSurface(): () => void { getMapCamera, getMapFog, getMapMode, + getMapRenderCount, }; win.__galaxyDebug = surface; return (): void => { @@ -245,5 +273,8 @@ export function installRendererDebugSurface(): () => void { if (current.getMapMode === getMapMode) { delete current.getMapMode; } + if (current.getMapRenderCount === getMapRenderCount) { + delete current.getMapRenderCount; + } }; } diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 5320ad8..efe6b0d 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -17,7 +17,15 @@ // Hit-test is owned by ./hit-test.ts; this file only exposes the // 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 { hitTest, type Hit } from "./hit-test"; @@ -66,6 +74,15 @@ export interface RendererHandle { getCamera(): Camera; getViewport(): Viewport; 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; /** * setExtraPrimitives replaces the current overlay primitive layer @@ -329,6 +346,12 @@ export async function createRenderer(opts: RendererOptions): Promise { + 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); @@ -484,6 +535,10 @@ export async function createRenderer(opts: RendererOptions): Promise { if (!pickModeActive) return; @@ -636,6 +692,9 @@ export async function createRenderer(opts: RendererOptions): Promise { // An existing session is cancelled first so the previous @@ -711,6 +770,7 @@ export async function createRenderer(opts: RendererOptions): Promise rendererBackendName(app.renderer), + getRenderCount: () => renderCount, hitAt: (cursorPx) => hitTest( currentWorld, @@ -748,6 +808,7 @@ export async function createRenderer(opts: RendererOptions): Promise currentWorld.primitives, onClick: (cb) => { @@ -803,6 +864,7 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { @@ -813,6 +875,9 @@ export async function createRenderer(opts: RendererOptions): Promise { + // 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 // app — the resolution callback might reference Svelte // stores that disappear next tick on dispose, but diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index c4ea0f3..cf88cc2 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -11,6 +11,10 @@ // * `getMapFog()` — the current visibility-fog circle list. // * `getMapCamera()` — the wrap-mode test reads the centre before // 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 { 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, 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 { + 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); +}); diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts index e7fce43..b835897 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -50,6 +50,7 @@ interface DebugSurface { getMapCamera(): MapCameraSnapshot | null; getMapFog(): MapFogSnapshot; getMapMode(): WrapMode | null; + getMapRenderCount(): number; } declare global {