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
+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 {