From a08f4f55b07df5d5cc44106634cd2b0d461826ea Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 20 May 2026 16:53:54 +0200 Subject: [PATCH] fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/PLAN.md | 23 ++++++- ui/docs/renderer.md | 42 +++++++----- ui/frontend/src/map/render.ts | 86 ++++++++++++++++++------- ui/frontend/tests/fog-paint-ops.test.ts | 22 ++++--- 4 files changed, 120 insertions(+), 53 deletions(-) diff --git a/ui/PLAN.md b/ui/PLAN.md index f9b7a3e..c988b25 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3293,9 +3293,26 @@ Decisions: is removed so a released drag stops instantly and the viewport goes idle immediately. `RendererHandle.getRenderCount()` (mirrored on `__galaxyDebug` as `getMapRenderCount`) backs the e2e - assertions. If Safari pan is still heavy after this, stage 2 cuts - the overpaint itself (an inverse stencil mask of the circle union, - kept vector so the map stays crisp at any zoom). + assertions. The owner confirmed this removed the idle / whole-system + freeze, but panning a loaded map with the fog on stayed heavy in + 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 diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index 30b60d9..b4e12e6 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -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 diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index efe6b0d..c142bc8 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -394,14 +394,20 @@ export async function createRenderer(opts: RendererOptions): Promise 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 { @@ -932,13 +965,18 @@ export async function createRenderer(opts: RendererOptions): Promise