acdream/docs/research/2026-06-02-render-reference-crosscheck.md
Erik 21bf97ed35 docs(render): REOPEN the render half — full retail-faithful redesign dossier (handoff + huge plan + 3 research docs)
The Phase W indoor seal did NOT land. The 2026-06-02 visual gate proved the interior render is fundamentally broken (#78: transparent walls, outdoor terrain + scenery entities bleeding in, grey floors, no outside-looking-in). Stage 4 (sky-through-door clip) was real but a top layer on a base that never sealed.

DECISIVE EVIDENCE (committed in the handoff): the PVS computes correctly AND the cell shells render correctly (opaque, textured, complete — the [shell] probe shows zero NOSNAP / zero missing-texture). The failure is the SEAL + three inconsistent gates — concretely the WbDrawDispatcher.cs:1756 ParentCellId==null -> return true bypass draws outdoor scenery indoors, and the indoor path draws the outdoor world then gates it instead of running ONLY DrawInside. Retail, when inside, runs ONE PView flood: visibility IS the cull; the landscape enters only through clipped exit portals + a conditional depth-only clear.

Dossier (per the user's mandate: NO shortcuts/bandaids, port from retail, redesign the whole pipeline if needed, brainstorm first):
- Master handoff (root cause + retail target + reusable-vs-redesign + apparatus + do-not-repeat + copy-paste pickup prompt).
- Huge staged redesign plan R0(brainstorm)->R1(one visibility authority, kill the bleed)->R2(indoor=DrawInside-only)->R3(the seal, DrawCells port)->R4(per-cell object/particle clip)->R5(outside-looking-in)->R6(dungeons)->R7(polish/conformance). Each ends at a user visual gate.
- 3 research docs: full retail render pipeline reference (705 lines, decomp-verified), acdream pipeline inventory + failure map, reference cross-check (WB two-pipe is the wrong model).

#78 promoted to the redesign. The 5 remaining Core test failures are pre-existing physics/collision bugs, none render-related.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:28:01 +02:00

449 lines
29 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.

# Indoor Cell / Portal Visibility Render Reference Cross-Check
**Date:** 2026-06-02
**Purpose:** Inform acdream's Phase U unified render pipeline redesign by documenting what
each reference client does for indoor cell rendering, portal visibility, the interior seal,
outdoor-scenery gating, and object/particle clipping — with explicit ADOPT / AVOID verdicts.
---
## 1. WorldBuilder — Indoor Render Approach (RenderInsideOut Two-Pipe Stencil)
### What WB actually does
**Source files:**
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 8801008
#### The branch decision
`GameScene.cs:881` sets `isInside = currentEnvCellId != 0` (the camera is in ANY EnvCell).
Two completely different code paths branch on that boolean:
- **Inside (`RenderInsideOut`)** — `VisibilityManager.RenderInsideOut` (lines 73239)
- **Outside (`RenderOutsideIn` or fallback)** — `VisibilityManager.RenderOutsideIn` (lines 241358)
or `RenderEnvCellsFallback` when `EnableCameraCollision` is off
This is the split acdream inherited from Phase N.4. **It is the root cause of every
doorway seam bug.** The two paths are fundamentally incompatible at a threshold crossing —
the hand-off point is where the flap, transparent walls, and terrain bleed all happen.
#### RenderInsideOut walkthrough (VisibilityManager.cs:73239)
1. **Stencil Bit 1 — doorway mask:** All portal polygons of the *camera's building* are rasterized
with `StencilOp.Replace` → Bit 1 = 1. This marks the screen pixels that correspond to doorways.
Done with `DepthFunc(Always)` so portals draw regardless of what's in front of them.
2. **Punch depth at doorways:** The same portal geometry is drawn again with `uWriteFarDepth=1`
(the stencil shader writes `gl_FragDepth = 1.0`). This clears depth at the doorway pixels
so the outdoor terrain can bleed through them.
3. **Render indoor cells ALWAYS** (no stencil guard). The camera building's full EnvCell set
is drawn unconditionally (`_currentEnvCellIds` = union of all cells in the camera building).
No per-portal clip. All cells in the building render even if behind the player.
4. **Gate outdoor geometry (terrain/scenery/static) through Bit 1.** Stencil func `Equal(1, 0x01)`
terrain, scenery, and static objects only draw where the portal polygons were rasterized.
This is the "seal" against outdoor bleed: if Bit 1 wasn't set, the pixel never gets terrain.
5. **Other buildings' cells** (Step 5, lines 157229): For each other building visible through
our doorways, WB does a further two-step mask — Bit 2 marks the intersection of our doorway
AND the other building's portals (stencil == 3 meaning both bits set), then draws that
building's cells only where both portals are open. Uses occlusion queries to skip buildings
that were fully occluded last frame.
6. **RenderOutsideIn** (outside path, lines 241358): mirrors Step 12 but camera is outside
looking in. Portal polygons mark Bit 1; depth is cleared at those pixels; EnvCells render
through the mask. Terrain/scenery/statics draw normally (no stencil guard).
#### What `GetVisibleBuildingPortals` provides
`PortalRenderManager.GetVisibleBuildingPortals` returns a `BuildingPortalGPU` per **building**
(not per cell or per portal). The `BuildingPortalGPU` is a triangle-fan tessellation of ALL
portal polygons for the building concatenated into a single VAO/VBO. This is the flat union —
there is no per-portal polygon tracking. One stencil pass per building.
`EnvCellRenderManager.GenerateForLandblockAsync` discovers cells recursively from building
portals (`portal.OtherCellId != 0xFFFF` — exit portals are skipped). The `seenOutsideCells`
set tracks cells with `EnvCellFlags.SeenOutside` but WB only stores this for diagnostic use;
it does NOT gate the cell draw off `SeenOutside`.
#### How WB decides cell visibility for the filter
`VisibilityManager.PrepareVisibility` (lines 4771): when `isInside`, adds ALL cells of every
building the camera is in (`_buildingsWithCurrentCell`). No per-portal traversal. No
per-portal clip. No `VisibleCells` stab-list from the dat. The full cell set of the building
is the filter.
When `isInside=false`, `GetVisibleBuildingPortals` returns frustum-visible building groups;
ALL their cells are added to `visibleEnvCells`. Again, no per-portal traversal.
**There is no WB equivalent of retail's per-cell `VisibleCells` (the PVS stab-list). WB
never reads `EnvCell.VisibleCells`.** WB's visibility is building-level, not cell-level.
#### Why the WB two-pipe diverges from retail's recursive PView
Retail `PView::ConstructView` (decomp ~433750) and `ClipPortals` (~433572):
| Property | WB RenderInsideOut | Retail PView |
|---|---|---|
| **Visibility unit** | Building (all cells in a building) | Per-cell portal traversal |
| **Clip granularity** | One stencil mask per building | Per-portal screen-space clip polygon |
| **Camera branching** | Hard `isInside` branch switching two completely different code paths | No branch — "which cell is the camera in" changes only the BFS root, not the algorithm |
| **Outdoor geometry gate** | Stencil Bit 1 derived from the portal polygon raster at the wall | OutsideView clip polygon accumulated by clipping through exit portals in the BFS |
| **Per-cell PVS** | Not used | `CEnvCell.stab_list` + `seen_outside` read per cell; portal side test per edge |
| **Scenery gating** | Outdoor scenery draws only where Bit 1 is set (all portals of the camera building) | Outdoor entities/scenery assigned OutsideView clip slot; cull when OutsideView empty |
| **Terrain gate** | Depth punched at portal pixels; terrain only draws stencil==1 | TerrainClipMode: Skip when no exit portal visible, Planes/Scissor when one is |
| **Other-building cells** | 2-bit stencil composed gate with occlusion query fallback | Same recursive BFS — other buildings' cells are cells in the PVS; no separate pipe |
| **Seam at doorway** | **Inherent** — the two pipes switch at `currentEnvCellId != 0`; the frame of the switch always tears | **None** — outdoor/indoor are the same draw loop with different clip regions |
#### Why acdream abandoned WB's two-pipe (2026-05-30)
From `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`:
> "The A8.F effort tried to graft retail's recursive clip *on top of* WB's two-pipe stencil
> (a CPU-built NDC mask bridging the two pipes). That hybrid is inherently fragile and failed
> its visual gate (issue #103). You cannot make two pipes hand off seamlessly at a doorway;
> retail avoids the entire bug class by never splitting."
The specific failure modes WB's architecture cannot fix without replacing the architecture:
1. **The flap** — The `isInside` / `currentEnvCellId != 0` gate flips on the same frame the camera
crosses the doorway. On the flip frame WB switches from RenderInsideOut to RenderOutsideIn (or
vice versa). The stencil state from the prior frame is cleared; the new portal polygon raster
hasn't loaded. There is one frame where the indoor draw is missing OR the outdoor draw bleeds
through. This is inherent to the architecture.
2. **Terrain bleed indoors** — WB's stencil Bit 1 mask is derived from the camera-building portal
polygons rasterized with `DepthFunc(Always)` BEFORE terrain. If the portal polygon is degenerate,
or the stencil clear races the portal raster, terrain bleeds through. The mask is not ground in
the per-cell PVS (`VisibleCells` / `SeenOutside`) so it cannot make a definitive "no terrain here
EVER" decision the way retail's `seen_outside=false` does for sealed dungeons.
3. **Transparent walls** — In RenderOutsideIn, WB clears depth with `uWriteFarDepth=1` at the portal
stencil, then renders EnvCells into the cleared region. The wall geometry adjacent to the portal
also had its depth cleared (the portal polygon doesn't exactly hug the wall opening), so a wall
polygon's depth test can fail against the cleared far-plane, making it appear transparent.
4. **Outdoor scenery entities indoors** — WB's `RenderInsideOut` gates terrain/scenery/static-objects
through Bit 1 (only where the portal polygon rasters hit). But if the entity's ParentCellId is
not tracked or is 0 (outdoor scenery has no indoor cell parent), it routes to the stencil
unguarded path and draws everywhere.
5. **Cannot be fixed without rebuilding** — The stencil approach is a GPU-side approximation of
what retail does on the CPU (per-portal clip-polygon BFS). Every "fix" to the stencil approach
adds more GPU state to paper over a case where the stencil and the intended visibility diverge.
The correct fix replaces the stencil entirely with retail's CPU-driven visibility.
**VERDICT: DO NOT REINTRODUCE the WB RenderInsideOut/RenderOutsideIn two-pipe stencil or any
approximation of it. The architecture is the bug.**
---
## 2. ACViewer — Cell/Portal Rendering and Sealing Approach
**Source files:**
- `references/ACViewer/ACViewer/Render/R_Landblock.cs`
- `references/ACViewer/ACViewer/Render/R_EnvCell.cs`
- `references/ACViewer/ACViewer/Render/Buffer.cs`
- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs`
### What ACViewer does
ACViewer is a **dat viewer and map editor** (MonoGame/DirectX), not a game client. Its rendering
is a brute-force draw with no runtime visibility or sealing:
**`R_Landblock.cs:83101`** `BuildEnvCells()`: For each cell id from 0x100 to
`0x100 + NumCells - 1` in the landblock, creates an `R_EnvCell` and adds it to the list.
All cells for the landblock are built, with no portal traversal.
**`Buffer.Draw()` / `Buffer.DrawWithZSlicing()`**: Draws all batches in `RB_EnvCell`,
`RB_StaticObjs`, `RB_Buildings`, `RB_Scenery` unconditionally (gated only by the Z-slicing
filter for multi-floor dungeon inspection). No portal culling. No stencil. No clip.
**`R_EnvCell.Draw()`** (`R_EnvCell.cs:7087`): Calls `DrawEnv()` (sets xWorld + draws the
environment cell struct mesh) + `DrawStaticObjs()` (draws each stab). No filter.
**There is no portal-based visibility in ACViewer at all.** ACViewer draws ALL cells and ALL
objects in every loaded landblock every frame. Culling is done only by the MonoGame frustum
culling on the DirectX state (backface culling ON in dungeon mode per `Buffer.cs:166`).
### ACViewer's EnvCell data model — what it DOES read
**`EnvCell.cs`** (Physics/Common, the ACViewer version used by `R_EnvCell`):
- `VisibleCellIDs` — list of low-byte cell IDs from the dat (`stab_list` / `numStabs`). This
is the DAT-baked PVS for this cell. ACViewer reads it in the constructor (`VisibleCellIDs =
envCell.VisibleCells`) and builds `VisibleCells` dict via `build_visible_cells()`.
- `SeenOutside``envCell.SeenOutside` flag. ACViewer reads it.
- `Portals` — the `CellPortal[]` list. ACViewer reads it.
- `find_visible_child_cell(Vector3 origin, bool searchCells)` (lines 206231): Checks if `origin`
is in this cell's AABB; if not, searches `VisibleCells.Values` (or falls back to `Portals`) for
a cell containing `origin`. This is the **retail `CEnvCell::find_visible_child_cell`** ported
to ACViewer's physics tree — it is the cell-membership resolver for moving objects in physics.
**Critically, ACViewer reads and maintains the PVS / portal data but uses it ONLY for physics
collision, not for rendering.** The render path is entirely brute-force.
### Does ACViewer seal interiors?
**No.** ACViewer does not try to occlude the outdoor world when drawing from inside a building.
It draws everything: terrain + scenery + building geometry + dungeon cells simultaneously. It
relies on correct depth testing to show the right surfaces. This works for a static viewer (you
can rotate to any position and inspect geometry) but would be completely broken for a game client
(outdoor terrain bleeds through dungeon floors when the camera is inside).
**ACViewer is GPL-licensed (read for understanding only; do not copy code).**
### What is reusable from ACViewer
- **The `EnvCell` data model** (especially `find_visible_child_cell`, `build_visible_cells`,
`VisibleCellIDs`, `SeenOutside`): these are faithful ports of the retail data structures.
acdream already has its own equivalent (`LoadedCell.VisibleCells`, `LoadedCell.SeenOutside`
in `CellVisibility.cs`). The ACViewer source confirms the field interpretations.
- **`CellPortal` struct** (`PolygonId`, `OtherCellId`, `OtherPortalId`, `PortalSide`): confirms
the exact field layout. acdream's `CellPortalInfo` record matches.
- **Algorithm understanding**: ACViewer's `find_visible_child_cell` confirms the retail pattern —
first check `point_in_cell(origin)` (self), then search `VisibleCells` by AABB, then fallback
to portal-linked neighbours. This is the retail `CEnvCell::find_visible_child_cell` at
`acclient_2013_pseudo_c.txt:311397`.
---
## 3. ACE — Cell/Portal/Visibility Data Model
**Source files:**
- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs`
- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/BuildInfo.cs`
- `references/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs`
ACE is the **server** — it defines the canonical dat file format that both the retail client and
acdream read. The data model here is authoritative.
### EnvCell fields (ACE DatLoader, `EnvCell.cs`)
| Field | Type | Purpose |
|---|---|---|
| `Flags` | `EnvCellFlags` | Bitflags: `SeenOutside (0x1)`, `HasStaticObjs (0x2)`, `HasRestrictionObj (0x8)` |
| `Surfaces` | `List<uint>` | Surface IDs (`0x08000000 | shortId`) — the textures for this cell |
| `EnvironmentId` | `uint` | Environment dat id (`0x0D000000 | shortId`) — the prefab geometry block |
| `CellStructure` | `ushort` | Which sub-structure within the environment (one environment can have many cell shapes) |
| `Position` | `Frame` | World-space placement of this cell (position + rotation) |
| `CellPortals` | `List<CellPortal>` | Connectivity list: `OtherCellId` (0xFFFF = exit portal), `OtherPortalId`, `PolygonId`, `ExactMatch`, `PortalSide` |
| `VisibleCells` | `List<ushort>` | PVS stab-list: low-byte cell IDs of cells potentially visible from this one (precomputed by AC's content tools) |
| `StaticObjects` | `List<Stab>` | Static geometry placed within this cell (furniture, decorations) |
| `RestrictionObj` | `uint` | GUID of the object controlling access (housing barriers) |
| `SeenOutside` | `bool` | Derived from `Flags.HasFlag(SeenOutside)` — this cell has line of sight to the exterior |
**Note from the ACE comment at line 46:** `numStabs` (the `VisibleCells` count) — "I believe this
is what cells can be seen from this one. So the engine knows what else it needs to load/draw."
This confirms the stab-list is the visibility PVS the renderer should use as a filter.
### The exit portal sentinel: `CellPortal.OtherCellId == 0xFFFF`
`CellPortal.cs:10,19`: `OtherCellId` is a `ushort`. Value `0xFFFF` (65535) means "exit to
outdoor world" — the portal connects this cell to the exterior. This is the seal-breaking
portal. A cell with an exit portal in its `CellPortals` list has line of sight to the
outdoors, which is why retail's `SeenOutside` flag is TRUE for such cells (and recursively
for any cell that can reach an exit portal through the connectivity graph).
When `OtherCellId != 0xFFFF`, the portal connects to another EnvCell with low-byte id
`OtherCellId` in the same landblock. The full id is `(landblockId << 16) | OtherCellId`.
### LandblockInfo — building portal graph (`LandblockInfo.cs`, `BuildInfo.cs`, `CBldPortal.cs`)
The `LandblockInfo` dat file (`xxxxFFFE`) contains:
| Field | Type | Purpose |
|---|---|---|
| `NumCells` | `uint` | Total number of EnvCells in this landblock |
| `Objects` | `List<Stab>` | Static objects at landblock level (not inside any EnvCell) |
| `Buildings` | `List<BuildInfo>` | One `BuildInfo` per building structure |
Each `BuildInfo` has:
- `ModelId` — the GfxObj/Setup id of the building mesh
- `Frame` — world placement
- `Portals``List<CBldPortal>` — the **building-level portal list** (distinct from the per-cell `CellPortals`)
Each `CBldPortal` has:
- `OtherCellId` — the EnvCell low-byte id that this portal opens into (0xFFFF = no indoor side)
- `OtherPortalId` — back-link into that cell's portal list
- `StabList` — list of cells visible through this portal opening (the per-building-portal PVS)
This is the **entry-point graph** acdream uses in `EnvCellRenderManager.GenerateForLandblockAsync`
(line 657) to discover which EnvCells belong to which building: start from `BuildInfo.Portals`,
follow `CBldPortal.OtherCellId` → first EnvCell of the building → follow `CellPortal` connections
recursively to discover all cells in the building.
**WB's cell discovery correctly skips `portal.OtherCellId == 0xFFFF`** — these are exit portals
that lead outside, not to another EnvCell (`EnvCellRenderManager.cs:658,780`). Acdream's
`CellVisibility.GetVisibleCellsFromRoot` also skips them (line 467) and sets `HasExitPortalVisible`.
### What the seal means in the data model
A cell is "sealed" (truly indoor, no outdoor bleed) if:
- `SeenOutside == false`: no exit portal reachable from this cell via the connectivity graph.
Retail's `CellManager::ChangePosition` releases the landscape (terrain) when `seen_outside`
is false on the current cell, because there is definitively nothing outdoor to draw.
A cell "sees outside" if:
- `SeenOutside == true`: at least one exit portal (`OtherCellId == 0xFFFF`) is reachable.
Terrain may be visible; the outdoor world should be drawn, clipped to the visible exit portal.
Acdream already reads this correctly. The `LoadedCell.SeenOutside` field is set from the dat
flag (GameWindow.cs:7704), and `rootSeenOutside = physicsRoot?.SeenOutside ?? true` gates terrain.
---
## 4. Chorizite.ACProtocol
`references/Chorizite.ACProtocol/` — generated from the protocol XML. No rendering-relevant files;
it covers network wire types only. Not applicable to this cross-check.
---
## 5. AC2D
`references/AC2D/` — C++ fixed-function OpenGL client. Does not implement indoor rendering (it
renders everything via the server's authoritative Z; no client-side interior/exterior split). Not
applicable to indoor rendering cross-check.
---
## 6. Reusability Table
| Reference | Component | Reusable? | License | Caveat |
|---|---|---|---|---|
| **WorldBuilder** | `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher` mesh pipeline | YES (in-tree) | MIT | Keep — Phase U is about visibility, not mesh extraction |
| **WorldBuilder** | `EnvCellRenderManager.GenerateForLandblockAsync` cell discovery via `CBldPortal` / recursive `CellPortal` | YES (in-tree, adapted) | MIT | The discovery loop is correct; WB already skips 0xFFFF exit portals |
| **WorldBuilder** | `EnvCellRenderManager.PrepareRenderBatches` frustum + filter | YES (in-tree) | MIT | Used by acdream's `EnvCellRenderer`; only the visibility FILTER needs replacing (use stab-list PVS instead of per-building set) |
| **WorldBuilder** | `PortalRenderManager.GetVisibleBuildingPortals` + `BuildingPortalGPU` mesh | AVOID | MIT | This is the per-building stencil mesh — only useful for WB's two-pipe stencil; not needed for a unified PView pipeline |
| **WorldBuilder** | `VisibilityManager.RenderInsideOut` / `RenderOutsideIn` two-pipe stencil | **DO NOT USE** | MIT | Root cause of all indoor seam bugs; see §1 |
| **WorldBuilder** | `EnvCellFlags.SeenOutside` / `CBldPortal.OtherCellId != 0xFFFF` sentinel | YES (already used) | MIT | Data semantics confirmed correct |
| **ACViewer** | `EnvCell.find_visible_child_cell` | READ-ONLY (GPL) | GPL | Confirms retail `CEnvCell::find_visible_child_cell` interpretation; already ported in acdream's `CellVisibility` |
| **ACViewer** | `EnvCell.build_visible_cells` + `VisibleCells` dict | READ-ONLY (GPL) | GPL | Confirms PVS stab-list usage; acdream has `LoadedCell.VisibleCells` equivalent |
| **ACViewer** | Brute-force render of all cells (Buffer.cs) | **DO NOT USE** | GPL | No visibility, no seal, not usable for a game client |
| **ACE** | `EnvCell` dat file format (all fields) | YES (already used) | AGPL | acdream reads these via DatReaderWriter; field semantics confirmed authoritative |
| **ACE** | `CellPortal.OtherCellId == 0xFFFF` exit-portal sentinel | YES (already used) | AGPL | Critical for detecting the indoor/outdoor boundary |
| **ACE** | `LandblockInfo.Buildings` / `BuildInfo.Portals` entry-point graph | YES (already used) | AGPL | The cell discovery starting point; WB and acdream both use this correctly |
| **ACE** | `CBldPortal.StabList` (per-building-portal PVS) | INVESTIGATE | AGPL | Per-portal stab-list not currently used by acdream; may supplement the per-cell stab-list for cross-building portal resolution |
---
## 7. Recommendations for Phase U Redesign
### Adopt
1. **Retail PView portal-traversal as the single unified visibility pass** (retail anchor:
`PView::ConstructView` ~433750, `ClipPortals` ~433572, `GetClip` ~432344).
acdream already has `PortalVisibilityBuilder` and `CellVisibility.GetVisibleCellsFromRoot`
which are correct unit-tested ports. These are the keepers.
2. **Single code path regardless of camera position.** The camera being inside or outside an EnvCell
changes only which `LoadedCell` is the BFS root — null (outdoor root: player in a LandCell) vs
non-null (indoor root: player in an EnvCell). The draw algorithm does NOT branch on this.
3. **Per-cell PVS stab-list (`LoadedCell.VisibleCells`) as the filter ground.** Retail's
`grab_visible_cells` (decomp 311878) loads the stab-list into `VisibleCells` at cell entry
time. The per-frame BFS walks portal connectivity PLUS the stab-list to determine the drawable
set. The stab-list is precomputed by AC's content tools and is more conservative than a
per-frame portal clip (it includes cells reachable from any viewpoint in the cell, not just
from the current camera position). Use it as the BFS frontier expansion to avoid missed cells
at glancing angles.
4. **`SeenOutside` gate for terrain** (already in acdream at GameWindow.cs:7177).
`seen_outside=false` → skip terrain entirely (pure dungeon). `seen_outside=true` and an
exit portal is visible → draw terrain clipped to the `OutsideView` clip region.
This is retail's `CellManager::ChangePosition` @ `0x004559B0` behavior.
5. **`CellPortal.OtherCellId == 0xFFFF` as the outdoor-world gate** (already in acdream at
`CellVisibility.cs:467`, `PortalVisibilityBuilder`). Every exit portal reached in the BFS
contributes to `OutsideView`. When `OutsideView` is empty, NOTHING outdoor draws (terrain,
scenery, outdoor entities all cull). This is the closed seal.
6. **Outdoor scenery/entity gating via ParentCellId** (already in `WbDrawDispatcher.ResolveEntitySlot`):
- Live-dynamic entities (server GUID != 0): always slot 0 (no clip) — retail draws players/NPCs
through depth without portal clipping.
- Indoor entities (ParentCellId is a full EnvCell id): route to that cell's clip slot; cull if
the cell is not in the visible set.
- Outdoor scenery/statics (ParentCellId == null or is a LandCell id): route to `OutdoorSlot`;
cull when `OutdoorVisible = false` (no exit portal in view). **This is the outdoor-scenery seal.**
7. **The WB-derived mesh pipeline** (`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`,
`TerrainModernRenderer`) is NOT the visibility problem and should not be replaced. Phase U
replaces the draw ORCHESTRATION (what gets drawn, when, with what clip), not the mesh
extraction (what vertices are in the VBO).
8. **`EnvCellRenderer.PrepareRenderBatches` with an explicit `filter` set** (already wired:
`envCellShellFilter` in GameWindow.cs:7333). The filter set is the BFS-visible cell ids from
`ClipFrameAssembly.CellIdToSlot.Keys`. This correctly excludes cells outside the visible set
(e.g. the other side of a multi-story building when the player is only in one floor).
### Avoid
1. **WB's `RenderInsideOut` / `RenderOutsideIn` two-pipe stencil** — abandoned 2026-05-30, do not
reintroduce. The architecture is the bug, not a parameter of the architecture.
2. **Per-building stencil mesh** (`BuildingPortalGPU`, `PortalRenderManager.RenderBuildingStencilMask`,
the `_stencilShader` + `InitializeStencilShader` machinery from WB) — only useful for WB's
stencil two-pipe. If acdream needs per-portal depth-clip, the retail mechanism is a software
clip plane (gl_ClipDistance) set from the per-portal NDC clip region, not a GPU stencil.
3. **The `isInside` / `cameraInsideBuilding` gate** — this is the two-pipe switch. Phase U's
redesign must not have any version of this. The outdoor case is `root == null` (player in a
LandCell); the indoor case is `root != null` (player in an EnvCell). These are inputs to the
SAME algorithm, not selectors for different algorithms.
4. **ACViewer's brute-force all-cells draw** — usable for map viewing tools, not for a game
client. The `Buffer.Draw()` approach will render hundreds of EnvCells including those in
completely different buildings on the other side of the landblock, causing massive overdraw
and incorrect visibility.
5. **Any "grace frame" or fallback AABB resolver for the portal-visibility root.** The root
comes from the physics-ownership answer (`CellGraph.CurrCell`) exclusively — retail's
`CellManager::ChangePosition` reads the transition-owned `curr_cell` with no AABB fallback.
Stage 3 (2026-06-02) deleted the FindCameraCell AABB grace-frame fallback from acdream.
6. **Re-porting the outdoor-terrain or EnvCell mesh extraction from retail decomp.** The WB
inventory doc (`docs/architecture/worldbuilder-inventory.md`) classifies these as green
(already correctly extracted from WB into `src/AcDream.{Core,App}/Rendering/Wb/`). The
rendering *orchestration* is 🔴 (must come from retail), the mesh *extraction* is 🟢
(WB has a tested port). Do not re-port what WB already got right.
---
## 8. Summary of WB-vs-Retail Divergence (10-line version)
1. WB branches hard on `isInside` (camera in ANY EnvCell) → two completely different render paths.
2. Retail has ONE path — portal-traversal BFS from the camera cell; indoor and outdoor are just cells.
3. WB's "seal" is a per-building GPU stencil derived from portal polygon rasters (flat, building-level).
4. Retail's seal is the CPU-derived `OutsideView` clip polygon (recursive, per-portal, per-cell).
5. WB uses NO per-cell PVS stab-list (`VisibleCells`) for rendering; retail uses it as the BFS frontier.
6. WB's outdoor gate (terrain/scenery draw only where stencil=1) fails at doorway-crossing frames (the flap).
7. Retail's outdoor gate (terrain clips to `OutsideView`; skip when empty) is frame-exact and derived from the same BFS as the cell draw.
8. WB cannot express per-portal clip precision (one stencil per building); retail clips each portal opening independently.
9. WB's approach is sound for a static dat-viewer where you never cross thresholds; it is architecturally wrong for a live game client.
10. The Phase U unified pipeline (retail PView port) is the correct fix; grafting anything onto WB's two-pipe stencil is not.
---
## Key File Paths Referenced
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs` — WB RenderInsideOut/RenderOutsideIn (full stencil two-pipe; read for understanding; DO NOT reintroduce)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs` — WB building portal GPU mesh + stencil shader
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` — WB cell discovery loop (reusable), PrepareRenderBatches with filter (reusable)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 8801008 — the `isInside` branch; root of the two-pipe problem
- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs``find_visible_child_cell`, `build_visible_cells`, `VisibleCellIDs`, `SeenOutside` (read-for-understanding; GPL; don't copy)
- `references/ACViewer/ACViewer/Render/Buffer.cs` / `R_EnvCell.cs` / `R_Landblock.cs` — brute-force all-cells draw (read-for-understanding; GPL; DO NOT use)
- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs` — authoritative field list including `VisibleCells`, `SeenOutside`, `CellPortals`
- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs``OtherCellId == 0xFFFF` exit-portal sentinel
- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs` + `Entity/BuildInfo.cs` + `Entity/CBldPortal.cs` — building portal entry-point graph
- `src/AcDream.App/Rendering/CellVisibility.cs` — acdream's BFS visibility system (correct, keep)
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — acdream's recursive portal-clip BFS (correct, keep)
- `src/AcDream.App/Rendering/ClipFrameAssembler.cs` — acdream's per-cell clip slot assembly (correct, keep)
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — entity outdoor-slot routing via `ResolveEntitySlot` (correct, keep)
- `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md` — Phase U decision rationale