fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Tests · UI / test (push) Successful in 1m57s
Tests · UI / test (pull_request) Successful in 1m56s

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:
Ilia Denisov
2026-05-20 16:53:54 +02:00
parent 44c18c3ef4
commit a08f4f55b0
4 changed files with 120 additions and 53 deletions
+12 -10
View File
@@ -1,15 +1,17 @@
// Phase 29 unit coverage for the visible-hyperspace overlay's
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
// next to its sole consumer (`RendererHandle.setVisibilityFog`)
// the renderer dispatches each op onto its own Pixi `Graphics`
// inside a `fogLayer` container that sits below every primitive
// copy. The natural rendering order paints fog underneath the
// world, replacing the earlier `cut()` implementation that
// produced disconnected arc segments.
// Phase 29 unit coverage for the visible-hyperspace overlay's paint
// ops. `fogPaintOps` lives in `src/map/render.ts` next to its sole
// consumer (`RendererHandle.setVisibilityFog`): the renderer draws
// the rectangle ops into a `fogLayer` container (below every
// primitive copy) and feeds the circle ops into an inverse stencil
// mask that cuts the visibility holes out of the fog. `fogPaintOps`
// only produces the ordered op list — rect(s) first, then one circle
// 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
// `fogLayer` has no transform — wraps for torus mode are baked
// into the ops directly.
// `fogLayer` and the mask have no transform — wraps for torus mode
// are baked into the ops directly.
import { describe, expect, test } from "vitest";