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:
+20
-3
@@ -3293,9 +3293,26 @@ Decisions:
|
|||||||
is removed so a released drag stops instantly and the viewport
|
is removed so a released drag stops instantly and the viewport
|
||||||
goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
|
goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
|
||||||
on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
|
on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
|
||||||
assertions. If Safari pan is still heavy after this, stage 2 cuts
|
assertions. The owner confirmed this removed the idle / whole-system
|
||||||
the overpaint itself (an inverse stencil mask of the circle union,
|
freeze, but panning a loaded map with the fog on stayed heavy in
|
||||||
kept vector so the map stays crisp at any zoom).
|
Safari (the overpaint fill-rate was untouched) — addressed in
|
||||||
|
decision 9.
|
||||||
|
9. **Inverse stencil mask for the fog (fog perf, stage 2).** The fog's
|
||||||
|
visibility holes were previously cut by opaque background-coloured
|
||||||
|
circle overpaint — on a large report dozens of near-world-sized
|
||||||
|
opaque circles repainted every frame, the fill-rate cliff that kept
|
||||||
|
Safari's WebGPU pan heavy after stage 1. Stage 2 replaces the
|
||||||
|
overpaint with an INVERSE stencil mask: `setVisibilityFog` 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 to
|
||||||
|
one rectangle fill plus a stencil pass (no blended colour writes,
|
||||||
|
friendly to Apple's tile-based GPU), 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); the
|
||||||
|
rendered result is verified by a high-contrast screenshot during
|
||||||
|
development plus the existing fog / render-on-demand e2e.
|
||||||
|
|
||||||
## Phase 30. Calculator Tab
|
## Phase 30. Calculator Tab
|
||||||
|
|
||||||
|
|||||||
+26
-16
@@ -333,22 +333,32 @@ Phase 29 fog overlay used to highlight the player's visible
|
|||||||
hyperspace. Each entry describes a circle around a LOCAL planet
|
hyperspace. Each entry describes a circle around a LOCAL planet
|
||||||
where the player has scanner / visibility coverage:
|
where the player has scanner / visibility coverage:
|
||||||
|
|
||||||
- An empty list destroys the existing fog `Graphics`.
|
- An empty list destroys the existing fog rectangles and mask.
|
||||||
- A non-empty list rebuilds a single viewport-level `fogLayer` (a
|
- A non-empty list rebuilds a single viewport-level `fogLayer` (a
|
||||||
sibling that sits below the nine torus copies, not a child of
|
sibling below the nine torus copies). `fogPaintOps` returns an
|
||||||
them). `fogPaintOps` returns an ordered op list — one world-sized
|
ordered op list — one world-sized rectangle filled with `FOG_COLOR`
|
||||||
rectangle filled with `FOG_COLOR` (two shades lighter than the
|
(two shades lighter than the dark theme background) plus one circle
|
||||||
dark theme background), then an opaque background-coloured circle
|
per visibility circle. The renderer draws the rectangle ops into
|
||||||
for every visibility circle — and the renderer dispatches each op
|
`fogLayer` and collects the circle ops into a single `Graphics` set
|
||||||
onto its own `Graphics`. The overpaint order naturally unions
|
as `fogLayer`'s **inverse stencil mask**
|
||||||
overlapping circles — earlier iterations used Pixi v8's
|
(`setMask({ mask, inverse: true })`), so the fog shows everywhere
|
||||||
`Graphics.cut()` to subtract holes, but `cut()` produces incorrect
|
EXCEPT inside the union of the circles. Overlapping circles union
|
||||||
unions for multiple overlapping holes; layered repainting trades
|
for free in the stencil.
|
||||||
one extra fill per circle for a predictable, geometry-free union.
|
- Why a mask: earlier iterations subtracted holes with Pixi v8's
|
||||||
- The ops carry world-space positions, so wrap mode is baked into
|
`Graphics.cut()` (incorrect unions for overlapping holes), then with
|
||||||
the op list rather than into copy visibility: `torus` emits the
|
opaque background-coloured overpaint. The overpaint was a fill-rate
|
||||||
|
cliff — on a large report it painted dozens of near-world-sized
|
||||||
|
opaque circles every frame, which froze panning under Safari's
|
||||||
|
WebGPU backend. An inverse stencil mask rasterises the same circles
|
||||||
|
far cheaper (no blended colour writes, friendly to Apple's
|
||||||
|
tile-based GPU) and stays fully vector, so the fog edge is crisp at
|
||||||
|
any zoom.
|
||||||
|
- The ops carry world-space positions, so wrap mode is baked into the
|
||||||
|
op list rather than into copy visibility: `torus` emits the
|
||||||
rectangle and every circle at the nine `{-1,0,1}²` tile offsets;
|
rectangle and every circle at the nine `{-1,0,1}²` tile offsets;
|
||||||
`no-wrap` emits only the central tile. `fogLayer` has no transform.
|
`no-wrap` emits only the central tile. `fogLayer` and the mask are
|
||||||
|
both untransformed children of the viewport, so the coordinates line
|
||||||
|
up.
|
||||||
- The fog layer sits below every primitive copy in z-order, so
|
- The fog layer sits below every primitive copy in z-order, so
|
||||||
primitives paint on top.
|
primitives paint on top.
|
||||||
- The fog never participates in hit-test. Planet glyphs sit on
|
- The fog never participates in hit-test. Planet glyphs sit on
|
||||||
@@ -356,8 +366,8 @@ where the player has scanner / visibility coverage:
|
|||||||
|
|
||||||
The map view recomputes the fog input only when the report or the
|
The map view recomputes the fog input only when the report or the
|
||||||
`visibleHyperspace` toggle changes, and under render-on-demand a
|
`visibleHyperspace` toggle changes, and under render-on-demand a
|
||||||
static fog paints no frames at all — the layered overpaint cost is
|
static fog paints no frames at all — the mask-cheap fog cost is only
|
||||||
only paid on the frames where the camera is actually moving.
|
paid on the frames where the camera is actually moving.
|
||||||
|
|
||||||
## Debug surface
|
## Debug surface
|
||||||
|
|
||||||
|
|||||||
@@ -394,14 +394,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
|
|
||||||
// Phase 29 fog layer: a single Container sharing the viewport's
|
// Phase 29 fog layer: a single Container sharing the viewport's
|
||||||
// coordinate space, populated by `setVisibilityFog`. Added to
|
// coordinate space, populated by `setVisibilityFog`. Added to
|
||||||
// the viewport BEFORE the nine torus copies so the layered
|
// the viewport BEFORE the nine torus copies so the fog always
|
||||||
// repaint (fog rectangles + background-coloured circles) always
|
// renders underneath every primitive. The layer holds the
|
||||||
// renders underneath every primitive. An earlier per-copy
|
// fog-coloured world rectangle(s); the visibility holes are cut by
|
||||||
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
|
// an inverse stencil mask (`fogMask`, the union of the visibility
|
||||||
// top in practice — moving the fog to a sibling of the copies
|
// circles) rather than by opaque overpaint — see `setVisibilityFog`.
|
||||||
// avoids any reorder ambiguity.
|
// 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();
|
const fogLayer = new Container();
|
||||||
viewport.addChild(fogLayer);
|
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
|
// Create nine torus copies, each holding its own primitive
|
||||||
// graphics. Origin copy is always visible; the other eight
|
// 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),
|
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||||
setVisibilityFog: (circles) => {
|
setVisibilityFog: (circles) => {
|
||||||
// Drop the previous fog children — every flip rebuilds
|
// Detach the old mask before destroying its Graphics, then
|
||||||
// from scratch instead of mutating in place. Pixi v8's
|
// drop the previous fog rectangles. Every flip rebuilds from
|
||||||
// `Container.removeChildren()` returns the detached
|
// scratch instead of mutating in place.
|
||||||
// children so we can destroy each one explicitly.
|
fogLayer.mask = null;
|
||||||
for (const old of fogLayer.removeChildren()) {
|
for (const old of fogLayer.removeChildren()) {
|
||||||
old.destroy({ children: true });
|
old.destroy({ children: true });
|
||||||
}
|
}
|
||||||
|
if (fogMask !== null) {
|
||||||
|
fogMask.destroy();
|
||||||
|
fogMask = null;
|
||||||
|
}
|
||||||
// Repaint whether or not new fog is added: clearing the layer
|
// Repaint whether or not new fog is added: clearing the layer
|
||||||
// (toggling the fog off) is itself a scene change.
|
// (toggling the fog off) is itself a scene change.
|
||||||
requestRender();
|
requestRender();
|
||||||
@@ -886,21 +896,44 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
if (ops.length === 0) return;
|
if (ops.length === 0) return;
|
||||||
// Each op gets its own Graphics so any multi-shape Pixi
|
// The fog is the fog-coloured rectangle(s); the visibility
|
||||||
// quirks cannot drop a layer (an earlier all-in-one
|
// holes are cut by an INVERSE stencil mask built from the
|
||||||
// implementation surfaced exactly that symptom in DEV —
|
// union of the visibility circles. This replaces the earlier
|
||||||
// only the last planet's glyph stayed visible inside the
|
// opaque-circle overpaint, which on a large report painted
|
||||||
// bg holes). The ops carry world-space positions; the
|
// dozens of near-full-world opaque circles every frame — a
|
||||||
// `fogLayer` has no transform.
|
// 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) {
|
for (const op of ops) {
|
||||||
const g = new Graphics();
|
|
||||||
if (op.kind === "fillRect") {
|
if (op.kind === "fillRect") {
|
||||||
|
const g = new Graphics();
|
||||||
g.rect(op.x, op.y, op.width, op.height);
|
g.rect(op.x, op.y, op.width, op.height);
|
||||||
|
g.fill({ color: op.color, alpha: op.alpha });
|
||||||
|
fogLayer.addChild(g);
|
||||||
} else {
|
} 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) => {
|
resize: (w, h) => {
|
||||||
@@ -932,13 +965,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
}
|
}
|
||||||
// `app.destroy({...children: true})` below recursively
|
// `app.destroy({...children: true})` below recursively
|
||||||
// destroys every container in the scene graph, fogLayer
|
// destroys every container in the scene graph, fogLayer
|
||||||
// included. The explicit removeChildren()/destroy here
|
// included. The explicit teardown here drops the fog
|
||||||
// drops the fog children eagerly so a future caller
|
// rectangles + the inverse mask eagerly so a future caller
|
||||||
// querying the renderer mid-dispose does not see stale
|
// querying the renderer mid-dispose does not see stale fog
|
||||||
// fog instances still parented under the layer.
|
// instances still parented under the layer.
|
||||||
|
fogLayer.mask = null;
|
||||||
for (const old of fogLayer.removeChildren()) {
|
for (const old of fogLayer.removeChildren()) {
|
||||||
old.destroy({ children: true });
|
old.destroy({ children: true });
|
||||||
}
|
}
|
||||||
|
if (fogMask !== null) {
|
||||||
|
fogMask.destroy();
|
||||||
|
fogMask = null;
|
||||||
|
}
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
viewport.off("moved", wrapTorusCamera);
|
viewport.off("moved", wrapTorusCamera);
|
||||||
viewport.off("clicked", handleViewportClicked);
|
viewport.off("clicked", handleViewportClicked);
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Phase 29 unit coverage for the visible-hyperspace overlay's
|
// Phase 29 unit coverage for the visible-hyperspace overlay's paint
|
||||||
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
|
// ops. `fogPaintOps` lives in `src/map/render.ts` next to its sole
|
||||||
// next to its sole consumer (`RendererHandle.setVisibilityFog`) —
|
// consumer (`RendererHandle.setVisibilityFog`): the renderer draws
|
||||||
// the renderer dispatches each op onto its own Pixi `Graphics`
|
// the rectangle ops into a `fogLayer` container (below every
|
||||||
// inside a `fogLayer` container that sits below every primitive
|
// primitive copy) and feeds the circle ops into an inverse stencil
|
||||||
// copy. The natural rendering order paints fog underneath the
|
// mask that cuts the visibility holes out of the fog. `fogPaintOps`
|
||||||
// world, replacing the earlier `cut()` implementation that
|
// only produces the ordered op list — rect(s) first, then one circle
|
||||||
// produced disconnected arc segments.
|
// per visibility circle — which is what these tests pin; earlier
|
||||||
|
// renderer implementations used Pixi `cut()` (disconnected arcs) and
|
||||||
|
// then opaque overpaint (a fill-rate cliff under Safari's WebGPU).
|
||||||
//
|
//
|
||||||
// Coordinates returned by `fogPaintOps` are in world space because
|
// Coordinates returned by `fogPaintOps` are in world space because
|
||||||
// `fogLayer` has no transform — wraps for torus mode are baked
|
// `fogLayer` and the mask have no transform — wraps for torus mode
|
||||||
// into the ops directly.
|
// are baked into the ops directly.
|
||||||
|
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user