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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user