From 60f16c40641095605b30326991744eba27cf4e2e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 8 Oct 2026 01:42:28 -0700 Subject: [PATCH 1/4] docs(prds): file PRD-538, shadow maps redraw only what streamed in Streaming invalidations re-render Machinefall's whole 4096 shadow map 200-280 times per walk, which keeps PRD-478's walking GPU p95 above 8.3 ms early in the route. Redraw only the dirty pages instead. --- ...hadow-maps-redraw-only-what-streamed-in.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md diff --git a/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md new file mode 100644 index 0000000000..ec060fa14c --- /dev/null +++ b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md @@ -0,0 +1,72 @@ +# PRD-538 — Shadow maps redraw only what streamed in + +**Status:** NOT STARTED +**Priority:** P1 — Open: streaming invalidations re-render a whole shadow map several times a second, which keeps PRD-478's walking GPU p95 above 8.3 ms. +**Complexity:** 6 (MEDIUM) — one engine module family (`render/virtual-shadow*.ts`), a partial render path through three's shadow pass, and a pixel-equivalence proof +**Owner:** João +**Depends on:** none. PRD-478's AC-2 depends on this PRD. + +## Context + +Measured on Machinefall `map-walk`, desktop RTX 2080, live clock, PRD-478 branch `1fe8b3087` (2026-10-08): + +| Fact | Value | +| --- | --- | +| Shadow levels | one, ±250 m, 4096² (`shadowExtents: [250]`) | +| Level renders per walk | 200–280, of which 5 are window moves; the rest are streaming invalidations | +| Invalidation delay | 0.25 s: the default is `0.25 * extent / finestExtent`, and a single level is its own finest | +| GPU p95 per 1500-frame route window | 3–4 ms late in the route; 9–12 ms early, where streaming is heaviest | +| Shadow GPU p95 in those windows | 3.9–6.6 ms | + +A streamed cell changes a small part of the map, but `VirtualShadowNode` redraws the whole level for it. `invalidateRegion(region)` already says which part changed; today it only chooses *which levels* redraw. + +A longer delay is not the fix. It cuts renders but lets a newly streamed caster's shadow arrive late near the player, which PRD-478's AC-3 forbids. + +## Solution + +Redraw only the pages an invalidation touched. The level window is already a grid of `pagesPerAxis²` virtual pages (`virtual-shadow-pages.ts`), and window moves already snap to it. An invalidation with a region marks the pages it overlaps dirty. A redraw then renders only the dirty pages' rectangle: +- The shadow camera gets `setViewOffset` for that rectangle, so its depth values are the full map's. +- The target's viewport and scissor are the same rectangle. +- The rectangle's depth is reset first, because a WebGPU clear ignores the scissor. +- Casters are culled to the sub-frustum. + +A window move, an invalidation with no region, or more than half the pages dirty still redraws the whole level. + +Layer: mechanism only, `packages/core/src/render/`. Nothing a game sets changes, and no appearance parameter is involved. + +## Decisions + +- 2026-10-08, decided by the agent while the owner was away: this is a separate PRD rather than a fourth PRD-478 phase, because PRD-478 has three phases (R5). + +## Acceptance Criteria + +- [ ] AC-1: a partial redraw produces the same depth texels as a full redraw of the same scene. proof: a spec that renders both and compares the depth bytes inside and outside the dirty rectangle. +- [ ] AC-2: Machinefall `map-walk` route-window shadow GPU p95 falls against this PRD's base. proof: 3 interleaved live-clock pairs, `TN_FRAME_BUDGET` `gpuShadowP95`, 1500-frame windows. +- [ ] AC-3: no visual loss. proof: a blind A/B against the base on the 4 `map-walk` and 4 `map-views` poses, plus the `world-capture` pop series. + +## Execution Phases + +#### Phase 1: Dirty pages +**Status:** NOT STARTED +**Files:** `packages/core/src/render/virtual-shadow.ts`, `virtual-shadow-pages.ts`; `packages/core/__tests__/` + +- [ ] `invalidateRegion` marks the pages of each level the region overlaps; a redraw with no region, a window move, or more than half the pages dirty marks the whole level. proof: red-green spec on the dirty-page set. +- [ ] Stats report pages redrawn per render next to `rendersTotal`. proof: spec reading `stats`. + +#### Phase 2: Partial redraw +**Status:** NOT STARTED +**Files:** `packages/core/src/render/virtual-shadow.ts` + +- [ ] A redraw renders only the dirty rectangle through `setViewOffset`, a matching viewport and scissor, a depth reset of that rectangle, and caster culling to the sub-frustum. proof: AC-1's depth-equivalence spec. +- [ ] The native host draws the same rectangle. proof: a native conformance case or a `--target desktop` playtest of a streamed world (repository rule: a web-only path is unfinished). + +#### Phase 3: Measure on Machinefall +**Status:** NOT STARTED +**Files:** none in the engine + +- [ ] AC-2's timing pairs. proof: `TN_FRAME_BUDGET` windows, recorded here. +- [ ] AC-3's blind A/B and pop series. proof: sheets on the PR. + +## Blocked on + +- Final timing needs the desktop RTX 2080 at low load. On 2026-10-07/08 it sat at load 6–33 because of other sessions. From 7d2313e7aa61e16f803ee5514b51adbf80a7ee5b Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 8 Oct 2026 01:44:14 -0700 Subject: [PATCH 2/4] docs(prd-538): design notes from three's shadow pass --- .../PRD-538-shadow-maps-redraw-only-what-streamed-in.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md index ec060fa14c..017fec3cc9 100644 --- a/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md +++ b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md @@ -32,6 +32,12 @@ Redraw only the pages an invalidation touched. The level window is already a gri A window move, an invalidation with no region, or more than half the pages dirty still redraws the whole level. +Design notes from reading three r18x (2026-10-08): +- `Renderer` takes the viewport from `renderTarget.viewport`. It applies `renderTarget.scissor` only while the canvas target has `setScissorTest(true)`, so both must be set and restored around the partial render. +- A WebGPU clear wipes the whole attachment whatever the viewport. A partial redraw that clears would erase the rest of the map. +- An admission only adds casters, and an added caster can only bring a texel's depth closer, so an admission-only redraw needs no clear: render the new casters into the dirty rectangle with the existing depth test. An eviction removes casters, which needs the old depth cleared, so evictions keep the full redraw (or a later depth-reset pass). This splits Phase 2 into an admission path that is safe without a clear and an eviction path that stays full. +- `ShadowNode.renderShadow` calls `shadow.updateMatrices(light)`, which builds the receivers' `shadowMatrix` from the camera's current projection. A `setViewOffset` applied before it would make every receiver sample the sub-rectangle as if it were the whole map. Apply the view offset after `updateMatrices`, or rebuild `shadowMatrix` from the full projection after the render. + Layer: mechanism only, `packages/core/src/render/`. Nothing a game sets changes, and no appearance parameter is involved. ## Decisions @@ -57,7 +63,7 @@ Layer: mechanism only, `packages/core/src/render/`. Nothing a game sets changes, **Status:** NOT STARTED **Files:** `packages/core/src/render/virtual-shadow.ts` -- [ ] A redraw renders only the dirty rectangle through `setViewOffset`, a matching viewport and scissor, a depth reset of that rectangle, and caster culling to the sub-frustum. proof: AC-1's depth-equivalence spec. +- [ ] An admission-only redraw renders only the dirty rectangle (`setViewOffset` after `updateMatrices`, matching viewport and scissor, no clear, casters culled to the sub-frustum), and eviction or region-less invalidations keep the full redraw. proof: AC-1's depth-equivalence spec, with an admission case and an eviction case. - [ ] The native host draws the same rectangle. proof: a native conformance case or a `--target desktop` playtest of a streamed world (repository rule: a web-only path is unfinished). #### Phase 3: Measure on Machinefall From 27d15e1dd965ac5a0576dd4bf5e52c2a6415f925 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 8 Oct 2026 02:41:55 -0700 Subject: [PATCH 3/4] docs(prd-538): update level renders after PRD-478's invalidation cuts --- .../PRD-538-shadow-maps-redraw-only-what-streamed-in.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md index 017fec3cc9..dde86860a6 100644 --- a/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md +++ b/docs/PRDs/open-world/PRD-538-shadow-maps-redraw-only-what-streamed-in.md @@ -13,7 +13,7 @@ Measured on Machinefall `map-walk`, desktop RTX 2080, live clock, PRD-478 branch | Fact | Value | | --- | --- | | Shadow levels | one, ±250 m, 4096² (`shadowExtents: [250]`) | -| Level renders per walk | 200–280, of which 5 are window moves; the rest are streaming invalidations | +| Level renders per walk | 200–280 on `1fe8b3087`, 68–79 after PRD-478's `5a6d9d02f`/`ed75639ef` stopped terrain swaps and non-casting arrivals from invalidating; 13–20 are window moves | | Invalidation delay | 0.25 s: the default is `0.25 * extent / finestExtent`, and a single level is its own finest | | GPU p95 per 1500-frame route window | 3–4 ms late in the route; 9–12 ms early, where streaming is heaviest | | Shadow GPU p95 in those windows | 3.9–6.6 ms | From f6b87b60c8a2a885ba44474b90db04cac7af16d2 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 7 Oct 2026 22:19:53 -0700 Subject: [PATCH 4/4] docs(prds): file PRD-537, far hills show the forest Machinefall trees end at the streaming ring (about 256-360 m) on a 2 km map. The engine already keeps whole-map far impostor aggregates behind WorldCells impostors: true, which the game never sets; it stays off for the PR #375 shadow regression. The cell proxy GLBs hold props, not trees, so they are not the fix. --- .../PRD-537-far-hills-show-the-forest.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/PRDs/open-world/PRD-537-far-hills-show-the-forest.md diff --git a/docs/PRDs/open-world/PRD-537-far-hills-show-the-forest.md b/docs/PRDs/open-world/PRD-537-far-hills-show-the-forest.md new file mode 100644 index 0000000000..da513233f1 --- /dev/null +++ b/docs/PRDs/open-world/PRD-537-far-hills-show-the-forest.md @@ -0,0 +1,73 @@ +# PRD-537 — Far hills show the forest + +**Status:** NOT STARTED +**Priority:** P2 — Open: Machinefall's far hills show bare terrain; the engine's whole-map impostors are off and their shadow regression is unfixed. +**Complexity:** 5 (MEDIUM) — one engine file family (`world-cells.ts`, shadow levels) and one game opt-in; the mechanism exists, the work is turning it on without a shadow or pop regression +**Owner:** João +**Depends on:** PRD-478 (its walking budget, 8.3 ms CPU and GPU p95, is the cost ceiling here) + +## Context + +The owner saw it on 2026-10-07: in Machinefall, the hills in the background are bare. Measured on the built world (`world.25d1d6a1.json`, the `a5-current` site): + +| Fact | Value | +| --- | --- | +| Map | 2048 m square, 128 m cells, 254 cells with content | +| Placements | 121,421 in total, 67,585 of them trees (76 tree assets) | +| Placement data | 32 bytes each, 3.9 MB for the whole map (`placements.bin`) | +| Props and trees end at | the streaming ring, about 256–360 m from the camera (`MapWalk.ts` comment) | +| Terrain reaches | the whole map (`terrain.streamRadius: 8`, 17 × 17 tiles) | + +So past the ring the terrain draws and nothing stands on it. + +The engine already has the mechanism. `WorldCells` option `impostors: true` bakes an octahedral impostor for every alpha-cutout foliage asset at runtime and adds it as the asset's terminal two-triangle level. It also keeps **whole-map far aggregates**: one `InstancedMesh` per atlas key that holds the original placements and hands each one over to the near ring (`stats().impostor.far`, `#newFarMesh`, `#disableFar`). The option is off by default. Its own doc gives the reason: the impostor replaces the coarsest level the wide shadow levels draw, and its card left the road and forest floor almost unshaded (PR #375 visual regression). Machinefall's `World.ts` does not set it. + +Options considered: +1. **Turn on the existing whole-map impostors and fix the shadow regression** (this PRD). Placements are already small; the atlases are bounded by a 128 MiB budget; far trees cost one instanced draw per atlas key. +2. **Draw each cell's proxy GLB past the ring.** Rejected: the 86 `world.cell_*.proxy.*.glb` files hold merged props (grass, cattails, shrubs, a watchtower, cars), not trees, and weigh 485 MB in total (up to 70 MB each). +3. **Paint a canopy into the far terrain colour.** Kept as a fallback only: it is game-side and cheap, but it shows no tree tops on the skyline. + +Layer split: the residency, hand-over and shadow behaviour are engine mechanism (`packages/core/src/world-cells.ts`, `render/world-impostor*.ts`, `render/virtual-shadow.ts`). Whether a game turns impostors on, and what its trees look like, belongs to the game (Machinefall `apps/client/src/level/World.ts`). + +## Decisions + +- 2026-10-07, João: the brain writes this PRD; a cheap arm (Haiku 5.5, max effort) executes the mechanical steps under close review. Visual judging and the engine shadow fix stay with the brain. + +## Acceptance Criteria + +- [ ] AC-1: on the aerial and map-views poses, 3 fresh blind raters prefer the impostors-on capture to the impostors-off capture for the far hills. proof: capture sheets on the PR plus the raters' votes. +- [ ] AC-2: on the four `map-walk` poses (camp, first stream, highway, bridge), near shadows with impostors on match impostors off in a blind triptych judge. proof: triptych sheets plus the judge's verdict. +- [ ] AC-3: the `world-capture` pop series with impostors on shows no one-arm pop that impostors off does not show. proof: pop series judged blind. + +## Execution Phases + +#### Phase 1: Measure the existing far path on Machinefall +**Status:** NOT STARTED +**Files:** Machinefall `apps/client/src/level/World.ts`; no engine file + +- [ ] Machinefall reads `?tnImpostors=1` and passes `impostors: true` to `WorldCells.load`; without the flag nothing changes. proof: `map-walk` playtest passes with and without the flag; a diff of `World.ts` only. +- [ ] With the flag, `stats().impostor.far` reports the far aggregates on `map-walk`: aggregate count, far instances, near-owned, atlas bytes against the 128 MiB budget. proof: the `world` entity's debug fields in the playtest report, both numbers written here. +- [ ] Captures with the flag on and off at the four `map-walk` poses and two aerial poses show trees on the far hills only with the flag on. proof: labelled sheets on the PR and one blind judge. + +#### Phase 2: Shadows keep their casters when impostors are on +**Status:** NOT STARTED +**Files:** `packages/core/src/world-cells.ts`, `packages/core/src/render/virtual-shadow.ts`; `packages/core/__tests__/` + +- [ ] With impostors on, a shadow level's caster set inside its own window equals the set with impostors off: the impostor terminal never replaces a near caster. proof: red-green spec beside `world-shadow-gpu-keys.spec.ts`, both arms on the committed `world-v1` package. +- [ ] Far impostors cast only where no finer caster covers the same placement. proof: red-green spec counting each placement's casters per level. + +#### Phase 3: Hand-over without pop, then on in the game +**Status:** NOT STARTED +**Files:** `packages/core/src/world-cells.ts` (only if the pop series finds a defect); Machinefall `World.ts` + +- [ ] At the ring boundary a far impostor hands over to the near mesh in the same frame, with no frame that draws neither. proof: red-green spec on `#disableFar` over a walk that crosses the ring. +- [ ] Machinefall sets `impostors: true` by default and removes the flag. proof: Machinefall commit plus a passing `map-walk` playtest. + +## Blocked on + +- Walking CPU and GPU p95 with impostors on, against PRD-478's 8.3 ms: needs the RTX 2080 on a quiet desktop (the desktop load was 35–110 all day on 2026-10-07). +- The owner's side-by-side review of the far hills. + +## Notes + +- Whether `impostors` should default to true in the engine is a separate decision, once Phase 2 removes the reason it is off. The repository rule "conventions ship on by default" argues for it.