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>
416 lines
25 KiB
Markdown
416 lines
25 KiB
Markdown
# 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<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 ~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).
|