Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/PRDs/open-world/PRD-537-far-hills-show-the-forest.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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 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 |

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.

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

- 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`

- [ ] 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
**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.
Loading