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>
526 lines
35 KiB
Markdown
526 lines
35 KiB
Markdown
# acdream Render Pipeline — Inventory and Failure Map (2026-06-02)
|
||
|
||
> Produced as a full redesign handoff after the Phase W session (Stage 3 + Stage 4
|
||
> shipped, membership fix landed). Incorporates live probe evidence from the
|
||
> Holtburg cottage run captured the same session.
|
||
|
||
---
|
||
|
||
## 1. Per-frame draw order
|
||
|
||
All code lives in `GameWindow.OnRender`. Line numbers are from
|
||
`src/AcDream.App/Rendering/GameWindow.cs`.
|
||
|
||
```
|
||
~7140 [visibility root]
|
||
physicsRoot = DataCache.CellGraph.CurrCell → TryGetCell → physicsRoot
|
||
visibility = _cellVisibility.ComputeVisibilityFromRoot(physicsRoot, visRootPos=PLAYER)
|
||
→ VisibilityResult { CameraCell, VisibleCellIds (old BFS set), HasExitPortalVisible }
|
||
cameraInsideCell = visibility?.CameraCell != null
|
||
rootSeenOutside = physicsRoot?.SeenOutside ?? true
|
||
|
||
~7167 [compute render booleans]
|
||
renderSky = !cameraInsideCell || rootSeenOutside
|
||
playerInsideCell = cameraInsideCell && !rootSeenOutside (sealed dungeon)
|
||
|
||
~7250 [PrepareRenderBatches] — EnvCellRenderer
|
||
_envCellRenderer.PrepareRenderBatches(envCellViewProj, camPos, filter:null, …)
|
||
(CPU snapshot; builds per-cell batches. Always called. Cheap.)
|
||
|
||
~7290 [ClipFrame / PortalVisibilityBuilder] ← THE AUTHORITATIVE VISIBILITY FRAME
|
||
if clipRoot (indoor root):
|
||
pvFrame = PortalVisibilityBuilder.Build(clipRoot, visRootPos=PLAYER, envCellViewProj=EYE)
|
||
→ PortalVisibilityFrame { OutsideView, CellViews, OrderedVisibleCells }
|
||
clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame)
|
||
→ TerrainClipMode { Skip | Scissor | Planes }
|
||
→ CellIdToSlot (per-visible-cell clip region)
|
||
→ OutdoorVisible / OutdoorSlot
|
||
→ HasOutsideView / OutsideViewNdcAabb
|
||
_wbDrawDispatcher.SetClipRouting(CellIdToSlot, OutdoorSlot, OutdoorVisible)
|
||
_envCellRenderer.SetClipRouting(CellIdToSlot)
|
||
envCellShellFilter = HashSet<uint>(CellIdToSlot.Keys)
|
||
else (outdoor root):
|
||
_clipFrame.Reset() — no-clip, slot 0 for everything
|
||
_wbDrawDispatcher.ClearClipRouting()
|
||
|
||
~7373 _clipFrame.UploadShared(_gl) ← pushes TerrainUBO + RegionSsbo to GPU
|
||
_wbDrawDispatcher.SetClipRegionSsbo(...)
|
||
_envCellRenderer.SetClipRegionSsbo(...)
|
||
_terrain.SetClipUbo(...)
|
||
|
||
~7378 [Stage 4] sky pre-scene (LScape — drawn FIRST through the doorway)
|
||
drawSkyThisFrame = renderSky && (clipAssembly==null || clipAssembly.HasOutsideView)
|
||
skyDoorwayClip = clipAssembly!=null && clipAssembly.HasOutsideView
|
||
if drawSkyThisFrame:
|
||
BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb) ← clips to portal opening
|
||
re-bind TerrainClipUBO at binding=2
|
||
Enable ClipDistance0..7
|
||
_skyRenderer.RenderSky(...) ← sky mesh (gl_ClipDistance gated)
|
||
Disable ClipDistance0..7
|
||
_particleRenderer.Draw(SkyPreScene) ← particles ONLY inside scissor; NO cell gate
|
||
EndScissor
|
||
|
||
~7431 [pre-login guard] if IsLiveModeWaitingForLogin → goto SkipWorldGeometry
|
||
|
||
~7444 Enable ClipDistance0..7 ← world-geometry bracket opens
|
||
|
||
~7464 [terrain]
|
||
if terrainClipMode == Skip → nothing drawn
|
||
elif terrainClipMode == Scissor → glScissor to OutsideView AABB → _terrain.Draw → disable
|
||
else (Planes) → _terrain.Draw (UBO gated per-vertex)
|
||
|
||
~7523 [Stage 4] conditional doorway Z-clear
|
||
if skyDoorwayClip:
|
||
glScissor to OutsideViewNdcAabb
|
||
glClear(DepthBufferBit) ← depth only; color preserved
|
||
EndScissor
|
||
|
||
~7538 [cell shells — opaque pass]
|
||
if clipAssembly!=null && envCellShellFilter!=null:
|
||
_envCellRenderer.Render(Opaque, envCellShellFilter)
|
||
(per-cell geometry from PrepareRenderBatches; clip-gated via binding=3 slot map)
|
||
|
||
~7546 [entities]
|
||
_wbDrawDispatcher.Draw(camera, LandblockEntries, frustum,
|
||
visibleCellIds: visibility?.VisibleCellIds, ← OLD BFS set, NOT pvFrame
|
||
animatedEntityIds: animatedIds)
|
||
Per-entity slot routing (SetClipRouting active = indoor root):
|
||
serverGuid != 0 (live-dynamic) → slot 0 (unclipped)
|
||
ParentCellId in CellIdToSlot → cell's clip slot
|
||
ParentCellId null → OutdoorSlot or CULL (OutdoorVisible)
|
||
ParentCellId not in CellIdToSlot → CULL
|
||
IsShellScopedSet always returns false (U.1 deleted shell sets); so outdoor
|
||
scenery (ParentCellId==null) routes to OutdoorSlot/CULL — BUT clip routing
|
||
only fires for indoor root (SetClipRouting active). Outdoor root: ALL slot 0,
|
||
nothing culled → outdoor scenery ALWAYS draws when outdoors.
|
||
|
||
~7553 [cell shells — transparent pass]
|
||
if clipAssembly!=null && envCellShellFilter!=null:
|
||
_envCellRenderer.Render(Transparent, envCellShellFilter)
|
||
|
||
~7561 Disable ClipDistance0..7 ← world-geometry bracket closes
|
||
|
||
~7568 [Scene particles]
|
||
_particleRenderer.Draw(Scene)
|
||
NO cell filter; no clip planes. All particles draw regardless of camera location.
|
||
|
||
~7583 [Stage 4] sky post-scene (weather / rain)
|
||
if drawSkyThisFrame:
|
||
BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb)
|
||
re-bind TerrainClipUBO
|
||
Enable ClipDistance0..7
|
||
_skyRenderer.RenderWeather(...) ← rain cylinder clipped to doorway
|
||
Disable ClipDistance0..7
|
||
_particleRenderer.Draw(SkyPostScene) ← NO cell gate; scissor only
|
||
EndScissor
|
||
|
||
~7739 SkipWorldGeometry: (pre-login goto target)
|
||
```
|
||
|
||
---
|
||
|
||
## 2. The gate table — the headline inconsistency
|
||
|
||
| What is gated | Gate source | Computed by | Consistency verdict |
|
||
|---|---|---|---|
|
||
| **Terrain** | `terrainClipMode` + UBO planes | `ClipFrameAssembler` ← `PortalVisibilityBuilder` (pvFrame OutsideView) | CORRECT — skips when no exit portal visible. Planes-mode clips to portal opening per-vertex. Scissor-mode over-includes. |
|
||
| **Cell shells** | `envCellShellFilter` (CellIdToSlot.Keys) + per-instance clip slot (binding=3) | Same pvFrame / ClipFrameAssembler | CORRECT — only draws cells whose clip region is non-empty. |
|
||
| **Indoor static entities** (ParentCellId set) | `CellIdToSlot` lookup via `ResolveSlotForFrame` | Same pvFrame / ClipFrameAssembler | CORRECT for indoor statics. CULL when cell not visible. |
|
||
| **Outdoor scenery / building shells** (ParentCellId == null) | `OutdoorVisible` → OutdoorSlot or CULL | Same pvFrame / ClipFrameAssembler | GATED when `_clipRoutingActive` (indoor root only). Clips to OutsideView slot. BUT: `IsShellScopedSet` always returns false (shell sets deleted in U.1), so the `IsBuildingShell` anchor-cell branch in `EntityPassesVisibleCellGate` is dead code — the live path at line 1756 returns `true` unconditionally for null-ParentCellId when NOT clip-routing. See §3. |
|
||
| **Live-dynamic entities** (serverGuid != 0) | None — slot 0, always unclipped | N/A | INTENTIONAL — matches retail. |
|
||
| **Particles (Scene / SkyPost / SkyPre)** | Sky pre/post: doorway scissor only. Scene: NONE | N/A | BROKEN: no cell gate. Issue #104 (deferred). |
|
||
| **Sky mesh** | `drawSkyThisFrame` (seen_outside + HasOutsideView) + gl_ClipDistance against TerrainClipUBO | Same pvFrame | CORRECT — gated to portal opening or full-screen outdoors. |
|
||
|
||
### The core inconsistency (three gates, not one)
|
||
|
||
```
|
||
GATE #1 — Terrain:
|
||
Computed from pvFrame.OutsideView (PortalVisibilityBuilder) → TerrainClipMode
|
||
Skip ⇒ 0 terrain. Planes ⇒ clipped to portal. Scissor ⇒ AABB over-include.
|
||
SOURCE: ClipFrameAssembler (~GameWindow.cs:7322)
|
||
|
||
GATE #2 — Cell shells:
|
||
Computed from pvFrame.CellIdToSlot (same PortalVisibilityBuilder) → envCellShellFilter
|
||
Only draws cells whose clip region is non-empty (correct, but incomplete seal).
|
||
SOURCE: ClipFrameAssembler (~GameWindow.cs:7333)
|
||
|
||
GATE #3 — Entities (WbDrawDispatcher):
|
||
Indoor statics: ParentCellId ∈ CellIdToSlot → correct.
|
||
Outdoor stabs (ParentCellId == null):
|
||
When _clipRoutingActive (indoor root): gated to OutdoorSlot or culled — looks correct.
|
||
BUT: visibleCellIds passed to EntityPassesVisibleCellGate is from
|
||
CellVisibility.ComputeVisibilityFromRoot → VisibilityResult.VisibleCellIds,
|
||
which is the OLD BFS (GetVisibleCellsFromRoot). This is a SEPARATE computation
|
||
from pvFrame. The two can agree, but they are NOT the same object.
|
||
SOURCE: _cellVisibility.ComputeVisibilityFromRoot (~GameWindow.cs:7166)
|
||
NOT from PortalVisibilityBuilder.
|
||
```
|
||
|
||
**The duplication:** `CellVisibility.ComputeVisibilityFromRoot` (old BFS, produces `VisibilityResult.VisibleCellIds`) and `PortalVisibilityBuilder.Build` (new pvFrame BFS) are TWO SEPARATE portal traversals per frame. The entity dispatcher consumes the old one via `visibleCellIds`; terrain + shells consume the new one via `pvFrame`. When they disagree, entities draw in cells whose shells are culled, or vice versa.
|
||
|
||
**The structural gap (issue #78 root cause):** outdoor scenery (ParentCellId == null: houses, trees, landblock-baked stabs) bypasses the entity gate entirely when NOT clip-routing (outdoor root). When clip-routing is active (indoor root), they route to OutdoorSlot/cull — BUT the `IsShellScopedSet` branch that would use `BuildingShellAnchorCellId` for building-shell culling is dead code (always returns false, line 1761). So building shells registered with `IsBuildingShell=true` but no `BuildingShellAnchorCellId` still pass. The result: some outdoor geometry leaks through the portal restriction.
|
||
|
||
**Retail's design:** one PView traversal (`PView::ConstructView` → `ClipPortals` → `AddViewToPortals`, retail decomp `:433750`). Every renderer consumes the SAME regions. There is no separate entity-visibility BFS.
|
||
|
||
---
|
||
|
||
## 3. Per-renderer notes
|
||
|
||
### 3.1 EnvCellRenderer (`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`)
|
||
|
||
- **What it does:** draws indoor cell geometry (walls/floor/ceiling of EnvCells). WB port
|
||
(`WB EnvCellRenderManager.cs`). Per-cell batches prepared by `PrepareRenderBatches`
|
||
(~line 530), drawn in two passes (Opaque / Transparent) via `Render(pass, filter)` (~line 790).
|
||
- **Clip mechanism:** per-instance clip-slot buffer (binding=3, `_clipSlotBuffer`). Each
|
||
instance stores its `CellIdToSlot` slot index. The shared clip-region SSBO (binding=2,
|
||
`ClipFrame.RegionSsbo`) is handed in via `SetClipRegionSsbo`.
|
||
- **Diagnostics:**
|
||
- `ACDREAM_PROBE_SHELL=1` → `[shell]` line each opaque pass (~line 943). Reports per-cell:
|
||
`gfx=N` (gfxObj count), `tf=N` (transform count), `batch=N`, `idx=N` (index count / 3 = tris),
|
||
`tr=N` (translucent batches), `zh=N` (zero-handle = missing bindless texture).
|
||
Diagnosis tree: `NOSNAP` = cell not in snapshot (no batches prepared); `zh>0` = geometry
|
||
present but texture handle zero (invisible); `idx>0 + zh=0 + tr=0` = opaque geometry drawn,
|
||
fault is depth/occlusion not the shell itself.
|
||
- **Session evidence:** all visited cells (0170–0175) showed `idx>0 + zh=0 + tr=0 + tr=0`.
|
||
Shells ARE drawn, textured, opaque. Max `filter=3` cells rendered at once (correct for a
|
||
3-cell visibility set). ZERO `NOSNAP`.
|
||
- **Known issue (U.4 fix):** GL state (Blend, DepthMask, uViewProjection, CullMode cache)
|
||
must be set self-contained at entry to each `Render()` call. Bugs were hit 3× in Phase U.4
|
||
when state bled from prior renderers. Fixed by explicit state setup at ~line 810 + 1010.
|
||
`CullMode.Landblock → CullMode.None` override at line 1216 renders cell polys double-sided
|
||
as a stopgap (architectural cause not yet resolved).
|
||
|
||
### 3.2 TerrainModernRenderer (`src/AcDream.App/Rendering/TerrainModernRenderer.cs`)
|
||
|
||
- **What it does:** draws all loaded landblock terrain via `glMultiDrawElementsIndirect`.
|
||
Single global VBO/EBO, one slot per landblock (~line 26 onwards).
|
||
- **Clip mechanism:** TerrainClipUBO (binding=2, `ClipFrame.TerrainUbo`) handed in via
|
||
`SetClipUbo`. When the UBO count > 0 (Planes mode), `terrain_modern.vert` clips per-vertex
|
||
via `gl_ClipDistance`. When count==0 (outdoor root / Scissor mode), ungated.
|
||
- **Gate in GameWindow:** `terrainClipMode` (~line 7464):
|
||
- `Skip` → no draw at all (correct for sealed cellar: OutsideView is empty).
|
||
- `Scissor` → `glScissor` to `TerrainScissorNdcAabb`; UBO count 0 (no plane gating).
|
||
This mode OVER-INCLUDES (everything in the scissor box draws, not just through the portal).
|
||
- `Planes` → draws normally; UBO planes gate per-vertex.
|
||
- **Known gap:** Scissor-mode over-inclusion. When the OutsideView polygon exceeds the
|
||
convex-plane budget, terrain is scissored to its NDC AABB rather than clipped to the
|
||
actual portal shape. An unusually large or non-convex doorway can let terrain bleed.
|
||
|
||
### 3.3 WbDrawDispatcher (`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`)
|
||
|
||
- **What it does:** draws all world entities (static scenery, NPCs, doors, items) via
|
||
`glMultiDrawElementsIndirect`. Per-instance clip slots (binding=3) + shared clip SSBO
|
||
(binding=2).
|
||
- **Entity gate:** `EntityPassesVisibleCellGate` (static, line 1739):
|
||
- `visibleCellIds == null` → pass (outdoor root: nothing culled).
|
||
- `ParentCellId` set → pass iff cell ∈ `visibleCellIds`.
|
||
- `ParentCellId == null && IsBuildingShell && IsShellScopedSet` → anchor-cell check. BUT
|
||
`IsShellScopedSet` always returns `false` (line 1761, U.1 deletion). So this branch is
|
||
dead.
|
||
- `ParentCellId == null && !(the above)` → **unconditionally returns `true`** (line 1756).
|
||
This is the outdoor-scenery bypass: houses, trees, landblock-baked stabs all pass when
|
||
`visibleCellIds` is non-null but they have no `ParentCellId`.
|
||
- **Clip-slot routing** (when `_clipRoutingActive`, i.e. indoor root):
|
||
- `serverGuid != 0` → slot 0 (unclipped — retail behavior).
|
||
- `ParentCellId in CellIdToSlot` → cell's clip slot.
|
||
- `ParentCellId == null && OutdoorVisible` → OutdoorSlot (gated to OutsideView).
|
||
- `ParentCellId == null && !OutdoorVisible` → CULL.
|
||
- `ParentCellId not in CellIdToSlot` → CULL.
|
||
- When NOT `_clipRoutingActive` (outdoor root): all slot 0, no culling.
|
||
- **The gap:** `visibleCellIds` passed by GameWindow is `visibility?.VisibleCellIds` (old
|
||
`CellVisibility` BFS), not `pvFrame.OrderedVisibleCells` (new `PortalVisibilityBuilder`
|
||
BFS). These are parallel traversals of the same graph; in practice they should agree for
|
||
the simple cottage graph, but they can diverge for complex portal topologies.
|
||
|
||
### 3.4 CellVisibility (`src/AcDream.App/Rendering/CellVisibility.cs`)
|
||
|
||
- **What it does:** OLD portal visibility system. `ComputeVisibilityFromRoot(root, pos)` does
|
||
a simple BFS through `LoadedCell.Portals`, collecting the full reachable set as
|
||
`VisibleResult.VisibleCellIds` (HashSet). Sets `CameraCell` when inside a cell.
|
||
Also produces `HasExitPortalVisible` (a portal with `OtherCellId == 0xFFFF` was reached).
|
||
- **Used for:** `visibility?.VisibleCellIds` → entity gate in `WbDrawDispatcher.Draw`.
|
||
Also for `cameraInsideCell`, `physicsRoot` lookup.
|
||
- **NOT used for:** terrain gating, shell filter, clip-slot assignment. Those come from
|
||
`PortalVisibilityBuilder`.
|
||
- **Note:** `FindCameraCell` AABB grace-frame fallback was DELETED in Stage 3 (2026-06-02).
|
||
`ComputeVisibilityFromRoot(null, …)` returns `null` (outdoor root) — no AABB scan fallback.
|
||
|
||
### 3.5 PortalVisibilityBuilder (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`)
|
||
|
||
- **What it does:** the NEW portal-clip visibility BFS (port of retail `PView::ConstructView`
|
||
`:433750`). Produces `PortalVisibilityFrame`: per-cell `CellView` (screen-space NDC clip
|
||
region), `OutsideView` (union of all exit-portal regions), `OrderedVisibleCells`
|
||
(closest-first list).
|
||
- **Used for:** terrain clip mode, shell filter, entity clip slots (via `ClipFrameAssembler`).
|
||
- **KEEP** — this is the correct retail-faithful PView kernel. Output is correct (confirmed
|
||
by `[vis]` probe: cellar 0174 shows `outPolys=0 / terrain=Skip`; room 0171 shows
|
||
`outPolys=1 / terrain=Planes`).
|
||
|
||
### 3.6 ClipFrame / ClipFrameAssembler / ClipPlaneSet
|
||
|
||
- **What it does:** CPU → GPU bridge. `ClipFrameAssembler.Assemble` translates the
|
||
`PortalVisibilityFrame` into a `ClipFrameAssembly`: per-cell plane arrays packed into a
|
||
SSBO (`RegionSsbo`), a UBO for terrain (`TerrainUbo`), `CellIdToSlot` map, `TerrainClipMode`.
|
||
- **KEEP** — pure CPU logic, GL-free, fully unit-tested. The slot/gate policy is correct.
|
||
|
||
### 3.7 ParticleRenderer (`src/AcDream.App/Rendering/ParticleRenderer.cs`)
|
||
|
||
- **What it does:** draws particle emitters for all three passes (SkyPreScene, Scene,
|
||
SkyPostScene). `particle.vert` has NO `gl_ClipDistance` support.
|
||
- **Gating:** SkyPreScene and SkyPostScene are bounded by the doorway scissor
|
||
(`BeginDoorwayScissor`) when `skyDoorwayClip` is set. Scene pass: **no gate at all** — draws
|
||
every particle regardless of camera location.
|
||
- **Known gap:** issue #104 (deferred). Inside a sealed cellar, `Scene` particles from the
|
||
outdoor network-portal (e.g. Holtburg Town portal effect) are still drawn, visible through
|
||
walls.
|
||
|
||
### 3.8 SkyRenderer (`src/AcDream.App/Rendering/Sky/SkyRenderer.cs`)
|
||
|
||
- **What it does:** sky mesh (celestial objects) + weather (rain cylinder). Uses `sky.vert`
|
||
which writes `gl_ClipDistance` from the same binding=2 TerrainClipUBO.
|
||
- **Gate:** `drawSkyThisFrame = renderSky && (clipAssembly==null || HasOutsideView)`.
|
||
Indoor with no exit portal → no sky (seen_outside=false for dungeon; HasOutsideView=false
|
||
for a room whose portal is around a corner). Indoor with visible portal → sky/weather
|
||
clipped to doorway via gl_ClipDistance (Planes mode) or glScissor (Scissor mode).
|
||
- **KEEP** — Stage 4 correctly implements the retail LScape draw split.
|
||
|
||
---
|
||
|
||
## 4. Failure map — 5 user symptoms → root cause → gate
|
||
|
||
**Probe context:** `[vis]` probe confirms PVS is correct.
|
||
`[shell]` probe confirms shells ARE drawn (idx>0, zh=0, tr=0).
|
||
Player moves 0170→0171→0175→0174 correctly via `[cell-transit]`.
|
||
|
||
### Symptom 1: From outside through a door/window, interior walls are transparent
|
||
|
||
- **Root cause:** outside-looking-in (U.5) — when the camera is outdoors and the player
|
||
is outdoors, `clipRoot == null` → outdoor root → `_clipRoutingActive = false` → every
|
||
entity slot 0, terrain ungated, shells not drawn (shell rendering is only active when
|
||
`clipAssembly != null`). EnvCell shells are NEVER rendered from the outdoor root.
|
||
The indoor cells are not in the WbDrawDispatcher entity walk because they have
|
||
`ParentCellId` set to cells that may not be in `visibleCellIds` (which is `null`
|
||
outdoors).
|
||
- **Gate:** GATE #2 (shell rendering only active for indoor root). No outdoor → indoor
|
||
visibility pipeline exists yet.
|
||
- **Status:** planned Phase U.5 / deferred by the render-reset mandate.
|
||
|
||
### Symptom 2: Inside you see only the outdoor world + NPCs/particles/doors (no interior)
|
||
|
||
- **Root cause (indoor root path):** shells ARE drawn (probe confirmed). But outdoor terrain
|
||
renders on top and clips through the shells because:
|
||
(a) **Terrain** (Gate #1): in Planes mode, outdoor terrain is clipped to the portal-plane
|
||
region, but since the cottage room has an exit portal, terrain draws THROUGH the doorway
|
||
region. In Scissor mode it draws through the portal bounding box. The shells do not
|
||
depth-occlude the terrain if terrain draws after the shells — but terrain draws BEFORE
|
||
the shells (line 7464 vs 7538). So: terrain draws first, shells draw on top. This is
|
||
correct draw order. The issue is that the terrain PLANE region covers the entire portal
|
||
opening (the door/window), not just the area past the wall, so terrain pixels are
|
||
written inside the room via the doorway.
|
||
(b) **Outdoor stabs** (Gate #3, line 1756): `ParentCellId==null` entities bypass
|
||
`visibleCellIds`, always pass the `EntityPassesVisibleCellGate`. When `_clipRoutingActive`,
|
||
they route to OutdoorSlot (clipped to OutsideView) or are culled when `!OutdoorVisible`.
|
||
For a cellar with `seen_outside=true` but sealed walls (OutsideView present), the stabs
|
||
are clipped to the OutsideView AABB — but an AABB is a rectangle, not the actual doorway
|
||
silhouette, so stabs visible in the same NDC box as the doorway still draw.
|
||
- **Gate:** GATE #1 (terrain) leaks through the portal opening; GATE #3 (entity) allows
|
||
outdoor stabs to render through any pixel in the OutsideView bounding box.
|
||
|
||
### Symptom 3: Looking out from inside, you see houses/trees through the ground
|
||
|
||
- **Root cause:** same as Symptom 2 from the other direction — outdoor stabs have
|
||
`ParentCellId==null` → unconditionally pass `EntityPassesVisibleCellGate` (line 1756).
|
||
When inside, clip routing is active and they route to OutdoorSlot. If `OutdoorVisible`
|
||
is true (there IS an exit portal), they clip to the OutsideView AABB — but the AABB
|
||
over-includes relative to the actual portal polygon. If `OutdoorVisible` is false (cellar
|
||
with no direct exit portal in view) they should be culled — and the `[vis]` probe shows
|
||
`terrain=Skip` for the sealed cellar, meaning this is correct for terrain. But stabs
|
||
still leak through because clip-slot routing uses `outdoorSlot` (which points to the
|
||
OutsideView region, computed per frame), not a true exclusion.
|
||
- **The deeper gap:** even when `OutdoorVisible=false` (terrain Skip), the `ClipSlotCull`
|
||
sentinel at line 399 (`return outdoorVisible ? outdoorSlot : ClipSlotCull`) should cull
|
||
outdoor stabs. Let's verify this is actually reaching that code: `ResolveSlotForFrame`
|
||
(line 414) gates on `_clipRoutingActive`. When active and `OutdoorVisible=false`,
|
||
`ResolveEntitySlot` returns `ClipSlotCull` → `culled=true` → entity dropped. This is
|
||
CORRECT in theory. But the symptom persists, suggesting either:
|
||
(a) `_clipRoutingActive` is not set on frames where the symptom shows (possible race
|
||
between PrepareRenderBatches and Render), or
|
||
(b) some outdoor stabs have `serverGuid != 0` (live-dynamic) and thus take the
|
||
slot-0 / always-unclipped branch.
|
||
- **Gate:** GATE #3 (entity). The theory says it should work for dat-hydrated stabs when
|
||
`OutdoorVisible=false`; if the symptom still appears from the cellar, (b) is likely.
|
||
|
||
### Symptom 4: On the cellar stairs, walls show but floor is grey and entities show through walls above
|
||
|
||
- **Root cause (grey floor):** terrain is rendering. For cell 0175 (stairs), `seen_outside=true`
|
||
→ terrain draws. The stairs cell has an exit portal chain, so `OutsideView` is non-empty,
|
||
`terrain=Planes`. Terrain at the landblock Z level renders through the portal-plane region.
|
||
The stair geometry is at a higher Z than the terrain; the terrain wins depth for some pixels
|
||
→ grey floor (terrain texture visible through the stair floor polygon).
|
||
- **Root cause (entities through walls above):** live-dynamic entities (`serverGuid != 0`)
|
||
take slot 0 (unclipped) unconditionally (line 391: `if (serverGuid != 0) return 0`).
|
||
This matches retail intent, but means NPCs / doors visible through walls above the stairs
|
||
are working as designed — they are depth-tested only, not portal-clipped.
|
||
- **Gate:** terrain GATE #1 (Planes clip is correct in theory but terrain at ground level
|
||
is visible through a downward-looking portal); entity unclipped slot for live-dynamics.
|
||
|
||
### Symptom 5: In the cellar you see grey world instead of the floor
|
||
|
||
- **Root cause (confirmed by `[vis]` probe):** cellar 0174 shows `outPolys=0 / terrain=Skip`.
|
||
The cellar IS correctly sealed — terrain does NOT draw for the cellar frame (Skip mode).
|
||
The grey is NOT outdoor terrain. It is the **GL clear color** (background) showing through
|
||
because:
|
||
(a) The cell shell DOES draw (`[shell]` probe: 0174 `idx=42`, 14 triangles, opaque, zh=0).
|
||
But the FLOOR polygon of the cellar GfxObj may not be present in the dat's polygon list
|
||
(the cellar has no floor polygon — only walls). The floor visual is a separate landblock
|
||
terrain tile at the cellar depth, normally occluded by the terrain mesh. With terrain
|
||
SKIPPED (correct!), nothing draws at that Z level — the clear color shows through.
|
||
(b) Alternatively, the floor is present but the GfxObj has `CullMode.Landblock` remapped to
|
||
`CullMode.None` (EnvCellRenderer line 1216). If the floor polygon's normal points up,
|
||
it still draws. If the floor is missing from the dat, nothing closes the bottom.
|
||
- **This is a geometry/dat gap, not a gating bug** — the cellar cell may simply not
|
||
have a floor polygon in the EnvCell dat, expecting the terrain to serve as the floor
|
||
(retail gets away with this because the terrain is never skipped in retail for a
|
||
`seen_outside=true` cell with an exit portal reachable — retail draws terrain for every
|
||
cell it visits in the PView walk, not just when an OutsideView exists). The Skip mode
|
||
(correct for a SEALED dungeon with no exit portal) is too aggressive for a `seen_outside=true`
|
||
cellar where the exit portal is simply behind you / not in the current frustum.
|
||
- **Gate:** GATE #1 (terrain). `Skip` fires when `OutsideView.IsNothingVisible` — this is
|
||
correct for a dungeon, but for a `seen_outside=true` building interior it fires whenever
|
||
the exit portal is behind the camera, which removes the terrain floor.
|
||
|
||
---
|
||
|
||
## 5. REUSABLE vs REDESIGN inventory
|
||
|
||
### KEEP (correct, retail-faithful, well-tested)
|
||
|
||
| Component | What to keep | Why |
|
||
|---|---|---|
|
||
| `PortalVisibilityBuilder` | Entire file | Correct PView BFS port. Probe-confirmed: OutsideView polygons, ordered cells, exit-portal detection all correct. |
|
||
| `ClipFrame` / `ClipPlaneSet` | Entire files | GL-free, unit-tested. The slot/gate policy and SSBO layout are correct. |
|
||
| `ClipFrameAssembler` | Entire file + tests | Correct translation of pvFrame → GPU slots. `TerrainClipMode.Skip` = correct for sealed dungeon. |
|
||
| `LoadedCell.SeenOutside` | Field + hydration | Correct retail anchor (`acclient.h seen_outside`). Stage 3 uses it correctly for sky/sun gate. |
|
||
| Doorway Z-clear (Stage 4) | GameWindow ~7523 | Correct retail port of `PView::DrawCells:432731`. |
|
||
| Sky clip to OutsideView (Stage 4) | SkyRenderer path | Correct retail LScape split. gl_ClipDistance gating works. |
|
||
| `CellVisibility.ComputeVisibilityFromRoot` | Root-selection logic only | The physics-membership root selection is correct (Stage 3 fix). The BFS body can be retired once entity dispatch reads from pvFrame directly. |
|
||
| `EnvCellRenderer` | Geometry/texture/MDI path | `[shell]` probe confirms correct geometry, textures, depth, state. Keep the renderer; redesign the gating. |
|
||
| `TerrainModernRenderer` | Renderer itself | Works correctly. Just needs its caller to supply the correct clip mode. |
|
||
| `WbDrawDispatcher` slot-routing | `ResolveEntitySlot` logic (lines 381–400) | The policy is retail-faithful for indoor statics + live-dynamics. The gap is the outdoor-stab bypass, fixable without redesigning the dispatcher. |
|
||
| All diagnostic probes | `ACDREAM_PROBE_SHELL`, `PROBE_VIS`, `PROBE_FLAP`, `PROBE_CELL`, `PROBE_SWEPT` | Essential apparatus. Keep until the pipeline redesign is visual-verified. |
|
||
|
||
### REDESIGN / FIX
|
||
|
||
| Component | Problem | Fix direction |
|
||
|---|---|---|
|
||
| `WbDrawDispatcher.EntityPassesVisibleCellGate` line 1756 | `ParentCellId==null` returns `true` unconditionally (outdoor stab bypass). | When `_clipRoutingActive` AND `!OutdoorVisible`: return false. The `ResolveSlotForFrame` path already culls via `ClipSlotCull` for the clip-slot mechanism — verify that path is actually reached and the `IsBuildingShell` anchor branch fires for shells. |
|
||
| `WbDrawDispatcher.Draw` `visibleCellIds` parameter | Sourced from `CellVisibility` old BFS, not from `pvFrame`. Two separate traversals per frame. | Retire `VisibilityResult.VisibleCellIds` as the entity gate; use `pvFrame.OrderedVisibleCells` (same data, one traversal). |
|
||
| `CellVisibility.GetVisibleCellsFromRoot` BFS | Duplicate of PortalVisibilityBuilder's BFS (minus clip regions). Called once per frame alongside pvFrame. | Retain only `CameraCell` derivation (needed for `clipRoot`); discard the `VisibleCellIds` set. |
|
||
| `TerrainClipMode.Skip` trigger | Fires when `OutsideView.IsNothingVisible` — correct for dungeon, but fires for a `seen_outside=true` building interior when the exit portal is behind the camera. Removes the terrain floor for the cellar case. | Gate terrain-skip on `!physicsRoot?.SeenOutside` (i.e. Skip ONLY for dungeons where `seen_outside=false`). When `seen_outside=true`, always draw terrain at least at clip-mode Planes/Scissor. |
|
||
| `ParticleRenderer.Draw(Scene)` | No cell filter; no clip planes. Particles draw everywhere. | Add an indoor gate: when `clipAssembly != null` (indoor root), scissor Scene particles to OutsideView AABB. Full clip-slot support for particles is a larger change (particles have no instanceID). |
|
||
| `EnvCellRenderer` CullMode.Landblock → None override (line 1216) | Architectural stopgap — renders cell polys double-sided. The real winding issue hasn't been resolved. | Investigate whether AC's EnvCell geometry has consistent winding (retail uses backface cull). If winding is consistent, remove override and set CullMode per polygon's winding from the dat. |
|
||
| Outside-looking-in (U.5) | No pipeline for outdoor-camera → indoor-cell visibility. Shell rendering is only active for indoor root. | Phase U.5: add outdoor shell pass when `CellVisibility` detects a nearby building cell in the frustum. |
|
||
|
||
---
|
||
|
||
## 6. The diagnostic apparatus
|
||
|
||
### Probes: env vars and what they emit
|
||
|
||
| Env var | C# property | Emission site | Line format | When to use |
|
||
|---|---|---|---|---|
|
||
| `ACDREAM_PROBE_CELL=1` | `PhysicsDiagnostics.ProbeCellEnabled` | `PlayerMovementController.cs:776` | `[cell-transit] old=0x... new=0x... pos=(x,y,z) reason=...` | Confirm cell membership changes. Low volume (fires only on transitions). |
|
||
| `ACDREAM_PROBE_VIS=1` | `RenderingDiagnostics.ProbeVisibilityEnabled` | `GameWindow.cs:7338` | `[vis] root=0x... cells=[...] outPolys=N outPlanes=N per-cell:{0x...:N,...}` | Confirm PVS output. Cell-change-throttled to stay readable. |
|
||
| `ACDREAM_PROBE_FLAP=1` | `RenderingDiagnostics.ProbeFlapEnabled` | `GameWindow.cs:7352`, `PortalVisibilityBuilder.cs:235` | `[flap-cam] root=0x... res=None eyeInRoot=Y/n eye=... terrain=... outVisible=...` | Check whether terrain/portals flap. High volume; use briefly. |
|
||
| `ACDREAM_PROBE_SHELL=1` | `RenderingDiagnostics.ProbeShellEnabled` | `EnvCellRenderer.cs:950` | `[shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:gfx=N tf=N batch=N idx=N tr=N zh=N]` | Confirm cell shells are drawn per frame. Opaque pass only. |
|
||
| `ACDREAM_PROBE_SWEPT=1` | `PhysicsDiagnostics.ProbeSweptEnabled` | `PhysicsEngine.cs:861` | `[swept-sphere] ...` | Physics swept-sphere diagnostics (not render). |
|
||
| `ACDREAM_PROBE_PUSH_BACK=1` | `PhysicsDiagnostics.ProbePushBackEnabled` | BSPQuery.cs | `[push-back]`, `[push-back-disp]`, `[push-back-cell]` | A6 apparatus; heavy under motion. |
|
||
| `ACDREAM_PROBE_FLAP=1` (builder) | same | `PortalVisibilityBuilder.cs:235` | `[flap] camCell=0x... portals=N TRV/SKIP entries...` | Portal traversal trace per frame. Heavy. |
|
||
|
||
Runtime-toggleable via DebugPanel (F11 → Diagnostics checkboxes) without relaunch
|
||
for `PROBE_CELL` and `PROBE_VIS`.
|
||
|
||
### Reading logs on Windows (launch.log is UTF-16 LE from PowerShell Tee-Object)
|
||
|
||
Do NOT use GNU `grep` on `launch.log` — it interprets UTF-16 as binary.
|
||
|
||
**Correct approach (PowerShell):**
|
||
```powershell
|
||
# Filter [shell] lines
|
||
Select-String -Path launch.log -Pattern '^\[shell\]' | Select-Object -ExpandProperty Line
|
||
|
||
# Filter [vis] lines
|
||
Select-String -Path launch.log -Pattern '^\[vis\]' | Select-Object -ExpandProperty Line
|
||
|
||
# Filter [cell-transit] lines
|
||
Select-String -Path launch.log -Pattern '^\[cell-transit\]' | Select-Object -ExpandProperty Line
|
||
```
|
||
|
||
**Correct approach (ripgrep / Grep tool) — works with UTF-16 LE when -E flag is set:**
|
||
```
|
||
rg --encoding utf-16-le '\[shell\]' launch.log
|
||
rg --encoding utf-16-le '\[vis\]' launch.log
|
||
```
|
||
|
||
If the log was tee'd with PowerShell's default `Tee-Object` (UTF-16 LE without `-Encoding utf8`),
|
||
every other byte is NUL. Read the file with `Get-Content launch.log | Select-String '\[shell\]'`
|
||
or force UTF-8 at launch time:
|
||
```powershell
|
||
dotnet run ... 2>&1 | Tee-Object -FilePath launch.log -Encoding utf8
|
||
```
|
||
|
||
### `[shell]` diagnosis tree
|
||
|
||
```
|
||
[shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:...]
|
||
│
|
||
├── NOSNAP → cell not in PrepareRenderBatches snapshot
|
||
│ Cause: cell not yet loaded (streaming lag) OR PrepareRenderBatches filter excluded it.
|
||
│ Action: check streaming for the cell; verify PrepareRenderBatches is called with filter:null.
|
||
│
|
||
├── gfx=0 → cell present in snapshot but no GfxObj batches
|
||
│ Cause: EnvCell has no renderable geometry (possible for corridor/transition cells).
|
||
│ Action: inspect dat with DatReaderWriter dump; verify the cell has a non-empty
|
||
│ EnvironmentId → GfxObj polygon list.
|
||
│
|
||
├── idx=0 → gfxObj present but zero index count
|
||
│ Cause: GfxObj loaded but ObjectMeshManager returned 0-index batch.
|
||
│ Action: check mesh staging; PrepareMeshDataAsync may not have completed for this GfxObj.
|
||
│
|
||
├── zh>0 → batch present with zero bindless texture handle
|
||
│ Cause: texture not yet uploaded to TextureCache; GfxObj mesh arrived before texture decode.
|
||
│ Action: wait for texture upload; check for TextureCache errors in log.
|
||
│
|
||
└── idx>0 + zh=0 + tr=0 → OPAQUE GEOMETRY DRAWN — fault is elsewhere
|
||
The shell is geometrically correct and textured. The problem is:
|
||
(a) Terrain/outdoor stabs render in front and are not gated to the portal opening,
|
||
or (b) outside-looking-in (U.5, no outdoor root shell pass).
|
||
This is the confirmed state for the current session.
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Summary
|
||
|
||
The current pipeline has PVS that is **computationally correct** but enforces visibility
|
||
through **three inconsistent gates**:
|
||
|
||
1. **Gate 1 (terrain):** `PortalVisibilityBuilder` → `ClipFrameAssembler` → `TerrainClipMode`.
|
||
Correct for sealed dungeons. Over-aggressive `Skip` for `seen_outside=true` buildings
|
||
when portal is behind the camera.
|
||
|
||
2. **Gate 2 (shells):** same `PortalVisibilityBuilder` output → `envCellShellFilter`.
|
||
Correct. Only active for indoor root.
|
||
|
||
3. **Gate 3 (entities):** parallel OLD `CellVisibility` BFS → `VisibleCellIds` (set membership),
|
||
combined with `PortalVisibilityBuilder`-derived clip routing. The outdoor-stab bypass
|
||
(`ParentCellId==null` → `return true`, line 1756) is the primary architectural gap.
|
||
|
||
Retail has **one gate**: PView. One traversal, one region per cell, every geometry type
|
||
clipped to its portal-derived region. The fix is to make `PortalVisibilityBuilder` the
|
||
sole source of truth for ALL geometry types and retire `CellVisibility.VisibleCellIds` as
|
||
a rendering gate.
|
||
|
||
The `[shell]` probe evidence rules out: geometry missing, textures missing, wrong cull mode,
|
||
blend/depth state errors. The shells ARE drawn correctly. The residual is purely that outdoor
|
||
geometry is not gated to portal openings.
|