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