fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.
Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.
fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.
Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -394,14 +394,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
|
||||
// Phase 29 fog layer: a single Container sharing the viewport's
|
||||
// coordinate space, populated by `setVisibilityFog`. Added to
|
||||
// the viewport BEFORE the nine torus copies so the layered
|
||||
// repaint (fog rectangles + background-coloured circles) always
|
||||
// renders underneath every primitive. An earlier per-copy
|
||||
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
|
||||
// top in practice — moving the fog to a sibling of the copies
|
||||
// avoids any reorder ambiguity.
|
||||
// the viewport BEFORE the nine torus copies so the fog always
|
||||
// renders underneath every primitive. The layer holds the
|
||||
// fog-coloured world rectangle(s); the visibility holes are cut by
|
||||
// an inverse stencil mask (`fogMask`, the union of the visibility
|
||||
// circles) rather than by opaque overpaint — see `setVisibilityFog`.
|
||||
// An earlier per-copy approach with `copy.addChildAt(fog, 0)` ended
|
||||
// up with fog on top in practice — moving the fog to a sibling of
|
||||
// the copies avoids any reorder ambiguity.
|
||||
const fogLayer = new Container();
|
||||
viewport.addChild(fogLayer);
|
||||
// Inverse mask for `fogLayer`, rebuilt by `setVisibilityFog`. Pixi
|
||||
// requires a mask to live in the display list, so it sits as a
|
||||
// sibling under the viewport; `null` whenever the fog is off.
|
||||
let fogMask: Graphics | null = null;
|
||||
|
||||
// Create nine torus copies, each holding its own primitive
|
||||
// graphics. Origin copy is always visible; the other eight
|
||||
@@ -868,13 +874,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
},
|
||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||
setVisibilityFog: (circles) => {
|
||||
// Drop the previous fog children — every flip rebuilds
|
||||
// from scratch instead of mutating in place. Pixi v8's
|
||||
// `Container.removeChildren()` returns the detached
|
||||
// children so we can destroy each one explicitly.
|
||||
// Detach the old mask before destroying its Graphics, then
|
||||
// drop the previous fog rectangles. Every flip rebuilds from
|
||||
// scratch instead of mutating in place.
|
||||
fogLayer.mask = null;
|
||||
for (const old of fogLayer.removeChildren()) {
|
||||
old.destroy({ children: true });
|
||||
}
|
||||
if (fogMask !== null) {
|
||||
fogMask.destroy();
|
||||
fogMask = null;
|
||||
}
|
||||
// Repaint whether or not new fog is added: clearing the layer
|
||||
// (toggling the fog off) is itself a scene change.
|
||||
requestRender();
|
||||
@@ -886,21 +896,44 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
mode,
|
||||
);
|
||||
if (ops.length === 0) return;
|
||||
// Each op gets its own Graphics so any multi-shape Pixi
|
||||
// quirks cannot drop a layer (an earlier all-in-one
|
||||
// implementation surfaced exactly that symptom in DEV —
|
||||
// only the last planet's glyph stayed visible inside the
|
||||
// bg holes). The ops carry world-space positions; the
|
||||
// `fogLayer` has no transform.
|
||||
// The fog is the fog-coloured rectangle(s); the visibility
|
||||
// holes are cut by an INVERSE stencil mask built from the
|
||||
// union of the visibility circles. This replaces the earlier
|
||||
// opaque-circle overpaint, which on a large report painted
|
||||
// dozens of near-full-world opaque circles every frame — a
|
||||
// fill-rate cliff that froze panning under Safari's WebGPU
|
||||
// backend. A stencil mask rasterises the same circles far
|
||||
// cheaper (no blended colour writes) and stays fully vector,
|
||||
// so the fog edge is crisp at any zoom. The circle ops carry
|
||||
// `bgColor`, unused here — a mask only needs the shape.
|
||||
const mask = new Graphics();
|
||||
let hasHole = false;
|
||||
for (const op of ops) {
|
||||
const g = new Graphics();
|
||||
if (op.kind === "fillRect") {
|
||||
const g = new Graphics();
|
||||
g.rect(op.x, op.y, op.width, op.height);
|
||||
g.fill({ color: op.color, alpha: op.alpha });
|
||||
fogLayer.addChild(g);
|
||||
} else {
|
||||
g.circle(op.x, op.y, op.radius);
|
||||
// One fill per circle keeps each shape independent;
|
||||
// overlapping fills simply union in the stencil, which
|
||||
// is exactly the visibility coverage we want to cut.
|
||||
mask.circle(op.x, op.y, op.radius);
|
||||
mask.fill(0xffffff);
|
||||
hasHole = true;
|
||||
}
|
||||
g.fill({ color: op.color, alpha: op.alpha });
|
||||
fogLayer.addChild(g);
|
||||
}
|
||||
if (hasHole) {
|
||||
// The mask must be in the display list for its transform
|
||||
// to resolve; it shares the viewport's untransformed world
|
||||
// space with `fogLayer`, so the world-space op coordinates
|
||||
// line up. `inverse: true` shows the fog everywhere EXCEPT
|
||||
// inside the circle union.
|
||||
viewport.addChild(mask);
|
||||
fogLayer.setMask({ mask, inverse: true });
|
||||
fogMask = mask;
|
||||
} else {
|
||||
mask.destroy();
|
||||
}
|
||||
},
|
||||
resize: (w, h) => {
|
||||
@@ -932,13 +965,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
}
|
||||
// `app.destroy({...children: true})` below recursively
|
||||
// destroys every container in the scene graph, fogLayer
|
||||
// included. The explicit removeChildren()/destroy here
|
||||
// drops the fog children eagerly so a future caller
|
||||
// querying the renderer mid-dispose does not see stale
|
||||
// fog instances still parented under the layer.
|
||||
// included. The explicit teardown here drops the fog
|
||||
// rectangles + the inverse mask eagerly so a future caller
|
||||
// querying the renderer mid-dispose does not see stale fog
|
||||
// instances still parented under the layer.
|
||||
fogLayer.mask = null;
|
||||
for (const old of fogLayer.removeChildren()) {
|
||||
old.destroy({ children: true });
|
||||
}
|
||||
if (fogMask !== null) {
|
||||
fogMask.destroy();
|
||||
fogMask = null;
|
||||
}
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
|
||||
Reference in New Issue
Block a user