# 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 1–10 cover U.1–U.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 9–10). - `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, ~11007–11319). - `RenderOutsideInAcdream` method (~213–325). - The entire A8-perf instrumentation: `_a8Perf*` fields (~7134–7177), `MaybeFlushA8Perf`, `A8PerfStart/Stop/BeginGpuQuery/EndGpuQuery`, `EmitDrawOrderProbe`/`EmitEnvCellProbe`/`EmitStencilProbe`/`EmitBuildingsProbe`, and all their call sites (~11321–11609 + 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 { } }` block (~7342–7348, 7356–7523, 7613–7715, 7825–7831). **Collapse it to the DEFAULT branch only** (the `else` at ~7704–7715): 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 (~7613–7614). - `_indoorStencilPipeline` field (~172), its construction (~1941–1944), 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) " ``` --- ## 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 CellViews { get; } // per-cell clip region (== CellClipRegions) public List OrderedVisibleCells { get; } // NEW: closest-first draw order public Dictionary 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` 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) " ``` --- ## 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 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 visibleCells, CellView outsideView, int outsidePlaneCount, IReadOnlyDictionary 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 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 ~4–15, 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 2–4 (unit), Task 10 (probe + gate). ✓ - **Spec §9 staging (U.1–U.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. ✓