docs(render): Phase U — unified retail-faithful render pipeline design spec

One PView-faithful portal-visibility pass replacing the abandoned two-pipe
(inside/outside) split (#103). Settled in brainstorm 2026-05-30:
- Full Phase U in one spec (indoor BFS + outdoor building-peering + dungeon
  fixpoint + distance-priority ordering + reciprocal OtherPortalClip).
- Per-cell gate = hardware clip planes (gl_ClipDistance) + scissor pre-check
  (retail's two-level model); structurally immune to the #103 global-mask flood.
- Terrain stays its own path, gated to OutsideView (retail-faithful; NOT the
  handoff's "terrain as cells" sketch).
- Salvage = reuse the clip math (PortalView/ScreenPolygonClip/PortalProjection,
  ~36 tests), rework the builder (PortalViewBuilder), delete the stencil pipeline
  + GameWindow two-pipe orchestration. Audited keep-list preserves the real
  EnvCellRenderer / BuildingId / camera-collision fixes.

Staged U.1-U.6 with three visual gates. Retail anchors + acdream file:line
injection points catalogued in the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 15:38:09 +02:00
parent 48213c5b46
commit 8601137330

View file

@ -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.1U.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<uint> OrderedVisibleCells { get; } // closest-first
public IReadOnlyDictionary<uint, CellView> 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<uint, LoadedCell?> lookup);
// Outdoor-peering root (U.5): root at a building-exterior portal.
public static PortalViewFrame BuildFromBuildingPortal(
BuildingExteriorPortal portal, Vector3 cameraPos, Matrix4x4 viewProj,
Func<uint, LoadedCell?> 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<Vector3[]>`),
`BuildingId`.
### 5.2 `ClipPlaneSet` — edge → clip-plane extractor (GL-free)
```csharp
public readonly struct ClipPlaneSet
{
public int Count { get; } // 0..8
public ReadOnlySpan<Vector4> 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 ~73427715) 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:77047715 — 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:1100711319` |
| `RenderOutsideInAcdream` | `GameWindow.cs:213325` |
| A8-perf instrumentation (`_a8Perf*`, `MaybeFlushA8Perf`, probe emit methods) | `GameWindow.cs:71347177`, `1132111609` |
| `cameraInsideBuilding` / `a8IndoorBranchEnabled` / `ACDREAM_A8_INDOOR_BRANCH` branch | `GameWindow.cs:73427523`, `76137715`, `78257831` |
| `_indoorStencilPipeline` field / ctor / dispose | `GameWindow.cs:172`, `19411944`, `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` ~415, 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).