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;
}
};
}