diff --git a/docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md b/docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md new file mode 100644 index 0000000..92b2cd6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md @@ -0,0 +1,416 @@ +# Phase U — Unified retail-faithful render pipeline (design spec) + +**Status:** design approved 2026-05-30 (brainstorm). Supersedes the abandoned A8/A8.F +two-pipe approach (issue #103). +**Milestone:** M1.5 — "Indoor world feels right." +**Decision context:** [`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](../../research/2026-05-30-unified-render-pipeline-decision-and-handoff.md) + +--- + +## 1. The decision (recap) + +acdream inherited a **two-pipe** render structure from WorldBuilder: a normal outdoor +draw plus a separate flat `RenderInsideOut` stencil pass toggled on `cameraInsideBuilding`. +That split is the root cause of every indoor/outdoor seam bug — the "flap", missing / +transparent walls, terrain bleeding into interiors — and it **cannot** be made seamless +(you cannot hand off two pipes cleanly at a doorway). The A8.F effort tried to graft +retail's recursive portal clip *on top of* the two-pipe stencil and failed its visual gate +(#103): the CPU clip math was unit-test-correct, but the integration (one global NDC mask +gating ALL outdoor geometry, with an else-branch that floods terrain when the mask is +empty) is inherently fragile. + +**Retail has no such split.** It renders through one portal-visibility traversal (`PView`): +from whatever cell the camera occupies, walk the portal graph, build a screen-space convex +clip region per opening, and draw every visible cell — indoor *and* outdoor — gated by that +region. The camera being indoors vs outdoors selects only *which cell is the root*; the draw +machinery is identical. Seamless **by construction**. + +**Phase U builds that.** Modern code, retail behavior. + +### Settled design decisions (brainstorm 2026-05-30) + +| # | Decision | Choice | +|---|----------|--------| +| Scope | First spec covers how much of `PView` | **Full Phase U in one spec** (indoor BFS + outdoor-camera building peering + dungeon-scale fixpoint termination + distance-priority ordering + reciprocal `OtherPortalClip`). Implemented in staged tasks U.1–U.6 with review/visual checkpoints. | +| Clip gate | How a modern GPU enforces "draw only inside this region" | **Hardware clip planes (`gl_ClipDistance`)** for the exact gate + **scissor AABB** as a cheap broad-phase pre-reject. Mirrors retail's two-level structure (it stores both a per-edge `Plane` and an `xmin/xmax/ymin/ymax` box). Structurally immune to the #103 global-mask-flood. Stencil-per-cell is the documented fallback if clip planes prove insufficient. | +| Terrain | How terrain + outdoor scenery fit | **Separate path, gated (retail-faithful).** `TerrainModernRenderer` stays as-is; when indoors, the whole terrain + outdoor-scenery batch is gated to the `OutsideView` clip planes (empty ⇒ no terrain). Terrain never enters the cell graph. Diverges intentionally from the handoff's "terrain as cells" sketch, which is *not* what retail does. | +| Salvage | Reuse vs fresh-port the A8.F pieces | **Reuse the clip math, rewrite the builder, delete the stencil + orchestration.** Keep `PortalView` / `ScreenPolygonClip` / `PortalProjection` (pure, GL-free, ~36 passing tests). Rework `PortalVisibilityBuilder` (wrong termination, wrong queue, no `OtherPortalClip`, #103 under-production). Delete `IndoorCellStencilPipeline`, `portal_stencil.*`, and the GameWindow two-pipe orchestration. | + +--- + +## 2. Goal & non-goals + +**Goal.** One render path. The camera's current cell is the root of a per-frame +portal-visibility traversal that yields *(ordered visible cells, per-cell screen-space clip +region, an `OutsideView` region for the outdoors)*. A single draw pass renders all visible +geometry (indoor cells, indoor statics, server entities, terrain, scenery) gated by clip +planes derived from those regions. No `cameraInsideBuilding` branch. No `RenderInsideOut` +stencil pass. No outdoor-vs-indoor toggle in the draw orchestration — only root selection. + +**Non-goals (this phase).** +- Re-porting the WB mesh/dat pipeline. `ObjectMeshManager` / `WbMeshAdapter` / + `WbDrawDispatcher` / `TerrainModernRenderer` / `LandblockMesh` / `DatCollection` / texture + decode all stay. Phase U is **visibility + draw orchestration**, not mesh extraction. +- The 2026-05-30 camera-collision + physics work (`PhysicsCameraCollisionProbe`, + `RetailChaseCamera`, the viewer/sight 30-step bypass). Kept verbatim. +- Per-instance highlight / selection blink, alpha-blend mode subpasses, and other + rendering-quality items not on the seam-fix critical path. + +--- + +## 3. The retail oracle + +The thing we port. All line numbers are in +`docs/research/named-retail/acclient_2013_pseudo_c.txt`; struct lines in +`docs/research/named-retail/acclient.h`. + +### 3.1 Top-level dispatch — `SmartBox::RenderNormalMode` (~92649) + +Branches on `viewer_cell->seen_outside`: +- **Indoors** (`seen_outside == 0`, camera in a pure-indoor cell): `RenderDevice::DrawInside(viewer_cell)` → `PView::DrawInside(indoor_pview, viewer_cell)`. The BFS is rooted at the camera's `CEnvCell`. +- **Outdoors**: `LScape::draw(lscape)` draws terrain + landblock geometry (including building shells). Building interiors are peered into *from within that path* (§3.4). + +`seen_outside` is set on a `CEnvCell` in `CEnvCell::find_cell_list` (~311044); it means "this +interior cell has at least one open portal to the exterior" (cellar windows, inn doorways). +It keeps the landscape loaded and sunlight injected for hybrid cells. + +### 3.2 The clip region is a screen-space convex polygon — NOT scissor/stencil + +Retail stores a cell's clip region as `view_type` (acclient.h:32338): a set of `view_poly` +(32465) entries, each a convex polygon of `view_vertex` (32483). **Each `view_vertex` carries +a `Plane`** for Sutherland-Hodgman edge clipping, plus the polygon's `xmin/xmax/ymin/ymax` box +for fast rejection. Retail clips **every polygon in software** against the active region +(`Render::set_view` 343750 installs it; the rasterizer's clipper reads it). We cannot do +per-polygon software clipping on a modern GPU — the per-edge `Plane` is exactly what maps to +`gl_ClipDistance` (§5.2). + +### 3.3 Indoor BFS — `PView::ConstructView(CEnvCell*, uint16_t)` (433750) + +1. Reset `outside_view.view_count = 0`; bump `master_timestamp` (the frame counter that drives + the fixpoint). +2. `PView::InitCell` (432896) — classify the root cell's front-facing portals. +3. `PView::InsCellTodoList` (433183) — seed a **distance-sorted priority queue** (`cell_todo_list`). +4. **Closest-first BFS:** dequeue nearest cell → append to `cell_draw_list` → + `PView::ClipPortals` (433572): for each `seen && !inflag` portal, project + intersect with + the current clip region (`PView::GetClip` 432344) and, when non-empty, propagate into the + neighbour's `portal_view_type` via `Render::copy_view` (344784). `PView::OtherPortalClip` + (433524) additionally clips against the neighbour's matching (reciprocal) portal. + `PView::AddViewToPortals` (433446) enqueues grown neighbours; `update_count` vs + `master_timestamp` is the convergence watermark. A portal with `other_cell_id == 0xFFFF` + accumulates into `PView::outside_view` (the landscape window). +5. `PView::DrawCells` (432709) consumes `cell_draw_list`: draws landscape through + `outside_view` (if non-empty), then each cell's geometry gated by its stored region + (`CEnvCell::setup_view` → `set_view`). + +### 3.4 Outdoor → building-interior peering + +`outdoor_pview` (a second `PView` instance, landscape-draw OFF) handles seeing *into* a +building from outside. `RenderDeviceD3D::DrawBuilding` primes +`outdoor_pview->outdoor_portal_list = building->portals` before drawing the shell. The shell's +BSP traversal hits portal nodes (`BSPPORTAL::portal_draw_portals_only` ~326881), which call +`RenderDeviceD3D::DrawPortal` (427852) → `PView::ConstructView(CBldPortal*, CPolygon*, …)` +(433827, the **recursive** overload). That tests viewer sidedness against the building portal, +clips the opening, resolves the destination `CEnvCell`, and **recurses into the CEnvCell BFS** +(§3.3) rooted at the destination cell. `CBldPortal` (acclient.h:32094) fields: `portal_side`, +`other_cell_id`, `other_portal_id`, `exact_match`, `num_stabs`, `stab_list`. + +**Seamlessness:** both roots draw the same geometry from opposite sides of the same portal — +outdoor root peers *in* through the door; indoor root peers *out* through it +(`0xFFFF → outside_view → landscape`). Same clip machinery, same convergence. The threshold +crossing only swaps which cell is the root. + +### 3.5 Terrain is its own path + +`RenderDeviceD3D::DrawBlock` (430027) / `DrawLandCell` is the landblock render path, +visibility-tested in `LScape::draw_check_blocks` against the portal-derived clip polygon +(`Render::PortalList = pview`). Terrain is **never** enrolled into the cell graph; it is gated +by the single `outside_view` region when indoors and drawn normally when outdoors. + +--- + +## 4. Architecture + +``` + camera pos ─► CellVisibility.FindCameraCell(pos) + │ cell → INDOOR root null → OUTDOOR root + ▼ + PortalViewBuilder.Build(root, camPos, viewProj, lookup) [GL-free, CPU] + • closest-first BFS through portals (distance-priority queue) + • per-cell convex NDC clip region (ScreenPolygonClip + OtherPortalClip) + • timestamp/update-count fixpoint termination + • 0xFFFF exit portals → OutsideView + ▼ + ClipPlaneSet.From(region) [GL-free, CPU] + • each NDC edge (a→b) → clip-space plane (nx,ny,0,d) + • merge near-collinear; cap at 8; AABB-scissor fallback when over budget + ▼ + upload: per-cell plane SSBO (binding=2, keyed by CellId); terrain uniform planes + ▼ + ONE draw pass (depth buffer on): + sky (ungated, no depth write) + terrain + scenery (gated → OutsideView planes; outdoor root = ungated) + indoor cells EnvCellRenderer.Render(visibleCells) (gated per-cell, SSBO) + entities WbDrawDispatcher.Draw (gated per-cell, SSBO) + particles / weather (ungated) + ▼ + ACDREAM_PROBE_VIS [vis] line on cell change +``` + +Two invariant properties: +- **No two-pipe seam.** Indoor vs outdoor changes only the BFS root, not the draw pass. +- **Per-cell gating at draw time, never a global mask.** Each draw carries its own region's + planes (looked up by `CellId`); an empty region = zero planes pass = nothing drawn. The + #103 flood-on-empty failure mode is structurally impossible. + +--- + +## 5. Components + +Each is independently testable; interfaces are the contract. + +### 5.1 `PortalViewBuilder` — GL-free visibility core (reworked) + +Reworks the existing `PortalVisibilityBuilder`. Reuses `PortalView` (`ViewPolygon`/`CellView`), +`ScreenPolygonClip`, `PortalProjection` unchanged. + +```csharp +public sealed class PortalViewFrame +{ + public IReadOnlyList OrderedVisibleCells { get; } // closest-first + public IReadOnlyDictionary CellClipRegions { get; } + public CellView OutsideView { get; } // 0xFFFF exits, clipped +} + +public static class PortalViewBuilder +{ + // Indoor root. + public static PortalViewFrame Build( + LoadedCell rootCell, Vector3 cameraPos, Matrix4x4 viewProj, + Func lookup); + + // Outdoor-peering root (U.5): root at a building-exterior portal. + public static PortalViewFrame BuildFromBuildingPortal( + BuildingExteriorPortal portal, Vector3 cameraPos, Matrix4x4 viewProj, + Func lookup); +} +``` + +Changes from the #103 builder: +- **Distance-priority queue** (retail `InsCellTodoList` 433183) replacing the plain FIFO → + correct front-to-back order + early-out. +- **Timestamp/update-count fixpoint** (retail `master_timestamp` + `update_count`, + `AddViewToPortals` 433446) replacing the `MaxReprocessPerCell = 4` hard cap → converges on + cyclic dungeon graphs (closes the #102 fast-follow, relates #95). A cell re-enqueues only + when its accumulated region genuinely grows, tracked by a real watermark (the current + near-no-op grow-guard is removed). +- **Reciprocal `OtherPortalClip`** (retail 433524) — also clip the portal against the + neighbour's matching portal polygon → prevents over-inclusion through skewed openings. +- Emits per-cell `CellView`s + `OutsideView` (the existing builder already does the + `OutsideView`; this generalises to all cells). + +`LoadedCell` already supplies everything needed (`CellVisibility.cs:24`): `CellId`, +`WorldTransform`, `InverseWorldTransform`, `LocalBoundsMin/Max`, `Portals` +(`CellPortalInfo{ OtherCellId, PolygonId, Flags }`; `OtherCellId == 0xFFFF` = exit), +`ClipPlanes` (`PortalClipPlane{ Normal, D, InsideSide }`), `PortalPolygons` (`List`), +`BuildingId`. + +### 5.2 `ClipPlaneSet` — edge → clip-plane extractor (GL-free) + +```csharp +public readonly struct ClipPlaneSet +{ + public int Count { get; } // 0..8 + public ReadOnlySpan Planes { get; } // clip-space (nx,ny,0,d) + public bool UseScissorFallback { get; } // true ⇒ region exceeded 8 edges; use AABB + public Vector4 ScissorAabb { get; } // NDC xmin,ymin,xmax,ymax + + public static ClipPlaneSet From(CellView region); +} +``` + +Each NDC edge from `(ax,ay)` to `(bx,by)` becomes the half-space "inside the edge". With +`NDC = clip.xy / clip.w` (and `w > 0` for visible geometry), the screen-space line +`nx·x + ny·y + d = 0` lifts to a clip-space plane via multiply-through-by-`w`: + +``` +gl_ClipDistance = nx·clip.x + ny·clip.y + d·clip.w // ≥ 0 ⇒ inside +``` + +so no un-projection is needed — the 2D edge directly yields a `vec4(nx, ny, 0, d)` applied to +`gl_Position`. Sign convention is fixed by the builder's CCW winding (`EnsureCcw`). The 8-plane +cap (GL guarantees `GL_MAX_CLIP_DISTANCES ≥ 8`) is handled by: (1) merge near-collinear edges +(retail `copy_view` dedups within ~1px); (2) if still > 8, set `UseScissorFallback` and gate by +the AABB box instead — conservative (slight corner over-draw, never hides geometry). + +### 5.3 GPU gate — shaders + buffers + +- **`mesh_modern.vert`** (indoor cells, indoor statics, server entities all share it): new SSBO + at `binding=2`: `struct CellClip { uint count; uint _pad0,_pad1,_pad2; vec4 planes[8]; } + buffer ClipBuf { CellClip cells[]; };` indexed by a per-cell slot derived from + `InstanceData.CellId`. The shader writes `gl_ClipDistance[i] = dot4(planes[i], gl_Position)` + for `i < count`. **Does not break MDI batching** — `gl_ClipDistance` is a per-vertex built-in, + not per-draw state, so the single `glMultiDrawElementsIndirect` per group is preserved. + Existing bindings (`binding=0` instance mat4, `binding=1` batch tuple) are untouched. +- **`terrain_modern.vert`**: a small uniform block `{ int count; vec4 planes[8]; }` for the + `OutsideView`; writes `gl_ClipDistance`. `count = 0` when outdoors (ungated). +- **CPU**: `glEnable(GL_CLIP_DISTANCE0 + i)` for `i < maxActiveCount` (and disable the rest); + upload the per-cell SSBO + terrain uniforms each frame after the builder runs. An "outdoor / + no-clip" sentinel slot (count 0) for entities outside any gated cell. + +A per-frame **CellId → SSBO slot** map is built alongside `CellClipRegions` (visible cells get +slots 0..N; everything else maps to the no-clip sentinel). + +### 5.4 Draw orchestrator — the GameWindow restructure + +Replaces the deleted two-pipe branch (GameWindow.cs ~7342–7715) with the §4 sequence. Wires in +the **currently-orphaned** `EnvCellRenderer.Render(visibleCells)` (today only the two-pipe +methods call it — outdoors you see shells + floating furniture but no cell walls). Keeps every +audited retail-faithful fix (§7). The default outdoor draw — today +`_wbDrawDispatcher.Draw(set: EntitySet.All)` at GameWindow.cs:7704–7715 — becomes the +outdoor-root case of the unified sequence. + +### 5.5 `ACDREAM_PROBE_VIS` — runtime visibility probe + +One `[vis]` line per frame on cell change: root cell id, `OrderedVisibleCells` count + ids, +`OutsideView` poly count + extracted plane count, per-cell plane counts, and any 8-plane-cap +scissor fallbacks. Owned by `PhysicsDiagnostics`-style diagnostic owner (per Code Structure +Rule 5), runtime-toggleable via the DebugPanel. **This is the apparatus #103 lacked at runtime** +— built in U.2, used to validate the builder against live frames before any GL work. + +--- + +## 6. Data flow, two roots, error handling + +### 6.1 Per-frame (see §4 diagram) + +Opaque correctness rides the depth buffer; the BFS front-to-back order is for early-Z and alpha +sorting, not a correctness crutch (retail needed draw-order because its software clip path had no +per-pixel depth test; we have one). Clip planes do exactly one job: keep each thing inside its +portal-framed NDC region so nothing bleeds across an opening. + +### 6.2 Indoor root (dominant, ships first — U.4) + +Camera in a `CEnvCell`. BFS from that cell; `0xFFFF` exits union into `OutsideView`; terrain +gated to `OutsideView`. Fixes the cellar/inn bleed. + +### 6.3 Outdoor-peering root (U.5) + +Camera outdoors. Terrain + shells draw normally. To peer into a building, root the builder at the +building's camera-facing exterior portal and recurse into its cells (retail `outdoor_pview` / +`DrawBuilding` / `DrawPortal` / `ConstructView(CBldPortal)`). **Dependency to confirm during +U.5:** the render-side `BuildingExteriorPortal` (polygon + destination cell id + side). We carry +`BldPortalInfo` on the physics side (`BuildingPhysics` / `CheckBuildingTransit`); U.5 surfaces it +to the render layer (or reads the same dat structure). If the data is not readily available, U.5 +is the place to add it — the spec flags it as the one open data dependency. + +### 6.4 Error handling / fallbacks + +- **8-plane cap:** merge near-collinear edges; else AABB scissor fallback (conservative). Logged. +- **Empty-region semantics (the #103 inversion):** empty `CellView` ⇒ cell not visible ⇒ not + drawn; empty `OutsideView` ⇒ no outdoors visible ⇒ terrain not drawn. The correct reading of + empty. +- **Dungeon termination:** the timestamp/update-count fixpoint (§5.1) guarantees convergence on + cyclic graphs. +- **#103 under-production — tracked, not inherited:** the builder does not ship until + `ACDREAM_PROBE_VIS` shows a non-empty, *narrowing* `OutsideView` at the cellar window on a live + capture. Suspects to verify in U.2: exit-portal (`0xFFFF`) polygons actually populated in + `PortalPolygons` at cell hydration; portal-side test not over-culling. +- **Degenerate portals:** `< 3` verts after near-plane clip ⇒ skip (already in `PortalProjection`). +- **Camera-cell miss:** existing 3-frame grace + brute-force fallback in `FindCameraCell`; worst + case briefly renders as outdoor root (no crash, no flood). + +--- + +## 7. Delete / keep audit (Task 1, surgical) + +**Delete (two-pipe machinery only):** + +| Target | Location | +|--------|----------| +| `IndoorCellStencilPipeline.cs` (792 LOC) + `portal_stencil.vert/.frag` | `src/AcDream.App/Rendering/` | +| `RenderInsideOutAcdream` | `GameWindow.cs:11007–11319` | +| `RenderOutsideInAcdream` | `GameWindow.cs:213–325` | +| A8-perf instrumentation (`_a8Perf*`, `MaybeFlushA8Perf`, probe emit methods) | `GameWindow.cs:7134–7177`, `11321–11609` | +| `cameraInsideBuilding` / `a8IndoorBranchEnabled` / `ACDREAM_A8_INDOOR_BRANCH` branch | `GameWindow.cs:7342–7523`, `7613–7715`, `7825–7831` | +| `_indoorStencilPipeline` field / ctor / dispose | `GameWindow.cs:172`, `1941–1944`, `11690` | +| `PortalVisibilityBuilder.Build` call site | `GameWindow.cs:11040` (replaced by the new orchestration) | + +**Rework, don't delete:** `PortalVisibilityBuilder.cs` → `PortalViewBuilder` (§5.1). + +**Keep (referenced beyond the two-pipe path):** `EnvCellRenderer.cs`, `Building.cs`, +`BuildingLoader.cs`, `BuildingRegistry.cs`, `CellVisibility.cs`, and the clip-math trio +`PortalView.cs` / `ScreenPolygonClip.cs` / `PortalProjection.cs` (+ their ~36 tests). + +**Keep these real bug-fixes (NOT visibility machinery) through the delete:** + +| Commit | Fix | +|--------|-----| +| `9559726` | `EnvCellRenderer` pool aliasing (list clear, `PostPreparePoolIndex` cursor, nested `isSetup`) | +| `0fc6003` | `BuildingId` stamping + `GetCellsForLandblock` (cell lookup by landblock) | +| `5dc4140` | `EnvCellRenderer` SSBO stride (64B mat4), FrontFace(CW)+per-batch CullMode via MDI, `EntitySet` partition | +| `d5deeb3`, `0940d79`, `9ee42d4` | `EnvCellRenderer` GL-state correctness (cull state, cache invalidation at `Render` entry) | +| `aae5300` + camera family | `PhysicsCameraCollisionProbe` / `RetailChaseCamera` spring-arm | + +Note: the `EntitySet` values introduced specifically for the two-pipe split (`IndoorPass`, +`OutdoorScenery`, `BuildingShells`) get re-evaluated against the unified draw sequence in U.4 — +the partition mechanism stays; some specific draw-call sites collapse. + +--- + +## 8. Testing strategy + +- **Unit (GL-free):** `PortalViewBuilder` on synthetic graphs — a cottage chain (visible set, + ordering, `OutsideView` narrowing) and a cyclic dungeon hub (fixpoint termination, no + duplicate-accumulation blowup, `visibleCells` bounded). `ClipPlaneSet` on hand-worked polygons + (edge→plane sign, 8-cap merge, scissor fallback). Reuse the existing ~36 clip-math tests. +- **The real gate is visual + the runtime probe** — unit tests on synthetic data did **not** + catch #103. We assert "`OutsideView` non-empty and narrowing at the cellar window" on a live + `ACDREAM_PROBE_VIS` capture before claiming the builder works, then: + - Cottage → cellar → out the door: no flap, walls solid, no terrain bleed, seamless threshold + from any camera angle/zoom. + - Holtburg Inn: no outdoor stabs/terrain through floor/walls (closes #78). + - Dungeon via Town Network portal: `visibleCells` ~4–15, no foreign-dungeon geometry + (closes/relates #95). + - Zero regression to the outdoor default (today's working game). + +--- + +## 9. Implementation staging + +The spec covers all of Phase U; the plan (writing-plans) details each task. Build + test green at +every stage. + +| Stage | Deliverable | Gate | +|-------|-------------|------| +| **U.1** | Delete two-pipe surgically (§7); keep all audited fixes. Default path visibly unchanged. | Build/test green; "deck cleared" | +| **U.2** | GL-free core: `PortalViewBuilder` (priority queue, fixpoint, `OtherPortalClip`) + `ClipPlaneSet` + `ACDREAM_PROBE_VIS`. Fully unit-tested. No GL. | Tests + probe validates live `OutsideView` | +| **U.3** | GPU gate: clip-plane SSBO + `gl_ClipDistance` in `mesh_modern.vert`; terrain uniform planes in `terrain_modern.vert`; upload + slot-map infra. | Build green; clip visibly works on a test draw | +| **U.4** | Indoor-root orchestration: wire `EnvCellRenderer.Render` + gated terrain into the unified pass. Cellar/inn bug fix. | **Visual gate #1** | +| **U.5** | Outdoor-peering root: `BuildingExteriorPortal` root; seamless threshold from outside. (Confirms the §6.3 data dependency.) | **Visual gate #2** | +| **U.6** | Dungeon-scale validation; close/relate #95 + #102; confirm `visibleCells` sane + perf. | **Visual gate #3** | + +--- + +## 10. Risks + +- **Outdoor-peering data dependency (§6.3).** Medium. Render-side building-exterior-portal + geometry may need surfacing from the physics-side `BldPortalInfo`. Isolated to U.5; the indoor + bug fix (U.4) does not depend on it. +- **#103 under-production recurrence.** Medium, mitigated. The reworked builder + the runtime + probe + the empty-region inversion attack the exact failure; the probe makes recurrence + falsifiable on live frames before GL work. +- **8-plane cap on deep portal chains.** Low. M1.5 scenes are short chains; merge + scissor + fallback covers the rest. Revisit only if a dungeon shows corner over-draw. +- **MDI batching vs per-cell gating.** Low — resolved by the per-vertex `gl_ClipDistance` + + CellId-indexed SSBO (no per-cell draws, batching preserved). + +--- + +## 11. Reference index + +- **Decision / handoff:** [`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](../../research/2026-05-30-unified-render-pipeline-decision-and-handoff.md) +- **#103 failure detail:** [`docs/research/2026-05-29-a8f-visual-gate-failure-handoff.md`](../../research/2026-05-29-a8f-visual-gate-failure-handoff.md) +- **Why WB can't express per-portal clipping:** project memory `indoor-portal-visibility-wb-vs-retail` +- **Retail decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` — `SmartBox::RenderNormalMode` 92649; `PView::ConstructView` 433750 (CEnvCell) / 433827 (CBldPortal); `ClipPortals` 433572; `GetClip` 432344; `DrawCells` 432709; `InitCell` 432896; `AddViewToPortals` 433446; `InsCellTodoList` 433183; `OtherPortalClip` 433524; `copy_view` 344784; `set_view` 343750; `DrawInside` 433793; `DrawPortal` 427852; `DrawBlock` 430027; `find_visible_child_cell` 311397; `GetVisible` 311378; `grab_visible_cells` 311878. Structs (acclient.h): `PView` 45934; `portal_view_type` 32346; `view_type` 32338; `view_poly` 32465; `view_vertex` 32483; `CCellPortal` 32300; `CBldPortal` 32094. +- **acdream anchors:** `CellVisibility.cs` (`LoadedCell` :24, `FindCameraCell` :301, `PointInCell` :367, `GetVisibleCells` :426, `ComputeVisibility` :272); `EnvCellRenderer.cs` (MDI draw :876/:1058, `RegisterCell` :246); `TerrainModernRenderer.cs` (`Draw` :191, MDI :263); shaders `mesh_modern.vert`, `terrain_modern.vert`; clip-math `PortalView.cs` / `ScreenPolygonClip.cs` / `PortalProjection.cs`. +- **Related issues:** #103 (this supersedes the A8.F arc), #78 (inn through-floor bleed — U.4), #95 (dungeon portal-graph blowup — U.6), #102 (builder fixpoint fast-follow — U.2/U.6).