From 0f7b395be14b00194533b57ce2c2b9b25ee3d3ff Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 30 May 2026 15:48:17 +0200 Subject: [PATCH] =?UTF-8?q?docs(render):=20Phase=20U=20=E2=80=94=20impleme?= =?UTF-8?q?ntation=20plan=20(U.1-U.4=20detailed,=20U.5/U.6=20stubbed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-05-30-phase-u-unified-render-pipeline.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md diff --git a/docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md b/docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md new file mode 100644 index 0000000..11d6f8a --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md @@ -0,0 +1,424 @@ +# 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. ✓