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

The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.

Stage 1 (vector-preserving, no rasterisation):

- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
  single Ticker.shared flush gated on viewport.dirty (camera) plus an
  internal requestRender() from every content mutation (fog / hide-set /
  extras / wrap mode / resize / pick overlay). An idle map now does zero
  GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
  instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
  deterministic e2e assertions.

Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.

If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-20 16:28:18 +02:00
parent 0da2f4b6fb
commit 51902b995f
7 changed files with 307 additions and 28 deletions
@@ -72,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;
@@ -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;
}
};
}
+72 -2
View File
@@ -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<RendererHan
autoDensity: true,
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({
screenWidth: widthPx,
@@ -337,7 +360,35 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
worldHeight: opts.world.height,
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);
@@ -484,6 +535,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
viewport.on("moved", 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);
@@ -621,6 +676,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
width: PICK_OVERLAY_STYLE.hover.width,
});
}
requestRender();
};
const teardownPickMode = (): void => {
if (!pickModeActive) return;
@@ -636,6 +692,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
pickOverlay = 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 => {
// An existing session is cancelled first so the previous
@@ -711,6 +770,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
heightPx: viewport.screenHeight,
}),
getBackend: () => rendererBackendName(app.renderer),
getRenderCount: () => renderCount,
hitAt: (cursorPx) =>
hitTest(
currentWorld,
@@ -748,6 +808,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
...opts.world.primitives,
...prims,
]);
requestRender();
},
getPrimitives: () => currentWorld.primitives,
onClick: (cb) => {
@@ -803,6 +864,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
for (const [id, list] of primitiveGraphics) {
applyHiddenStateTo(id, list);
}
requestRender();
},
isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => {
@@ -813,6 +875,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
for (const old of fogLayer.removeChildren()) {
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(
opts.world,
circles,
@@ -848,8 +913,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
if (mode === "no-wrap") {
enforceCentreWhenLarger();
}
// The drawing buffer was resized; repaint at the new size.
requestRender();
},
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
// app — the resolution callback might reference Svelte
// stores that disappear next tick on dispose, but
+106
View File
@@ -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<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;
getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
getMapRenderCount(): number;
}
declare global {