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