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:
+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
|
||||
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
|
||||
sibling that sits below the nine torus copies, not a child of
|
||||
them). `fogPaintOps` returns an ordered op list — one world-sized
|
||||
rectangle filled with `FOG_COLOR` (two shades lighter than the
|
||||
dark theme background), then an opaque background-coloured circle
|
||||
for every visibility circle — and the renderer dispatches each op
|
||||
onto its own `Graphics`. The overpaint order naturally unions
|
||||
overlapping circles — earlier iterations used Pixi v8's
|
||||
`Graphics.cut()` to subtract holes, but `cut()` produces incorrect
|
||||
unions for multiple overlapping holes; layered repainting trades
|
||||
one extra fill per circle for a predictable, geometry-free union.
|
||||
- The ops carry world-space positions, so wrap mode is baked into
|
||||
the op list rather than into copy visibility: `torus` emits the
|
||||
sibling below the nine torus copies). `fogPaintOps` returns an
|
||||
ordered op list — one world-sized rectangle filled with `FOG_COLOR`
|
||||
(two shades lighter than the dark theme background) plus one circle
|
||||
per visibility circle. The renderer draws the rectangle ops into
|
||||
`fogLayer` and collects the circle ops into a single `Graphics` set
|
||||
as `fogLayer`'s **inverse stencil mask**
|
||||
(`setMask({ mask, inverse: true })`), so the fog shows everywhere
|
||||
EXCEPT inside the union of the circles. Overlapping circles union
|
||||
for free in the stencil.
|
||||
- Why a mask: earlier iterations subtracted holes with Pixi v8's
|
||||
`Graphics.cut()` (incorrect unions for overlapping holes), then with
|
||||
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;
|
||||
`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
|
||||
primitives paint on top.
|
||||
- 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
|
||||
`visibleHyperspace` toggle changes, and under render-on-demand a
|
||||
static fog paints no frames at all — the layered overpaint cost is
|
||||
only paid on the frames where the camera is actually moving.
|
||||
static fog paints no frames at all — the mask-cheap fog cost is only
|
||||
paid on the frames where the camera is actually moving.
|
||||
|
||||
## Debug surface
|
||||
|
||||
|
||||
Reference in New Issue
Block a user