acdream/docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md
Erik 8601137330 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>
2026-05-30 15:38:09 +02:00

416 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Phase U — Unified 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).