acdream/docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md
Erik 0f7b395be1 docs(render): Phase U — implementation plan (U.1-U.4 detailed, U.5/U.6 stubbed)
Ten bite-sized tasks to the first visual gate: U.1 delete two-pipe; U.2 GL-free
core (builder ordering+fixpoint, OtherPortalClip, ClipPlaneSet, ACDREAM_PROBE_VIS);
U.3 GPU gate (gl_ClipDistance in mesh_modern/terrain_modern + clip SSBO/UBO upload);
U.4 unified gated draw (EnvCellRenderer cell shells + WbDrawDispatcher All +
gated terrain; live-dynamic unclipped per retail) + per-instance slot assignment +
probe validation. U.5 outdoor-peering / U.6 dungeon-scale detailed after the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:17 +02:00

424 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase U — Unified Render Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Each task is a fresh subagent dispatch on **Opus 4.8**. Build + test green at every task; commit per task.
**Goal:** Replace the WorldBuilder-inherited two-pipe (inside/outside) render split with one retail-faithful `PView` portal-visibility pass — visible cells + per-cell screen-space clip region + an `OutsideView` for the outdoors — gated on the GPU by hardware clip planes (`gl_ClipDistance`). Seamless indoor/outdoor transitions by construction.
**Architecture:** Per frame: `CellVisibility.FindCameraCell` picks the root (cell → indoor, null → outdoor); `PortalVisibilityBuilder` runs a closest-first portal BFS to produce per-cell convex NDC clip regions + `OutsideView`; `ClipPlaneSet` turns each region's edges into clip-space planes; one draw pass gates three paths (`EnvCellRenderer.Render` for cell shells, `WbDrawDispatcher.Draw(All)` for statics/scenery/entities, `TerrainModernRenderer.Draw` for ground) by those planes. No `cameraInsideBuilding` branch, no `RenderInsideOut` stencil pass.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL 4.3+ bindless/MDI, `gl_ClipDistance`, GLSL `#version 430/460`. xUnit tests. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**Spec:** [`docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md`](../specs/2026-05-30-phase-u-unified-render-pipeline-design.md). Read it before starting; it has the retail anchors and the full component contracts.
**Build/test commands (PowerShell, from repo root):**
- Build: `dotnet build AcDream.slnx -c Debug`
- Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug`
- App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Debug`
**Staging:** Tasks 110 cover U.1U.4 (first visual gate after Task 10). U.5 (outdoor-peering root) and U.6 (dungeon-scale validation) are detailed *after* the U.4 visual gate — they depend on what the gate reveals and on the §6.3 building-portal data dependency. They are stubbed at the end of this plan.
**Diagnostic / probe conventions:** new runtime toggles go through a diagnostic-owner static class (Code Structure Rule 5), read once from env at startup, runtime-toggleable via DebugPanel. Strip temporary `[pv-dump]`-style probes before marking a task done; keep the durable `ACDREAM_PROBE_VIS`.
---
## File structure
**New files:**
- `src/AcDream.App/Rendering/ClipPlaneSet.cs` — pure NDC-edge → clip-space-plane extractor (+ 8-plane cap / scissor fallback).
- `src/AcDream.App/Rendering/RenderDiagnostics.cs``ACDREAM_PROBE_VIS` owner (visibility probe), DebugPanel-toggleable.
- `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs` — extractor unit tests.
**Modified files:**
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — rework: add `OrderedVisibleCells`, distance-priority queue, timestamp/update-count fixpoint, reciprocal `OtherPortalClip`. (Keep the file + class name to preserve the ~36 tests' references; evolve internals + output type.)
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — clip-plane SSBO (`binding=2`) + `gl_ClipDistance` writes keyed by per-instance cell slot.
- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` — uniform clip-plane block + `gl_ClipDistance`.
- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — accept a per-cell clip-slot binding + bind the clip SSBO at draw.
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — per-instance clip-slot in `InstanceData`/SSBO; bind clip SSBO; `glEnable(GL_CLIP_DISTANCE…)`.
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — upload `OutsideView` clip uniforms + enable clip distances.
- `src/AcDream.App/Rendering/GameWindow.cs` — DELETE two-pipe machinery (Task 1); wire the unified gated pass (Tasks 910).
- `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` — add ordering / fixpoint / `OtherPortalClip` cases.
**Deleted files (Task 1):**
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
- `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`, `…/portal_stencil.frag`
- `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`
**Keep untouched (audited real fixes — do NOT regress):** `EnvCellRenderer.cs` pool/GL-state logic (`9559726`, `9ee42d4`, `d5deeb3`, `0940d79`, `5dc4140`), `BuildingLoader`/`BuildingRegistry`/`Building` (`0fc6003`), `CellVisibility`, the clip-math trio (`PortalView.cs`, `ScreenPolygonClip.cs`, `PortalProjection.cs`), and the camera-collision work (`PhysicsCameraCollisionProbe`, `RetailChaseCamera`).
---
## Task 1 (U.1): Delete the two-pipe machinery
**Goal:** Remove all inside-out / two-pipe code so the unified pass is built clean. After this task the default game is byte-for-byte unchanged (any current indoor-wall degradation persists until Task 9 — that is expected, NOT a regression introduced here).
**Files:**
- Delete: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`, `Shaders/portal_stencil.vert`, `Shaders/portal_stencil.frag`, `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- [ ] **Step 1: Verify the keep-list fixes before touching anything.** Read each commit's diff so you know what must survive: `git show 9559726 9ee42d4 d5deeb3 0940d79 0fc6003 -- src/AcDream.App/Rendering/`. Confirm these touch `EnvCellRenderer` pool/GL-state, `BuildingLoader`, or `CellVisibility` — NOT the inside-out visibility/stencil path. Do not modify any of them.
- [ ] **Step 2: Delete the dead files.**
```
git rm src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
git rm src/AcDream.App/Rendering/Shaders/portal_stencil.vert src/AcDream.App/Rendering/Shaders/portal_stencil.frag
git rm tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs
```
(If any of the shader paths differ, locate them first with Glob `**/portal_stencil.*`.)
- [ ] **Step 3: Excise the GameWindow two-pipe code.** Remove, in `GameWindow.cs` (line numbers are approximate — locate by symbol):
- `RenderInsideOutAcdream` method (the `RenderInsideOut`-port, ~1100711319).
- `RenderOutsideInAcdream` method (~213325).
- The entire A8-perf instrumentation: `_a8Perf*` fields (~71347177), `MaybeFlushA8Perf`, `A8PerfStart/Stop/BeginGpuQuery/EndGpuQuery`, `EmitDrawOrderProbe`/`EmitEnvCellProbe`/`EmitStencilProbe`/`EmitBuildingsProbe`, and all their call sites (~1132111609 + the `a8PerfStart = …` / `A8Perf…(…)` lines scattered in the render method).
- The `cameraInsideBuilding` / `a8IndoorBranchEnabled` / `ACDREAM_A8_INDOOR_BRANCH` declarations + the whole `if (cameraInsideBuilding) { … } else { if (a8IndoorBranchEnabled) { … } else { <DEFAULT> } }` block (~73427348, 73567523, 76137715, 78257831). **Collapse it to the DEFAULT branch only** (the `else` at ~77047715): a single `_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds, animatedEntityIds: animatedIds);`. (Task 9 replaces this collapsed call with the gated unified sequence.)
- The depth-clear-if-inside workaround (~76137614).
- `_indoorStencilPipeline` field (~172), its construction (~19411944), and its `Dispose` (~11690).
- The orphaned `PortalVisibilityBuilder.Build` call inside `RenderInsideOutAcdream` (deleted with the method).
- [ ] **Step 4: Remove now-dead `EntitySet` partition values.** In `WbDrawDispatcher.cs`, the `IndoorPass`, `OutdoorScenery`, `BuildingShells`, `LiveDynamic` enum members are referenced only from the deleted two-pipe code. Remove them and any `switch`/branch arms in `WbDrawDispatcher` that handle them, leaving `EntitySet.All` as the sole member (or remove the `set:` parameter entirely if `All` is now the only path — prefer the smaller diff: keep the enum with just `All`). Verify with Grep that no references to the removed members remain.
- [ ] **Step 5: Build.** Run: `dotnet build AcDream.slnx -c Debug`. Expected: PASS, zero errors. Fix any dangling references (unused usings, leftover field refs) until green.
- [ ] **Step 6: Test.** Run the App + Core test suites. Expected: PASS (minus the deleted `IndoorCellStencilPipelineTests`). The ~36 clip-math tests (`PortalView`/`ScreenPolygonClip`/`PortalProjection`/`PortalVisibilityBuilder`) MUST still pass — Task 1 does not touch their subjects.
- [ ] **Step 7: Commit.**
```
git add -A
git commit -m "refactor(render): Phase U.1 — delete two-pipe inside-out machinery
Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (Task 9 replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2 (U.2a): `PortalVisibilityFrame` + distance-priority BFS + fixpoint termination
**Goal:** Rework the builder so it (a) returns an ordered visible-cell list, (b) traverses closest-first via a distance-priority queue (retail `InsCellTodoList` 433183), (c) terminates via a real grow-watermark fixpoint instead of the `MaxReprocessPerCell` hard cap (retail `master_timestamp` + `update_count`, `AddViewToPortals` 433446). Reuses `PortalView`/`ScreenPolygonClip`/`PortalProjection` unchanged.
**Files:**
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`
- Test: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
**Interface (evolve `PortalVisibilityFrame`):**
```csharp
public sealed class PortalVisibilityFrame
{
public CellView OutsideView { get; } // 0xFFFF exits, clipped
public Dictionary<uint, CellView> CellViews { get; } // per-cell clip region (== CellClipRegions)
public List<uint> OrderedVisibleCells { get; } // NEW: closest-first draw order
public Dictionary<uint, CellView> CrossBuildingViews { get; } // keep (U.5 outdoor-peering)
}
```
- [ ] **Step 1: Write failing tests for ordering + fixpoint.** Add to `PortalVisibilityBuilderTests.cs`:
```csharp
[Fact] // closest-first ordering
public void Build_OrdersVisibleCells_ClosestFirst()
{
// Three cells in a straight chain A->B->C, camera in A. Expect order [A,B,C].
var (cells, lookup) = SyntheticChain(/* see helpers below */);
var f = PortalVisibilityBuilder.Build(cells[0], cameraPos: A_center, viewProj, lookup);
Assert.Equal(new[]{ A_id, B_id, C_id }, f.OrderedVisibleCells);
}
[Fact] // cyclic graph terminates and bounds the visible set
public void Build_CyclicHub_TerminatesAndBounds()
{
// Hub cell with 4 rooms each portal-linked back to the hub (a cycle).
var (cells, lookup) = SyntheticCyclicHub();
var f = PortalVisibilityBuilder.Build(hub, hubCenter, viewProj, lookup);
Assert.True(f.OrderedVisibleCells.Count <= 5); // hub + 4 rooms, no blow-up
Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
}
```
Build the `SyntheticChain` / `SyntheticCyclicHub` helpers using `LoadedCell` with hand-set `WorldTransform = Identity`, `Portals`, `ClipPlanes`, `PortalPolygons` (reuse patterns already in the test file).
- [ ] **Step 2: Run tests — verify they fail.** `dotnet test …AcDream.App.Tests… --filter PortalVisibilityBuilder`. Expected: FAIL (`OrderedVisibleCells` missing / wrong order).
- [ ] **Step 3: Implement.** In `PortalVisibilityBuilder.Build`: replace the `Queue<LoadedCell>` with a distance-priority structure (insert cells keyed by distance from `cameraPos` to the cell's `WorldPosition`; dequeue nearest). Track per-cell a `viewVersion` (count of polygons accumulated, or a content hash); re-enqueue a neighbour only when its `CellView` genuinely grows (compare against the version recorded when last enqueued). Drop `MaxReprocessPerCell`. Append each dequeued cell id to `OrderedVisibleCells`. Keep the `OutsideView` and `CrossBuildingViews` accumulation logic.
- [ ] **Step 4: Run tests — verify pass.** Expected: PASS, including the pre-existing ~12 builder tests (no regression).
- [ ] **Step 5: Commit.**
```
git commit -am "feat(render): Phase U.2a — portal BFS ordering + fixpoint termination
PortalVisibilityFrame gains OrderedVisibleCells (closest-first). Replace the FIFO +
MaxReprocessPerCell cap with a distance-priority queue and a grow-watermark fixpoint
(retail InsCellTodoList 433183 / AddViewToPortals 433446) so cyclic dungeon graphs
converge without duplicate-cell blow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3 (U.2b): Reciprocal `OtherPortalClip`
**Goal:** When a portal leads to a loaded neighbour, also clip the portal opening against the neighbour's matching (reciprocal) portal polygon (retail `PView::OtherPortalClip` 433524) — prevents over-inclusion through skewed openings.
**Files:** Modify `PortalVisibilityBuilder.cs`; test in `PortalVisibilityBuilderTests.cs`.
- [ ] **Step 1: Failing test.** Construct two cells whose shared portal, viewed at an oblique angle, has a smaller reciprocal opening than the near-side projection. Assert the neighbour's resulting `CellView` area is bounded by the reciprocal (smaller) opening, not the near-side one.
```csharp
[Fact]
public void Build_AppliesReciprocalOtherPortalClip()
{
var (camCell, neighbour, lookup) = SyntheticReciprocalPair(obliqueAngleDeg: 60);
var f = PortalVisibilityBuilder.Build(camCell, camPos, viewProj, lookup);
var area = PolygonArea(f.CellViews[neighbourId]); // helper: signed-area sum of polys
Assert.True(area <= ReciprocalOpeningAreaNdc + Eps); // clipped to the narrower opening
}
```
- [ ] **Step 2: Run — fail.** Expected: FAIL (neighbour region too large).
- [ ] **Step 3: Implement** the reciprocal clip: at the TODO site already marked in `PortalVisibilityBuilder.cs` (the `// TODO(A8.F): neighbour-side OtherPortalClip (decomp:433524)` comment), project the neighbour's matching portal polygon (resolve via `portal.PolygonId` / the neighbour's `Portals` back-link) to NDC and intersect it into `clippedRegion` before unioning into the neighbour's `CellView`.
- [ ] **Step 4: Run — pass.** Expected: PASS + no regression.
- [ ] **Step 5: Commit** `feat(render): Phase U.2b — reciprocal OtherPortalClip (retail 433524)`.
---
## Task 4 (U.2c): `ClipPlaneSet` extractor
**Goal:** Turn a `CellView` (set of convex NDC polygons) into ≤8 clip-space planes for `gl_ClipDistance`, with collinear-edge merge + AABB-scissor fallback when over budget.
**Files:** Create `src/AcDream.App/Rendering/ClipPlaneSet.cs`; create `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs`.
**Interface:**
```csharp
public readonly struct ClipPlaneSet
{
public int Count { get; } // 0..8
public IReadOnlyList<Vector4> Planes { get; } // clip-space (nx, ny, 0, d)
public bool UseScissorFallback { get; }
public Vector4 ScissorNdcAabb { get; } // (minX, minY, maxX, maxY)
public static ClipPlaneSet From(CellView region);
public static ClipPlaneSet Empty { get; } // Count==0, nothing passes
}
```
Edge → plane: for the region's (single, after intersection) convex polygon wound CCW, each edge `(p→q)` has inward normal `n = normalize(perp(q-p))` with `perp((x,y)) = (-y, x)` for CCW; plane = `(n.x, n.y, 0, -dot(n, p))`, so `gl_ClipDistance = n.x*clip.x + n.y*clip.y + (-dot(n,p))*clip.w ≥ 0` inside. (A `CellView` with multiple disjoint polygons after intersection is rare for a single chain; take the first/largest polygon for the plane set and set `UseScissorFallback` with the union AABB if there are several — documented conservative behavior.)
- [ ] **Step 1: Failing tests.**
```csharp
[Fact]
public void From_AxisAlignedSquare_FourPlanes_PointInsideHasPositiveDistances()
{
var sq = new CellView(); sq.Add(new ViewPolygon(new[]{
new Vector2(-0.5f,-0.5f), new Vector2(0.5f,-0.5f), new Vector2(0.5f,0.5f), new Vector2(-0.5f,0.5f)});
var cps = ClipPlaneSet.From(sq);
Assert.Equal(4, cps.Count);
// clip-space point at NDC (0,0) with w=1 → inside → all distances >= 0
var clip = new Vector4(0,0,0,1);
foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, clip) >= 0);
// point at NDC (0.9,0) → outside the square → at least one distance < 0
var outClip = new Vector4(0.9f,0,0,1);
Assert.Contains(cps.Planes, p => Vector4.Dot(p, outClip) < 0);
}
[Fact]
public void From_NineEdgePolygon_FallsBackToScissor()
{
var poly = RegularNgonCellView(n: 9, radius: 0.6f);
var cps = ClipPlaneSet.From(poly);
Assert.True(cps.UseScissorFallback || cps.Count <= 8); // merge may reduce; if not, scissor
if (cps.UseScissorFallback) { Assert.Equal(0, cps.Count); /* AABB carries the gate */ }
}
[Fact]
public void From_EmptyRegion_IsEmpty()
{
Assert.Equal(0, ClipPlaneSet.From(new CellView()).Count);
}
```
- [ ] **Step 2: Run — fail** (`ClipPlaneSet` not defined).
- [ ] **Step 3: Implement** `ClipPlaneSet.From`: pick the region's principal convex polygon; merge edges whose direction differs by < ~0.5° (retail `copy_view` ~1px dedup); if > 8 edges remain, set `UseScissorFallback=true`, `Count=0`, and compute the NDC AABB; else emit the per-edge planes. Sign per the CCW formula above.
- [ ] **Step 4: Run — pass.**
- [ ] **Step 5: Commit** `feat(render): Phase U.2c — ClipPlaneSet (NDC edges → gl_ClipDistance planes)`.
---
## Task 5 (U.2d): `ACDREAM_PROBE_VIS` runtime probe
**Goal:** A durable per-frame visibility probe (the apparatus #103 lacked) so the builder is validated on live frames before any GL work.
**Files:** Create `src/AcDream.App/Rendering/RenderDiagnostics.cs`; wire one emit site (the place that will call `PortalVisibilityBuilder.Build` in Task 9 — for now, add a temporary call behind the probe so it can run pre-U.4, or defer the emit wiring to Task 9 and land only the owner here). Prefer: land the owner + a `static void EmitVis(...)` formatter here; call it from Task 9.
**Interface:**
```csharp
public static class RenderDiagnostics
{
public static bool ProbeVisibility { get; set; } // env ACDREAM_PROBE_VIS=1, DebugPanel-toggleable
public static void EmitVis(uint rootCellId, IReadOnlyList<uint> visibleCells,
CellView outsideView, int outsidePlaneCount,
IReadOnlyDictionary<uint,int> perCellPlaneCounts,
int scissorFallbacks);
}
```
`EmitVis` prints one `[vis]` line: `root=0x… cells=N ids=[…] outside(polys=…,planes=…) fallbacks=…`. Fire only on cell change (track last root id inside the owner).
- [ ] **Step 1:** Write a small unit test that `ProbeVisibility` defaults to the env value and `EmitVis` is a no-op when false (capture `Console.Out`).
- [ ] **Step 2:** Run — fail.
- [ ] **Step 3:** Implement the owner.
- [ ] **Step 4:** Run — pass. Build green.
- [ ] **Step 5:** Commit `feat(render): Phase U.2d — ACDREAM_PROBE_VIS visibility probe owner`.
---
## Task 6 (U.3a): `gl_ClipDistance` in `mesh_modern.vert`
**Goal:** Indoor cell shells, cell statics, scenery, building shells (everything through `mesh_modern.vert`) honor a per-instance clip-plane set.
**Files:** Modify `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`.
**Shader additions (std430):**
```glsl
// binding=2: per-clip-slot plane set. Slot index supplied per-instance (see Task 8).
struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
layout(std430, binding = 2) readonly buffer ClipBuf { CellClip clips[]; };
// per-instance clip slot: add to InstanceData (Task 8 fills it). Until then, default 0 = no-clip.
```
In `main()`, after `gl_Position` is computed:
```glsl
uint slot = inst.clipSlot; // 0 reserved = no-clip sentinel (count 0)
CellClip c = clips[slot];
for (uint i = 0u; i < c.count; ++i)
gl_ClipDistance[i] = dot(c.planes[i], gl_Position);
for (uint i = c.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0; // unused planes pass everything
```
- [ ] **Step 1:** Add the SSBO + the `clipSlot` field reference (coordinate the exact `InstanceData` layout with Task 8 — this task may land the shader with `slot=0` hardcoded if `InstanceData` isn't extended yet, then Task 8 wires the real slot). Verify the shader compiles: build + launch is not required, but ensure the shader-load path doesn't throw (run the app once headless if practical, or rely on Task 9's launch).
- [ ] **Step 2:** Build. Expected: PASS.
- [ ] **Step 3:** Commit `feat(render): Phase U.3a — gl_ClipDistance clip-plane SSBO in mesh_modern.vert`.
(No unit test — shader behavior is validated at the U.4 visual gate. Keep the change minimal + reviewable.)
---
## Task 7 (U.3b): `gl_ClipDistance` in `terrain_modern.vert`
**Goal:** Terrain honors the single `OutsideView` plane set (gated when indoors, ungated when `count==0`).
**Files:** Modify `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`.
```glsl
layout(std140, binding = N) uniform TerrainClip { int uClipCount; vec4 uClipPlanes[8]; };
// in main(), after gl_Position:
for (int i = 0; i < uClipCount; ++i) gl_ClipDistance[i] = dot(uClipPlanes[i], gl_Position);
for (int i = uClipCount; i < 8; ++i) gl_ClipDistance[i] = 1.0;
```
- [ ] **Step 1:** Add the uniform block + writes. Pick a free UBO binding; document it next to the existing terrain UBOs.
- [ ] **Step 2:** Build. Expected: PASS.
- [ ] **Step 3:** Commit `feat(render): Phase U.3b — gl_ClipDistance OutsideView gate in terrain_modern.vert`.
---
## Task 8 (U.3c): CPU clip-buffer upload + per-instance slot + enable clip distances
**Goal:** Build the per-frame `CellId → slot` map, upload the clip SSBO (`binding=2`) and terrain clip UBO, set per-instance `clipSlot`, and `glEnable(GL_CLIP_DISTANCE0…7)`.
**Files:** Modify `WbDrawDispatcher.cs`, `EnvCellRenderer.cs`, `TerrainModernRenderer.cs`. New small helper (in GameWindow or a `ClipBufferUploader`) to assemble + upload the SSBO.
**Design:**
- A per-frame `ClipFrame` (built in Task 9 from the `PortalVisibilityFrame`): `slot 0 = no-clip` (count 0); `slot 1 = OutsideView`; `slot 2..N = each visible cell's ClipPlaneSet`; a `Dictionary<uint,int> cellIdToSlot`.
- Upload the `CellClip[]` array to SSBO `binding=2` once per frame.
- `mesh_modern.vert` instance slot: extend `InstanceData` with `uint ClipSlot` (or pack into an existing unused field). `WbDrawDispatcher` sets each instance's slot from `cellIdToSlot[ParentCellId]` (cell statics), `1` (outdoor scenery / building shells when indoors → OutsideView; `0` when outdoors), or `0` (live dynamic = `ServerGuid != 0`, **unclipped, retail-faithful**). `EnvCellRenderer` sets each cell's instances to `cellIdToSlot[cellId]`.
- Terrain: upload `OutsideView` planes (or `count=0` when outdoors) to the UBO; `TerrainModernRenderer.Draw` binds it.
- `glEnable(GL_CLIP_DISTANCE0 + i)` for `i < 8` once at init (planes default to pass-all when unused, so always-enabled is fine and avoids per-draw state thrash); document the choice.
- [ ] **Step 1:** Extend `InstanceData` with `ClipSlot`; bump the SSBO stride if needed and update the std430 layout comment (mind the existing 64-byte mat4 stride note — add the slot in a way that preserves alignment, e.g. a parallel slot SSBO if extending the mat4 stride is risky). **Prefer a separate parallel `uint[] instanceClipSlot` SSBO at `binding=3`** indexed by `gl_InstanceID`/draw-id to avoid disturbing the proven mat4 instance buffer — decide and document.
- [ ] **Step 2:** Implement the `ClipFrame` assembly + upload helper + `glEnable` clip distances.
- [ ] **Step 3:** Build. Expected: PASS. (Behavioral validation at Task 9/U.4 gate.)
- [ ] **Step 4:** Commit `feat(render): Phase U.3c — clip SSBO/UBO upload + per-instance clip slot`.
---
## Task 9 (U.4a): Wire the unified gated draw pass
**Goal:** Replace the collapsed default draw (from Task 1) with the unified sequence: build visibility → assemble clip frame → upload → draw the three gated paths. This restores indoor cell shells (via `EnvCellRenderer.Render`) and stops the terrain bleed. **First visual gate.**
**Files:** Modify `GameWindow.cs` (the render method where Task 1 left the collapsed `Draw(All)`); `EnvCellRenderer.cs` / `TerrainModernRenderer.cs` draw signatures as needed to accept the clip frame.
**Sequence (in the render method, replacing the collapsed call):**
```csharp
var root = visibility?.CameraCell; // null ⇒ outdoor root
var pvFrame = root is not null
? PortalVisibilityBuilder.Build(root, camPos, envCellViewProj, id => _cellVisibility.TryGetCell(id, out var c) ? c : null)
: null; // outdoor root: no indoor BFS (U.5 adds peering)
var clipFrame = ClipFrame.Build(pvFrame); // slot 0 no-clip, 1 OutsideView, 2..N per cell
clipFrame.Upload(_gl); // SSBO binding=2 + terrain UBO
if (RenderDiagnostics.ProbeVisibility && pvFrame is not null)
RenderDiagnostics.EmitVis(root.CellId, pvFrame.OrderedVisibleCells, pvFrame.OutsideView, );
// sky (unchanged, before this)
_terrain.Draw(camera, frustum, neverCullLandblockId: playerLb); // gated via terrain UBO (OutsideView or count0)
if (pvFrame is not null)
_envCellRenderer.Render(WbRenderPass.Opaque, pvFrame.OrderedVisibleCells /* as filter */); // cell shells, gated by clip SSBO
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds, animatedEntityIds: animatedIds); // EntitySet.All, gated per-instance slot
if (pvFrame is not null)
_envCellRenderer.Render(WbRenderPass.Transparent, pvFrame.OrderedVisibleCells);
// particles / weather (unchanged, after this)
```
Notes:
- `EnvCellRenderer.Render` already supports a `filter` (the visible-cell set). Pass `OrderedVisibleCells`. The per-cell clip-slot binding from Task 8 supplies the planes; `EnvCellRenderer` binds the clip SSBO before its MDI call.
- When `root` is null (outdoor camera), terrain + entities draw ungated (clipFrame has only slot 0 + an empty OutsideView ⇒ `count=0`), and `EnvCellRenderer.Render` is skipped (cell shells are only drawn when the camera's portal BFS includes them; U.5 adds outdoor→building peering so you can see interiors from outside).
- This is the integration the CLAUDE.md warns about ("don't integrate via subagent without full context"). The subagent executing this task MUST read the surrounding render method first and preserve sky/particle/weather ordering, the `animatedIds` plumbing, and GL state (depth/cull) expectations.
- [ ] **Step 1:** Implement `ClipFrame` (assembly from `PortalVisibilityFrame`, holding the `CellClip[]`, `cellIdToSlot`, terrain plane set) + `Upload`.
- [ ] **Step 2:** Wire the sequence above into the render method. Ensure `EnvCellRenderer`/`WbDrawDispatcher`/`TerrainModernRenderer` bind the clip SSBO/UBO + that clip distances are enabled.
- [ ] **Step 3:** Build + full test suite. Expected: PASS.
- [ ] **Step 4:** Commit `feat(render): Phase U.4a — unified gated draw pass (indoor root)`.
---
## Task 10 (U.4b): Per-instance slot assignment correctness + probe validation
**Goal:** Make the slot assignment exactly right for every entity class, then validate on a live capture before declaring the visual gate ready.
**Files:** `WbDrawDispatcher.cs` (slot assignment in the entity walk), `EnvCellRenderer.cs` (cell-id slot), `GameWindow.cs`.
- [ ] **Step 1:** In `WbDrawDispatcher`'s per-instance walk, set `clipSlot`:
- `ServerGuid != 0` (live dynamic) → `0` (no-clip, retail draws live-dynamic unclipped).
- `ParentCellId != null``cellIdToSlot.GetValueOrDefault(ParentCellId.Value, /*not visible*/ -1)`. A `-1`/culled slot ⇒ skip the instance (cell not in the visible set).
- `ParentCellId == null` (outdoor scenery / building shells) → `root is not null ? 1 /*OutsideView*/ : 0 /*ungated outdoors*/`.
- [ ] **Step 2:** In `EnvCellRenderer`, set each cell's instance slot to `cellIdToSlot[cellId]`.
- [ ] **Step 3:** Self-validate with the probe (no user needed yet): launch with `ACDREAM_PROBE_VIS=1` (see CLAUDE.md launch block) into a Holtburg cottage cellar; read `launch.log` for `[vis]` lines. **Acceptance before the visual gate:** `OutsideView` is non-empty AND narrows (planes/AABB smaller) as you descend from the ground floor into the cellar; `cells` count is small + stable. If `OutsideView` is empty most frames (the #103 Finding 2 symptom), STOP and debug the builder (verify exit-portal `0xFFFF` polygons are populated in `PortalPolygons` at `BuildLoadedCell`; verify the portal-side test isn't over-culling) — do NOT proceed to the user gate with a known-empty OutsideView.
- [ ] **Step 4:** Build + test green. Strip any temporary `[pv-dump]` probes; keep `ACDREAM_PROBE_VIS`.
- [ ] **Step 5:** Commit `feat(render): Phase U.4b — per-instance clip-slot assignment + probe validation`.
- [ ] **Step 6: VISUAL GATE #1 (user).** Hand off to the user to walk Holtburg cottage → cellar → out the door: expect no flap, solid walls, no terrain bleed, seamless threshold; Holtburg Inn no through-floor bleed (#78); no regression to outdoor rendering. Do not start U.5 until the user confirms.
---
## Post-gate (detailed after Visual Gate #1)
### U.5 — Outdoor-peering root
Root the builder at a building's camera-facing exterior portal so interiors are visible through doors/windows from outside (retail `outdoor_pview` / `DrawBuilding` / `DrawPortal` / `ConstructView(CBldPortal)` — anchors in spec §3.4). **Open data dependency (spec §6.3):** surface render-side building-exterior-portal geometry (`BuildingExteriorPortal { polygon, destCellId, side }`) — we carry `BldPortalInfo` on the physics side (`BuildingPhysics` / `CheckBuildingTransit`); U.5 either reuses it or reads the same dat structure. Detail this task after the gate, once the indoor root is confirmed and the data path is scoped.
### U.6 — Dungeon-scale validation
Walk a dungeon via the Town Network portal; confirm `OrderedVisibleCells` stays ~415, no foreign-dungeon geometry, perf sane. Close/relate #95 + #102. Detail after the gate.
---
## Self-review (against spec)
- **Spec §2 (clip gate = clip planes + scissor):** Tasks 4, 6, 7, 8. ✓
- **Spec §2 (terrain separate, gated to OutsideView):** Tasks 7, 8, 9. ✓
- **Spec §5.1 (priority queue + fixpoint + OtherPortalClip):** Tasks 2, 3. ✓
- **Spec §5.2 (ClipPlaneSet, 8-cap, merge, scissor fallback):** Task 4. ✓
- **Spec §5.3 (mesh + terrain shaders, no MDI break via per-vertex clip):** Tasks 6, 7, 8. ✓
- **Spec §5.4 (unified orchestrator, EnvCellRenderer revived, keep audited fixes):** Tasks 1, 9, 10. ✓
- **Spec §5.5 (ACDREAM_PROBE_VIS built first):** Task 5 (owner), Task 10 (validation). ✓
- **Spec §7 (surgical delete + keep list):** Task 1. ✓
- **Spec §8 (unit tests + visual gate):** Tasks 24 (unit), Task 10 (probe + gate). ✓
- **Spec §9 staging (U.1U.4 first gate; U.5/U.6 post):** Task ordering + post-gate stubs. ✓
- **Type consistency:** `PortalVisibilityFrame.OrderedVisibleCells` / `CellViews` / `OutsideView` used identically in Tasks 2, 3, 9, 10; `ClipPlaneSet.From`/`Count`/`Planes`/`UseScissorFallback` consistent in Tasks 4, 8; `ClipFrame.Build`/`Upload` + `cellIdToSlot` consistent in Tasks 8, 9, 10. ✓
- **Live-dynamic unclipped (retail-faithful):** Task 8 + Task 10 Step 1. ✓