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