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>
This commit is contained in:
parent
b595cfbb9f
commit
21bf97ed35
6 changed files with 2394 additions and 2 deletions
|
|
@ -309,9 +309,16 @@ the indoor-lighting plumbing.
|
|||
|
||||
## #78 — Outdoor geometry (stabs + terrain mesh) visible inside EnvCells
|
||||
|
||||
**Status:** OPEN — **next-session investigation target (2026-05-25)**
|
||||
**Status:** OPEN — **PROMOTED 2026-06-02 to the full render-pipeline redesign** (this IS the
|
||||
core interior-seal bug; root cause now PROVEN). See
|
||||
[docs/research/2026-06-02-render-pipeline-redesign-handoff.md](research/2026-06-02-render-pipeline-redesign-handoff.md)
|
||||
+ [the redesign plan](superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md). Decisive evidence
|
||||
(2026-06-02 [shell]/[vis] probes): the PVS + cell shells render correctly; 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 render draws the outdoor world then gates it
|
||||
instead of running ONLY `DrawInside` (retail: visibility IS the cull). Fix = redesign Phase R1→R3.
|
||||
**Severity:** HIGH (immediate visual jank; broadened scope per 2026-05-25 PM finding)
|
||||
**Filed:** 2026-05-19 (broadened 2026-05-25)
|
||||
**Filed:** 2026-05-19 (broadened 2026-05-25; promoted to redesign 2026-06-02)
|
||||
**Component:** rendering, visibility
|
||||
|
||||
**Description:** Standing inside Holtburg Inn looking at the floor or
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
# 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.
|
||||
431
docs/research/2026-06-02-render-pipeline-redesign-handoff.md
Normal file
431
docs/research/2026-06-02-render-pipeline-redesign-handoff.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Render Pipeline Redesign — Master Handoff (2026-06-02)
|
||||
|
||||
> **Canonical pickup for the full render-pipeline redesign.** Phase W's interior "seal"
|
||||
> did **not** land. The single final visual verification (user, 2026-06-02) proved the
|
||||
> indoor render is fundamentally broken (issue **#78** — outdoor geometry visible inside
|
||||
> EnvCells — plus transparent walls, missing-floor appearance, entity/scenery bleed, and
|
||||
> no outside-looking-in). This document consolidates ALL research + the decisive live
|
||||
> diagnostic evidence + the retail-faithful target + the mandate.
|
||||
>
|
||||
> **Read order for the next session:** this doc → the 3 research docs (§5–§7 links) →
|
||||
> then **BRAINSTORM** (`superpowers:brainstorming`) the architecture **before writing any
|
||||
> code**. The plan in §10 is the spine; the brainstorm refines it with the user.
|
||||
|
||||
---
|
||||
|
||||
## 0. The mandate (user, 2026-06-02 — NON-NEGOTIABLE)
|
||||
|
||||
Quoting the user's intent directly:
|
||||
|
||||
- **FULLY WORKING** outdoor, indoor, and dungeon rendering. **No flaps, no missing
|
||||
textures, no transparent walls, no ground/terrain texture leaking into the cellar, no
|
||||
bleed-through.** Outside, inside, portal transitions, dungeons, particle clipping —
|
||||
**EVERYTHING.**
|
||||
- **No shortcuts. No bandaids. No quick fixes.** "If you have choices of doing something
|
||||
fast when it might not be the best way or the most architecturally correct way, never
|
||||
take that way." If the correct way is slower, take it anyway.
|
||||
- **If you have to refactor and redesign the whole pipeline, DO THAT.**
|
||||
- **Port from retail** decomp where needed — it is the oracle (`docs/research/named-retail/`).
|
||||
- **If you need more research mid-session, do it** — never guess.
|
||||
- **Start with a brainstorm** before implementing.
|
||||
|
||||
This supersedes the incremental "wire 3 gaps" framing of the Phase W render plan. The
|
||||
render half of Phase W is **reopened as a ground-up, retail-faithful redesign.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Current broken state — the visual gate (2026-06-02, Holtburg cottage)
|
||||
|
||||
The user walked the cottage and reported five distinct failures:
|
||||
|
||||
1. **Outdoor → looking through an open door/window:** interior walls are **transparent**
|
||||
(you see into/through the house). *(= outside-looking-in not implemented; deferred U.5.)*
|
||||
2. **Just inside:** you see **only the outdoor world background + NPCs/particles/doors** —
|
||||
no interior shell sealing you in.
|
||||
3. **Inside → looking out:** sky is visible, but you see **houses and trees through the
|
||||
ground**.
|
||||
4. **On the cellar stairs:** the cellar walls show, but the **floor is grey**, and NPCs /
|
||||
particles / doors show **through the walls above**.
|
||||
5. **In the cellar:** you see the **grey world instead of the floor**.
|
||||
|
||||
Net: **the interior is not a sealed space.** This is issue **#78** ("outdoor geometry
|
||||
visible inside EnvCells") in full, plus entity/scenery bleed and the missing
|
||||
outside-looking-in path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisive live evidence — the root cause is PROVEN (not theorized)
|
||||
|
||||
Captured this session via the in-tree probes (`ACDREAM_PROBE_CELL` / `_VIS` / `_SHELL`),
|
||||
read from the UTF-16 `Tee-Object` logs. **These three facts together pinpoint the cause:**
|
||||
|
||||
### 2.1 The visibility math is CORRECT
|
||||
- `[cell-transit]` shows the player moving cleanly through the cottage cells:
|
||||
`0xA9B40031`(outdoor) → `0170`(vestibule) → `0171`(room) → `0175`(stairs) → `0174`(cellar).
|
||||
Membership works (the Stage-1/2 fix `59f3a13` holds; some doorway flips are the player
|
||||
genuinely crossing the threshold, not a strobe).
|
||||
- `[vis]` shows the right visible set + portal openings, e.g.:
|
||||
- `root=0xA9B40171 cells=2 ids=[0171,0170] outside(polys=1,planes=4)` — from the room,
|
||||
the exit portal to outdoors IS detected (terrain/sky can draw through it).
|
||||
- `root=0xA9B40174 cells=1 ids=[0174] outside(polys=0,planes=0)` — the cellar is
|
||||
correctly **sealed** (no exit portal in view → terrain Skip).
|
||||
- Per-cell clip-plane counts are populated. **`max filter=3`** cells in any single frame.
|
||||
|
||||
**⇒ The PVS (`PortalVisibilityBuilder`) is not the bug. It computes the correct answer.**
|
||||
|
||||
### 2.2 The cell shells RENDER CORRECTLY — opaque, textured, complete
|
||||
The `[shell]` probe (added for #78) emits, per opaque pass, the actual geometry drawn.
|
||||
Distinct patterns observed (frequency × line):
|
||||
|
||||
```
|
||||
205977x filter=1 ... tris=2 [0xA9B40174:gfx=1 tf=1 batch=3 idx=42 tr=0 zh=0] (cellar: 14 tris)
|
||||
1476x filter=3 ... tris=48 [0xA9B40171:...idx=270 tr=0 zh=0] [0173:idx=24] [0172:idx=144] (room+subcells: 90+8+48 tris)
|
||||
130x filter=1 ... tris=38 [0xA9B40171:...idx=270 tr=0 zh=0] (room: 90 tris)
|
||||
125x filter=2 ... [0xA9B40174:idx=42] [0xA9B40175:idx=24] (cellar+stairs)
|
||||
75x filter=1 ... [0xA9B40175:idx=24] (stairs)
|
||||
```
|
||||
- **Zero `NOSNAP`** (no "geometry not prepared" cases). **Zero `zh>0`** (no missing-texture
|
||||
cases). **Zero `tr`** (all opaque). Every visible cell draws its full, textured mesh.
|
||||
- Per the probe's own diagnosis tree: `idx>0 + zh=0 + tr=0` ⇒ **"opaque geometry IS drawn,
|
||||
with valid textures — the fault is DEPTH/OCCLUSION, or the geometry isn't the wall."**
|
||||
|
||||
**⇒ The shells are not the bug either. They draw fine. This was NEVER a shader/texture/
|
||||
missing-mesh problem** (which is what naive guesses would chase).
|
||||
|
||||
### 2.3 So the failure is the SEAL + the GATES
|
||||
Putting 2.1 + 2.2 together with the symptoms: the visible cells draw correctly, but
|
||||
- only **≤3 cells'** shells draw, and **they do not form a closed opaque occluder** over
|
||||
the rest of the world, so the outdoor scene shows through wherever indoor geometry isn't;
|
||||
- the **outdoor terrain + outdoor scenery entities** (the "houses and trees through the
|
||||
ground" = `ParentCellId == null` stabs/scenery) are **not culled or depth-occluded** when
|
||||
you're indoors — they draw and win;
|
||||
- **outside-looking-in** draws no interior at all (no exterior→interior portal path; U.5).
|
||||
|
||||
This is the **three-inconsistent-gates** failure (see §3), the exact problem the project's
|
||||
own memory (`render — one gate (PView)`) flags as the core unsolved indoor-render issue —
|
||||
"the week of 2026-05-25→31 made no indoor-render progress because the pipeline had 3
|
||||
inconsistent gates (terrain/shell/entity)."
|
||||
|
||||
---
|
||||
|
||||
## 3. Root-cause analysis — three inconsistent gates, no closed-occluder seal
|
||||
|
||||
acdream's render loop gates four classes of geometry, from **inconsistent sources**:
|
||||
|
||||
| Geometry | Gate in code | Source of truth | Problem |
|
||||
|---|---|---|---|
|
||||
| **Terrain** | `terrainClipMode` (Skip/Scissor/Planes) | `ClipFrameAssembler` ← `PortalVisibilityBuilder` OutsideView | OK-ish, but only one of several gates |
|
||||
| **Cell shells** | `envCellShellFilter = clipAssembly.CellIdToSlot.Keys` | `PortalVisibilityBuilder` | Draws ≤3 cells; never forms a *closed occluder* |
|
||||
| **Entities** (incl. outdoor scenery, doors, NPCs) | `visibility?.VisibleCellIds` | **`CellVisibility.ComputeVisibilityFromRoot`** — a *different* traversal | Disagrees with the shell/terrain gate; **outdoor scenery (`ParentCellId=null`) not culled indoors** |
|
||||
| **Particles** | *(none)* | — | Issue #104 — no cell clip at all |
|
||||
| **Sky/weather** | `drawSkyThisFrame` (Stage 4) | `ClipFrameAssembler` | The only consistent one — but a top layer |
|
||||
|
||||
**Two distinct architecture defects:**
|
||||
|
||||
1. **Two visibility computations, not one.** `PortalVisibilityBuilder.Build` (the faithful
|
||||
PView BFS) feeds the shell + terrain + sky gates. But the **entity** gate reads
|
||||
`CellVisibility.ComputeVisibilityFromRoot` — an *older, separate* visibility path. They
|
||||
are not guaranteed to agree, and the entity one does not properly cull outdoor scenery
|
||||
when indoors. **Retail has ONE traversal (`PView`) that produces one `cell_draw_list`,
|
||||
and every cell's objects are drawn (and only those) — there is no second gate.**
|
||||
|
||||
2. **No "closed interior occluder" model.** Retail's interior render is *sealed by
|
||||
construction*: it draws the visible cells (whose `drawing_bsp` are closed boxes — floor,
|
||||
walls, ceiling), draws `LScape` only through exit-portal clip regions, does a conditional
|
||||
**Z-only** clear at the doorway, and draws each cell's objects clipped to that cell. The
|
||||
outdoor world is *never drawn behind a wall* because the traversal simply never visits it
|
||||
and the closed cells occlude it. acdream instead draws the outdoor world (terrain +
|
||||
scenery) and *then* a few cell shells on top, relying on depth/gates that don't hold —
|
||||
so the world bleeds through.
|
||||
|
||||
**The fix is not another gate or another clip. It is to make the render obey ONE
|
||||
visibility answer for ALL geometry, with the interior sealed by drawing closed cells +
|
||||
clipping LScape through portals — i.e., port retail's `PView::DrawCells` faithfully.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Reusable vs Redesign (from the live evidence; research doc B §5 deepens this)
|
||||
|
||||
**KEEP (proven correct, build on these):**
|
||||
- **Cell membership** — the transition-owned `find_cell_list` fix (`59f3a13`). `[cell-transit]`
|
||||
confirms correct cell tracking. Do not reopen.
|
||||
- **`PortalVisibilityBuilder`** (the PView BFS) — produces the correct visible set +
|
||||
OutsideView + `OrderedVisibleCells`, with `seen`-HashSet termination (#102 closed). This
|
||||
is the single visibility authority the redesign should route EVERYTHING through.
|
||||
- **The sky/weather NDC-clip insight** (Stage 4, `ce2edad`/`b595cfb`): OutsideView clip
|
||||
planes are screen-space half-spaces → projection-independent, so `sky.vert` clips the
|
||||
dome exactly. Reusable once the base seals; keep the commits.
|
||||
- **`ClipFrame` / `ClipPlaneSet` / `ClipFrameAssembler` / `PortalView`** — the clip-plane
|
||||
machinery + the per-cell NDC regions. Reusable.
|
||||
- **Cell shell rendering** (`EnvCellRenderer`) — draws correct opaque textured geometry.
|
||||
Keep; the bug is *what gets drawn around it*, not the shells.
|
||||
- **The diagnostic apparatus** — `ACDREAM_PROBE_CELL/VIS/SHELL` + the `[shell]` diagnosis
|
||||
tree. Indispensable; see §8.
|
||||
|
||||
**REDESIGN (the broken parts):**
|
||||
- **The gating.** Collapse the two visibility computations into ONE (`PortalVisibilityBuilder`'s
|
||||
visible set), and route terrain + shells + **entities** + particles through it. Delete the
|
||||
`CellVisibility.ComputeVisibilityFromRoot` entity path (or make it the same answer).
|
||||
- **The interior seal** — adopt retail's closed-cell + LScape-through-portal + Z-clear model
|
||||
so the outdoor world is never drawn behind a wall (occlusion by construction, not by gate).
|
||||
- **Outdoor-scenery gating** — `ParentCellId=null` entities (houses, trees, stabs) must be
|
||||
culled / occluded when indoors and not visible through a portal.
|
||||
- **Particle cell-clip** (#104) — particles need a cell and must clip to the visible set.
|
||||
- **Outside-looking-in (U.5)** — the exterior→interior portal path (look into a building
|
||||
from outside and see its sealed interior, not transparent walls).
|
||||
- **Dungeons** (#95) — validate BFS convergence + the no-landscape path on a real dungeon.
|
||||
|
||||
---
|
||||
|
||||
## 5. The retail-faithful target architecture
|
||||
**→ Full reference: `docs/research/2026-06-02-retail-render-pipeline-full-reference.md`** (research doc A —
|
||||
verified against the named-retail pseudo-C + `acclient.h` this session).
|
||||
|
||||
### 5.1 The ten load-bearing retail facts (THE architecture to port)
|
||||
1. **One cell graph, one membership answer, render obeys it.** Physics tracks `curr_cell`
|
||||
through the sweep; the camera tracks `viewer_cell`; both resolve via the same
|
||||
`CObjCell::GetVisible`.
|
||||
2. **The top-level decision is BINARY** (`RenderNormalMode @ 0x453aa0`): viewer in an
|
||||
outdoor landcell → `LScape::draw` (full landscape). Viewer in an EnvCell → **`DrawInside`
|
||||
only.** It is **NOT** "draw outdoors then draw cells on top." **When inside, the full
|
||||
outdoor scene is never drawn.** ← *This single fact is the inversion of acdream's bug.*
|
||||
3. **`DrawInside` = one PView portal flood** (`ConstructView → DrawCells`) producing the
|
||||
`cell_draw_list` + per-cell clip regions + **one** `outside_view`.
|
||||
4. **Exit portals (`other_cell_id == 0xffffffff`) pull the landscape INTO the indoor
|
||||
traversal** — `ClipPortals` (pc:433662) copies the doorway clip region into `outside_view`.
|
||||
The outdoors is seen *only* through that clipped region.
|
||||
5. **The seal sequence in `DrawCells`** (when `outside_view.view_count > 0`):
|
||||
`LScape::draw` **clipped to the doorway** → conditional **Z-ONLY** `Clear(4,…)` (NOT
|
||||
color) → exit-portal stencil → `DrawEnvCell` (closed geometry) → per-cell objects.
|
||||
6. **No blue clear-color hole by construction** — the only clear is depth-only + conditional;
|
||||
the doorway shows real terrain because LScape drew there first.
|
||||
7. **Ceilings/walls sealed by dat geometry** — `drawing_bsp` is a closed box; portal holes
|
||||
are stencil-masked, never filled; there is no "cap the ceiling" step.
|
||||
8. **VISIBILITY *IS* THE CULL.** Only `cell_draw_list` cells render, and only **their**
|
||||
`object_list` objects (drawn with `PortalList` set). There is **no second "draw all
|
||||
entities then gate them" pass.** → no wall/object/particle bleed, by construction.
|
||||
9. **Outside-looking-in is the mirror image** — `DrawPortal @ 0x5a5ab0` runs
|
||||
`ConstructView(CBldPortal)` + `DrawCells` to render the interior through the door's clip,
|
||||
the **same** machinery.
|
||||
10. **Dungeons are emergent** — all-EnvCell, `seen_outside == 0`, no exit portals →
|
||||
`outside_view` stays 0 → `LScape::draw` is never called → no terrain/sky, automatically.
|
||||
|
||||
Plus: BFS convergence is **watermark-bounded** by per-cell `portal_view_type.update_count`
|
||||
(the retail replacement for a fixed reprocess cap — fixes #102 *and* the dungeon PVS blowup
|
||||
#95); `find_visible_child_cell @ 0x52dc50` resolves child cells via the portal/stab graph +
|
||||
BSP `point_in_cell` (never AABB); each cell carries **three** BSPs (`drawing_bsp` render /
|
||||
`physics_bsp` collision / `cell_bsp` containment).
|
||||
|
||||
### 5.2 The porting checklist (doc A §7 — the spine of the plan)
|
||||
- **CL-A Membership foundation** (physics owns the cell): swept-cell return, exit-portal
|
||||
crossing, interior-wins + prune, commit-on-difference. *(Largely DONE — `59f3a13`.)*
|
||||
- **CL-B Render-root unification:** single decision, root at physics `CurrCell`, child-cell
|
||||
via graph, landscape keep/release on `seen_outside`, `grab_visible_cells`, pre-position terrain.
|
||||
- **CL-C PView traversal:** BFS, InitCell, ClipPortals (both branches), AddViewToPortals/AddToCell,
|
||||
`update_count` watermark, GetClip. *(`PortalVisibilityBuilder` already covers much of this.)*
|
||||
- **CL-D Seal mechanics in DrawCells:** LScape-through-door FIRST, conditional Z-only clear,
|
||||
exit-portal stencil, closed-geometry draw, **per-cell clipped objects**, self-contained GL state.
|
||||
- **CL-E Outside-looking-in:** DrawPortal, ConstructView(CBldPortal) recursion, separate outdoor pview.
|
||||
- **CL-F Entity/particle cell clipping:** graph placement, draw only visible cells, portal-clip straddlers.
|
||||
- **CL-G Conformance/acceptance:** cottage sealed + sky-through-door, dungeon sealed + no terrain,
|
||||
outside-looking-in, headless asserts.
|
||||
|
||||
**The redesign verdict:** acdream must stop drawing the outdoor world and then gating it.
|
||||
When the viewer is in an EnvCell, it must run **one `DrawInside` flood** that draws only the
|
||||
visible cells + their objects, pulling LScape in only through clipped exit portals. The cull
|
||||
*is* the visibility. Everything in §3 ("three inconsistent gates") collapses into this.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reference cross-check
|
||||
**→ Full cross-check: `docs/research/2026-06-02-render-reference-crosscheck.md`** (research doc C).
|
||||
|
||||
**WorldBuilder is the wrong model and must NOT be re-adopted for the seal** (10-point verdict):
|
||||
WB has a hard `isInside` branch switching two completely different render paths (retail has
|
||||
one); WB's "seal" is a per-**building** GPU stencil from portal-polygon rasters (flat,
|
||||
building-granularity) whereas retail's is a CPU-derived `OutsideView` clip polygon from the
|
||||
recursive per-**portal** BFS; **WB's flap is inherent** — the `isInside` branch flips at the
|
||||
doorway and the two stencil setups tear on that frame; WB clips one stencil per building
|
||||
(per-portal precision is impossible); WB works for a static dat-viewer but is
|
||||
**architecturally wrong for a live client that crosses thresholds**; grafting onto WB's
|
||||
two-pipe is **not recoverable** — the retail PView port is the correct fix.
|
||||
|
||||
- **ADOPT:** retail PView BFS (already in acdream as `PortalVisibilityBuilder`), the
|
||||
`SeenOutside` terrain gate, the `OtherCellId==0xFFFF` exit-portal sentinel, the WB **mesh
|
||||
pipeline** (keep — it's just GL plumbing), `EnvCellRenderer.PrepareRenderBatches(filter)`,
|
||||
entity outdoor-slot routing via `WbDrawDispatcher.ResolveEntitySlot`.
|
||||
- **AVOID (do not reintroduce):** WB `RenderInsideOut`/`RenderOutsideIn` stencil two-pipe,
|
||||
`BuildingPortalGPU`/`RenderBuildingStencilMask`, any `isInside` / `cameraInsideBuilding`
|
||||
gate, ACViewer brute-force all-cells draw, any AABB grace-frame fallback for the visibility root.
|
||||
|
||||
---
|
||||
|
||||
## 7. Current pipeline inventory + failure map
|
||||
**→ Full inventory: `docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md`** (research doc B).
|
||||
|
||||
### 7.1 The concrete bugs (file:line — these are the *mechanisms* behind §1's symptoms)
|
||||
- **🔴 THE outdoor-scenery bleed ("houses/trees through the ground"):**
|
||||
`WbDrawDispatcher.EntityPassesVisibleCellGate` hits an **unconditional `return true` at
|
||||
`WbDrawDispatcher.cs:1756`** for entities with `ParentCellId == null` (outdoor scenery:
|
||||
houses, trees, landblock stabs). The `IsShellScopedSet` anchor-cull branch that *should*
|
||||
gate them is **dead code** (always false since U.1 deleted shell sets). ⇒ **outdoor stabs
|
||||
always draw, even inside a sealed cellar.** This is the #1 visible bleed.
|
||||
- **🔴 Two visibility computations:** the entity gate is fed by `CellVisibility.VisibleCellIds`
|
||||
(a *parallel, separate* BFS), while shells + terrain are fed by `PortalVisibilityBuilder`.
|
||||
They are not the same answer. Retail has ONE (§5.1 fact 8).
|
||||
- **🟠 Terrain `Skip` over-aggression:** `TerrainClipMode.Skip` fires whenever the exit portal
|
||||
is not in the current view — correct for a true dungeon, but it removes the terrain floor for
|
||||
a `seen_outside=true` cottage when the player faces away from the door. (In the retail model
|
||||
this is moot: terrain is only ever drawn *through* an exit-portal clip, never as a floor under
|
||||
the interior.)
|
||||
- **🟠 Particles:** no cell gate at all (#104).
|
||||
- **🟠 `CullMode.Landblock → None` double-sided stopgap** (`EnvCellRenderer.cs:~1210`) — cells
|
||||
draw double-sided to dodge a winding issue; a redesign should resolve the actual winding.
|
||||
- **🟠 Outside-looking-in (U.5):** no outdoor-root shell pass → transparent walls through a door.
|
||||
|
||||
### 7.2 The gate table (the inconsistency, from B)
|
||||
| Geometry | Gate | Fed by | Verdict |
|
||||
|---|---|---|---|
|
||||
| Terrain | `TerrainClipMode` | `PortalVisibilityBuilder` | over-aggressive Skip; wrong *model* (floor vs portal-clip) |
|
||||
| Cell shells | `envCellShellFilter` | `PortalVisibilityBuilder` | correct geometry, but never a closed occluder |
|
||||
| **Entities** | `CellVisibility.VisibleCellIds` | **parallel BFS** | wrong source + `null`-ParentCell bypass = bleed |
|
||||
| Particles | *(none)* | — | #104 |
|
||||
| Sky/weather | `drawSkyThisFrame` | `PortalVisibilityBuilder` | the only consistent one (Stage 4) |
|
||||
|
||||
### 7.3 KEEP (from B): `PortalVisibilityBuilder`, `ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`,
|
||||
Stage-4 sky/weather clip + doorway Z-clear, `EnvCellRenderer` geometry/MDI path,
|
||||
`TerrainModernRenderer`, `ResolveEntitySlot` (correct for indoor statics), all probes.
|
||||
### 7.4 REDESIGN/FIX (from B): (a) delete the `ParentCellId==null → return true` bypass + gate
|
||||
outdoor stabs; (b) feed entity dispatch from `pvFrame.OrderedVisibleCells`, not the parallel
|
||||
`CellVisibility` BFS; (c) fix terrain `Skip`/model; (d) particle cell-gate (#104); (e) resolve
|
||||
the double-sided stopgap; (f) implement U.5 outside-looking-in.
|
||||
|
||||
---
|
||||
|
||||
## 8. Diagnostic apparatus (use it — evidence-first, always)
|
||||
|
||||
- **Launch** (per CLAUDE.md "Running the client"): set `ACDREAM_LIVE=1`, host/port/user/pass,
|
||||
`ACDREAM_DAT_DIR`, then `dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build
|
||||
-c Debug 2>&1 | Tee-Object -FilePath launch.log`, in the background. The character `+Acdream`
|
||||
spawns at the Holtburg cottage (`0xA9B40031`, outdoor); walk in to reach `0170→0171→0175→0174`.
|
||||
- **Probes** (env vars; the log is **UTF-16** — read with PowerShell `Select-String` or the
|
||||
ripgrep Grep tool, **NOT** GNU grep):
|
||||
- `ACDREAM_PROBE_CELL=1` → `[cell-transit]` (membership; low volume, decisive).
|
||||
- `ACDREAM_PROBE_VIS=1` → `[vis]` (the PVS frame: root cell, visible cells, OutsideView
|
||||
poly/plane counts, per-cell plane counts).
|
||||
- `ACDREAM_PROBE_SHELL=1` → `[shell]` (per opaque pass: `filter`, `drawCalls`, `inst`,
|
||||
`tris`, and per-cell `gfx tf batch idx tr zh`). **Diagnosis tree:** `NOSNAP`/`gfx=0` ⇒
|
||||
no geometry prepared; `idx>0 + zh>0` ⇒ prepared but missing texture (invisible);
|
||||
`idx>0 + zh=0 + tr=0` ⇒ opaque drawn, fault is depth/occlusion or wrong geometry.
|
||||
⚠ Unthrottled — fires every frame (183K lines in a short walk). Filter in PowerShell.
|
||||
- **Screenshots:** Windows blocks foreground-steal from a background process; capturing the
|
||||
AcDream window reliably needs the user to foreground it (or a `PrintWindow` approach that
|
||||
is unreliable for GL surfaces). Prefer the probes + the user's description for render bugs.
|
||||
- **`tools/A8CellAudit`** + the committed doorway fixture remain for offline membership checks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Do-NOT-repeat / settled facts
|
||||
|
||||
- **Do not chase shaders/textures/missing-meshes for #78.** The `[shell]` evidence proves
|
||||
shells draw opaque + textured + complete. The bug is the seal + the gating, not the shells.
|
||||
- **Do not add another gate, scissor, or clip layer on top.** That is the failed incremental
|
||||
approach. The redesign collapses to ONE visibility answer + a closed-occluder seal.
|
||||
- **Do not reintroduce WorldBuilder's `RenderInsideOut` stencil two-pipe** (abandoned
|
||||
2026-05-30; reference-divergent from retail). Port retail's recursive PView.
|
||||
- **Do not reopen the membership fix** (`59f3a13`) — `[cell-transit]` confirms it works.
|
||||
- **My mistake this session (learn from it):** I trusted the prior handoff's "render infra
|
||||
already exists, just wire 3 gaps" framing and built the Stage-4 sky-through-door clip on
|
||||
top **without verifying the base seal end-to-end** (I can't see the screen; I leaned on the
|
||||
test suite + a smoke-launch). The visual gate correctly caught that the premise was wrong.
|
||||
**Lesson:** for a render seal, get the user's eyes (or the `[shell]`/`[vis]` evidence) on
|
||||
the ACTUAL sealed result EARLY, before building layers on top. A green test suite proves
|
||||
nothing about whether the interior looks sealed.
|
||||
- **Stage-4 commits are kept** (`ce2edad`, `a8b831c`, `872dd34`, `21609a7`, `4bc99fc`,
|
||||
`b595cfb`) — the sky NDC-clip + the green-tests triage are real and reusable; they are a
|
||||
top layer that becomes correct once the base seals. The 5 remaining Core test failures are
|
||||
pre-existing physics/collision bugs (2 step-up gaps incl. a regression from A6.P4's door
|
||||
fix; 3 door-collision apparatus / A6.P5), none Phase-W's, flagged not fixed.
|
||||
|
||||
---
|
||||
|
||||
## 10. The redesign plan
|
||||
**→ `docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md`** (the huge staged plan).
|
||||
Stages: **R0** brainstorm/lock → **R1** one-visibility-authority (kill the outdoor-scenery bleed)
|
||||
→ **R2** indoor = `DrawInside`-only (stop drawing the outdoor world indoors) → **R3** the seal
|
||||
(`DrawCells` faithful port: LScape-through-door → Z-only clear → closed cells) → **R4** per-cell
|
||||
object + particle clip → **R5** outside-looking-in (U.5 / `DrawPortal`) → **R6** dungeons → **R7**
|
||||
polish + conformance. Each ends at a **user visual gate**. The core inversion: **when inside, run
|
||||
ONLY the PView flood — visibility IS the cull; the outdoor world enters only through clipped exit
|
||||
portals.**
|
||||
|
||||
---
|
||||
|
||||
## 11. Pickup prompt (copy-paste for the next session)
|
||||
|
||||
```
|
||||
RENDER PIPELINE REDESIGN — full retail-faithful rewrite of the world render (outdoor + indoor +
|
||||
dungeon + portals + particles). Continue on branch claude/thirsty-goldberg-51bb9b (do NOT
|
||||
branch/worktree; do NOT push without asking; NEVER git stash/gc — a shared stash is under
|
||||
investigation). Use PowerShell on Windows; the launch logs are UTF-16 (read with Select-String /
|
||||
the ripgrep Grep tool, NOT GNU grep).
|
||||
|
||||
MANDATE (user, non-negotiable): FULLY WORKING outdoor + indoor + dungeon rendering — no flaps, no
|
||||
missing textures, no transparent walls, no terrain leaking into cellars, no entity/particle
|
||||
bleed-through, outside-looking-in works. NO shortcuts, NO bandaids, NO quick fixes; take the
|
||||
architecturally-correct path even if slower; redesign the whole pipeline if needed; PORT FROM
|
||||
RETAIL; do more research mid-session rather than guess; START WITH A BRAINSTORM.
|
||||
|
||||
READ FIRST (in order):
|
||||
1. docs/research/2026-06-02-render-pipeline-redesign-handoff.md (THIS handoff: §2 proven evidence,
|
||||
§3 root cause = three inconsistent gates + no closed-occluder seal, §5 retail target + porting
|
||||
checklist CL-A..G, §9 do-not-repeat).
|
||||
2. docs/research/2026-06-02-retail-render-pipeline-full-reference.md (the retail PView pipeline to
|
||||
port — the seal mechanics in DrawCells).
|
||||
3. docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md (the concrete bugs:
|
||||
the WbDrawDispatcher.cs:1756 ParentCellId==null bypass = the outdoor bleed; the parallel
|
||||
visibility BFS; terrain Skip model).
|
||||
4. docs/research/2026-06-02-render-reference-crosscheck.md (why WB's two-pipe stencil is the wrong
|
||||
model — do NOT reintroduce it).
|
||||
5. THE PLAN: docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md (stages R0..R7).
|
||||
|
||||
PROVEN ROOT CAUSE (don't re-investigate): the PVS computes correctly and the cell shells render
|
||||
correctly (opaque, textured, complete — [shell] probe). The failure is the SEAL + the GATING:
|
||||
acdream draws the outdoor world (terrain + scenery ENTITIES that aren't culled) and then a few cell
|
||||
shells on top, relying on three inconsistent gates. Retail, when inside, runs ONLY DrawInside (one
|
||||
PView flood) — visibility IS the cull, and the landscape enters only through clipped exit portals.
|
||||
That inversion is the redesign.
|
||||
|
||||
DO NEXT:
|
||||
1. Phase R0 — BRAINSTORM the §1 architecture (handoff §5 / plan §1) with the user; resolve the open
|
||||
questions (plan §3); write the per-phase design spec. NO code until the design is locked.
|
||||
2. Then execute R1→R7 (plan §2), each retail-anchored, each ending at a user visual gate. R1 (one
|
||||
visibility authority + kill the WbDrawDispatcher.cs:1756 bypass) kills the headline bleed first.
|
||||
|
||||
EVIDENCE-FIRST: launch per CLAUDE.md "Running the client" with ACDREAM_PROBE_CELL/VIS/SHELL=1; walk
|
||||
the Holtburg cottage (spawn 0xA9B40031 outdoor → 0170 vestibule → 0171 room → 0175 stairs → 0174
|
||||
cellar). The [shell] diagnosis tree + [vis] OutsideView counts are decisive. GET THE USER'S EYES on
|
||||
the actual sealed result at every visual gate — never declare a seal off the test suite (that was
|
||||
the mistake that produced this handoff).
|
||||
|
||||
KEEP (don't rewrite): PortalVisibilityBuilder (the PVS), ClipFrame/ClipFrameAssembler/ClipPlaneSet,
|
||||
EnvCellRenderer mesh path, TerrainModernRenderer, the WB mesh pipeline, the membership fix (59f3a13),
|
||||
the Stage-4 sky NDC-clip + doorway Z-clear (ce2edad/b595cfb). The work is RESTRUCTURING THE
|
||||
ORCHESTRATION + the entity/particle draw to be per-cell, not a from-scratch rewrite.
|
||||
|
||||
TEST STATE: full suite green except 5 pre-existing Core failures (2 step-up gaps incl. an A6.P4 door
|
||||
regression; 3 door-collision apparatus / A6.P5) — none render-related, flagged in the handoff, do not
|
||||
chase as part of this work.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Session ledger (2026-06-02, render half)
|
||||
- Stage 4 (sky/weather portal clip + Z-clear + green-tests triage) shipped: `ce2edad`, `a8b831c`,
|
||||
`872dd34`, `21609a7`, `4bc99fc`, `b595cfb`. Real + reusable, but a top layer — the base seal was
|
||||
never working (#78). Visual gate FAILED → this redesign handoff.
|
||||
- Research this session: retail pipeline reference (doc A), acdream inventory+failures (doc B),
|
||||
reference cross-check (doc C), this master handoff, the redesign plan.
|
||||
- Issues to file/track: #78 (the core seal — now the redesign target), #95 (dungeon BFS), #104
|
||||
(particle cell-clip), the `WbDrawDispatcher.cs:1756` outdoor-scenery bypass, the
|
||||
`CullMode.Landblock→None` double-sided stopgap, U.5 outside-looking-in.
|
||||
449
docs/research/2026-06-02-render-reference-crosscheck.md
Normal file
449
docs/research/2026-06-02-render-reference-crosscheck.md
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
# 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 880–1008
|
||||
|
||||
#### 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 73–239)
|
||||
- **Outside (`RenderOutsideIn` or fallback)** — `VisibilityManager.RenderOutsideIn` (lines 241–358)
|
||||
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:73–239)
|
||||
|
||||
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 157–229): 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 241–358): mirrors Step 1–2 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 47–71): 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:83–101`** `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:70–87`): 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 206–231): 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 880–1008 — 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
|
||||
|
|
@ -0,0 +1,810 @@
|
|||
# Retail AC — the FULL render pipeline (outdoor + indoor + dungeon + seal), implementable reference
|
||||
|
||||
> **Purpose.** The single, exhaustive, port-ready reference for how the **retail** AC client
|
||||
> (Sept 2013 EoR build, PDB-named) renders the world: outdoor landscape, building interiors,
|
||||
> dungeons, portals, the "seal" (drawing the outside through a doorway with no blue hole,
|
||||
> capped ceilings, no wall-bleed), and object/particle clipping. This is the foundation of the
|
||||
> Phase W / Phase U unified-render redesign. **The code is modern; the behavior is retail —
|
||||
> port this faithfully, with no shortcuts.**
|
||||
>
|
||||
> **Sources.** Consolidates the four 2026-06-02 decomp studies
|
||||
> (`-opus48-a`, `-opus48-b`, `-sonnet46`, `-codex`) and the approved design spec
|
||||
> (`docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md`),
|
||||
> then **deepens the DRAW/seal pipeline** with the actual pseudo-C read this session from
|
||||
> `docs/research/named-retail/acclient_2013_pseudo_c.txt` and verbatim structs from
|
||||
> `docs/research/named-retail/acclient.h`. Citations are `Class::method @ 0xADDR (pc:LINE)` for
|
||||
> decomp and `acclient.h:LINE` for structs. Membership/transition material (Section A) is
|
||||
> deliberately summarized — it is fully covered in the four studies and is *not* the render
|
||||
> redesign's spine; the render pipeline (Sections 2–7) is the new, deep material.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary — the ONE retail pipeline (≤12 bullets)
|
||||
|
||||
1. **One cell graph, one membership answer, render obeys it.** Physics tracks the player's cell
|
||||
as `SPHEREPATH::curr_cell` carried *through* the collision sweep; the camera tracks its own
|
||||
`viewer_cell` via a second (spring-arm) transition. Both resolve through the **same**
|
||||
`CObjCell::GetVisible(objcell_id)` graph. There is **no** separate render cell system, **no**
|
||||
static "re-derive the cell from XYZ", and **no** "underground" boolean.
|
||||
|
||||
2. **Top-level decision is binary, per frame** (`SmartBox::RenderNormalMode @ 0x00453aa0`,
|
||||
pc:92635). `is_player_outside`/viewer-cell test → **outdoor** path = `LScape::draw` (full
|
||||
terrain+sky+buildings); **EnvCell** path = `RenderDevice::DrawInside(viewer_cell)`. It is
|
||||
*not* "landscape then inside" at the top level — when inside, **only** `DrawInside` runs.
|
||||
|
||||
3. **`DrawInside` (`PView`) is one portal-flood traversal** (`PView::DrawInside @ 0x005a5860` →
|
||||
`ConstructView @ 0x005a57b0` → `DrawCells @ 0x005a4840`). It produces an ordered
|
||||
`cell_draw_list` (visible EnvCells) plus per-cell screen **clip regions** (`portal_view`) and
|
||||
one accumulated `outside_view` (the outdoors seen through exit portals).
|
||||
|
||||
4. **The landscape is pulled INTO the indoor traversal through exit portals.** A portal with
|
||||
`other_cell_id == 0xffffffff` is an *exit portal*. `ClipPortals` (`@ 0x005a5520`, pc:433662)
|
||||
copies its screen clip region into `this->outside_view` (gated by `draw_landscape`). This is
|
||||
the seam that makes the outside visible through a doorway.
|
||||
|
||||
5. **The seal sequence in `DrawCells`** (pc:432715+), when `outside_view.view_count > 0`:
|
||||
(a) `LScape::draw(lscape)` with `Render::PortalList = this` → terrain+sky+rain+exterior,
|
||||
**clipped to the doorway**; (b) a **conditional Z-only clear** `Clear(4, …)` (flag 4 = Z
|
||||
buffer, NOT color; conditional on `portalsDrawnCount`); (c) per-cell exit-portal **stencil**
|
||||
via `DrawPortalPolyInternal`; (d) per-cell **`DrawEnvCell`** (the closed interior geometry);
|
||||
(e) per-cell **`DrawObjCellForDummies`** with `PortalList` set to that cell's view (objects).
|
||||
|
||||
6. **There is no blue clear-color hole, by construction.** The only clear in the indoor path is
|
||||
`Clear(4, …)` = depth only, and even that is conditional. The doorway shows real terrain/sky
|
||||
because the landscape was drawn first, clipped to the exit-portal region.
|
||||
|
||||
7. **Ceilings/walls/floors are sealed by the dat geometry.** Each EnvCell's `drawing_bsp` is a
|
||||
*closed* box (floor + walls + ceiling) with holes only where `CCellPortal`s exist. There is no
|
||||
"cap the ceiling" step; `DrawCells` draws each visible cell's `drawing_bsp`. Portal openings
|
||||
are masked, not filled.
|
||||
|
||||
8. **Visibility IS the cull.** Only cells reached by the portal BFS are in `cell_draw_list`; only
|
||||
their objects (`object_list`, drawn per-cell with `PortalList` set) are drawn. An object/
|
||||
particle in a non-visible cell is never iterated → no wall bleed-through. Membership comes from
|
||||
the *same* physics `enter_cell`/`leave_cell` graph.
|
||||
|
||||
9. **Outside-looking-in is the mirror image, same machinery.** While drawing the landscape, when
|
||||
the camera can see a building's door, `PView::DrawPortal @ 0x005a5ab0` runs
|
||||
`ConstructView(CBldPortal, polygon, …) @ 0x005a59a0` — a viewer-vs-portal-plane side test, a
|
||||
`GetClip`, then it **recurses `ConstructView(interior_cell, other_portal_id)`** and `DrawCells`
|
||||
the interior through the door's clip region. Outside↔inside is one recursive portal-clipped
|
||||
traversal over the shared graph.
|
||||
|
||||
10. **Dungeons are emergent, not flagged.** A dungeon is an all-EnvCell landblock (terrain heights
|
||||
0, ≥1 EnvCell, no buildings) where every cell has `seen_outside == 0` and no exit portals. So
|
||||
`outside_view.view_count` stays 0, `LScape::draw` is never reached, and there is no terrain/
|
||||
sky — automatically. Same `DrawInside` path as a cottage interior.
|
||||
|
||||
11. **Terrain/sky/landscape state keys off `seen_outside`.** `CellManager::ChangePosition @
|
||||
0x004559b0` keeps the landscape loaded + sunlight/outdoor-ambient live iff the current cell is
|
||||
a landcell **or** `CObjCell::seen_outside` is set; otherwise it `release_all`s the landscape
|
||||
and uses flat indoor ambient. `CEnvCell::grab_visible_cells @ 0x0052e220` loads the landscape
|
||||
iff `seen_outside`.
|
||||
|
||||
12. **BFS convergence is watermark-bounded, not capped.** `ConstructView` uses a per-cell
|
||||
`portal_view_type.update_count` watermark + a `cell_todo_list` worklist; a cell can be
|
||||
re-processed only for genuinely new view slices (`update_count → view_count`). This is the
|
||||
retail replacement for acdream's fixed `MaxReprocessPerCell` cap (issue #102) and the right
|
||||
fix for dungeon PVS blowup (#95).
|
||||
|
||||
---
|
||||
|
||||
## 2. Render-decision tree — outdoor / indoor / both
|
||||
|
||||
### 2.1 The single per-frame gate: `SmartBox::RenderNormalMode @ 0x00453aa0` (pc:92635)
|
||||
|
||||
Verbatim control flow (pc:92642-92684), with the decompiler-garbled predicate named:
|
||||
|
||||
```c
|
||||
if (render_device->m_bOpenScene != 0) {
|
||||
// edi_2 == SmartBox::is_player_outside(this)-equivalent: viewer is in an OUTDOOR landcell
|
||||
// ebx_1 == "outside is relevant" = (viewer outside) || (viewer_cell->seen_outside != 0)
|
||||
if (edi_2 != 0 || this->viewer_cell->seen_outside != 0) ebx_1 = 1; else ebx_1 = 0;
|
||||
|
||||
// FOV / view-distance setup (omitted) ...
|
||||
|
||||
if (edi_2 == 0) { // ── VIEWER IS INSIDE AN ENVCELL ──
|
||||
if (ebx_1 != 0) { // cell can see outside:
|
||||
uint eax = Position::get_outside_cell_id(&this->viewer); // pc:92669
|
||||
LScape::update_viewpoint(this->lscape, eax); // pre-position terrain
|
||||
}
|
||||
Render::update_viewpoint(&this->viewer);
|
||||
render_device->vtable->DrawInside(render_device, this->viewer_cell); // pc:92675 PView
|
||||
} else { // ── VIEWER IS OUTSIDE (landcell) ──
|
||||
LScape::update_viewpoint(this->lscape, this->viewer.objcell_id); // pc:92679
|
||||
Render::update_viewpoint(&this->viewer);
|
||||
Render::set_default_view();
|
||||
Render::useSunlightSet(1);
|
||||
LScape::draw(this->lscape); // pc:92683 full outdoor render
|
||||
}
|
||||
}
|
||||
D3DPolyRender::FlushAlphaList(0);
|
||||
// ... target bounding-box callback + rendering callback ...
|
||||
```
|
||||
|
||||
**Key facts:**
|
||||
|
||||
- **Exactly two branches**, chosen by `edi_2` = "is the viewer cell an outdoor landcell?" When
|
||||
outside, the *only* draw is `LScape::draw`. When inside, the *only* draw is `DrawInside` — the
|
||||
landscape, if shown, is drawn **inside** `DrawInside`/`DrawCells` (Section 4), *not* here.
|
||||
- **`ebx_1` (= outside-relevant)** controls whether the landscape's *viewpoint* is updated before
|
||||
the indoor draw, so that if a doorway shows terrain it is centered on the right landblock. The
|
||||
*actual* terrain draw-through-door decision is `outside_view.view_count > 0` inside `DrawCells`.
|
||||
- The predicate maps to **`SmartBox::is_player_outside @ 0x00451e80` (pc:90996)**:
|
||||
```c
|
||||
int is_player_outside(SmartBox* this) {
|
||||
if (this->player == 0) return 0;
|
||||
uint lowWord = (uint16_t)this->player->m_position.objcell_id; // pc:91004
|
||||
return lowWord < 0x100; // (decompiler garbles the compare; semantics: outdoor iff < 0x100)
|
||||
}
|
||||
```
|
||||
Outdoor landcell ids are `0x0001..0x0040`; EnvCell ids are `>= 0x0100`. This low-word test is
|
||||
the *type* discriminator used everywhere (`find_cell_list` pc:308753; `GetVisible` pc:308209).
|
||||
|
||||
### 2.2 The decision matrix (port this exactly)
|
||||
|
||||
| Viewer cell | `seen_outside` | Top-level draw | Terrain/sky? |
|
||||
|---|---|---|---|
|
||||
| Outdoor landcell (`id&0xFFFF < 0x100`) | n/a | `LScape::draw` (full) | Yes (always). Buildings recurse via `CBldPortal`/`DrawPortal`. |
|
||||
| EnvCell (cottage/inn interior) | **1** | `DrawInside` | **Only through exit portals** (`outside_view>0` → `LScape::draw` clipped to doorway). |
|
||||
| EnvCell (dungeon / sealed room) | **0** | `DrawInside` | **No** (no exit portal reachable → `outside_view==0` → `LScape::draw` never called). |
|
||||
|
||||
**There is no "both at once" at the top level.** "Both" happens *only inside* the indoor path:
|
||||
`DrawCells` draws the landscape (through exit portals) **then** the interior cells, in one pass.
|
||||
|
||||
### 2.3 `RenderDeviceD3D::DrawInside @ 0x0059f0d0` (pc:427843) — thin forwarder
|
||||
|
||||
```c
|
||||
return PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2); // pc:427847
|
||||
```
|
||||
The render device owns two persistent `PView` instances: `indoor_pview` (rooted at the viewer
|
||||
cell) and `outdoor_pview` (used by `DrawPortal` for outside-looking-in, Section 6.2).
|
||||
|
||||
---
|
||||
|
||||
## 3. PView traversal — step by step
|
||||
|
||||
`PView` ("portal view") is the heart of indoor rendering: a breadth-first (worklist) portal flood
|
||||
over the shared `CEnvCell` graph that yields one ordered visible-cell list + per-cell clip regions.
|
||||
|
||||
### 3.1 Entry: `PView::DrawInside @ 0x005a5860` (pc:433793)
|
||||
|
||||
```c
|
||||
Render::object_scale = 1; /* object_scale_vec = (1,1,1) */
|
||||
CEnvCell::curr_view_push(viewer_cell); // pc:433800 push a view slot
|
||||
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // pc:433801 seed PVS from stab list
|
||||
Frame::cache(&identityFrame); Render::positionPush(3, &identityFrame);
|
||||
Render::copy_view(viewer_cell->portal_view[num_view-1], nullptr, 4); // pc:433814 seed root view = full screen
|
||||
ConstructView(this, viewer_cell, 0xffff); // pc:433817 build visible set
|
||||
PView::DrawCells(this, …); // pc:433819 draw it
|
||||
Render::framePop();
|
||||
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
|
||||
viewer_cell->num_view -= 1; // pop the view slot
|
||||
```
|
||||
`add_views`/`remove_views` (`@ 0x005a5210`) push/pop a per-cell `portal_view_type` slot for every
|
||||
cell in the root's stab list, so the BFS has somewhere to accumulate clip regions for those cells.
|
||||
|
||||
### 3.2 The BFS: `PView::ConstructView(CEnvCell*, ushort) @ 0x005a57b0` (pc:433750)
|
||||
|
||||
Verbatim:
|
||||
```c
|
||||
this->outside_view.view_count = 0; // reset the outdoor-through-portal accumulator
|
||||
PView::master_timestamp += 1; // new traversal stamp (cycle guard)
|
||||
this->cell_todo_num = 0; this->cell_draw_num = 0;
|
||||
PView::InitCell(this, root, portalId); // compute root's per-portal in/seen flags + clip
|
||||
PView::InsCellTodoList(this, root, 0); // push root onto the worklist
|
||||
while (true) {
|
||||
if (cell_todo_num <= 0) return;
|
||||
cell = cell_todo_list[--cell_todo_num]->cell; // POP (LIFO: index num-1)
|
||||
if (cell == 0) return;
|
||||
// append to the visible draw list (grow by 0x1e if needed):
|
||||
cell_draw_list[cell_draw_num++] = cell; // pc:433783
|
||||
cell->portal_view[cell->num_view - 1]->cell_view_done = 1; // pc:433784
|
||||
if (PView::ClipPortals(this, cell, 0) != 0) // pc:433786 clip this cell's portals
|
||||
PView::AddViewToPortals(this, cell); // pc:433787 enqueue visible neighbours
|
||||
}
|
||||
```
|
||||
**Outputs:** `cell_draw_list[0..cell_draw_num]` (visible EnvCells in pop order), each cell's
|
||||
`portal_view[...]->view` (accumulated screen clip polys), and `this->outside_view` (exit-portal
|
||||
clip regions). Note it is technically a LIFO worklist (a stack), but converges to the same visible
|
||||
set; "BFS" is used loosely in the studies.
|
||||
|
||||
### 3.3 `PView::InitCell @ 0x005a4b70` (pc:432896) — per-portal sidedness + clip init
|
||||
|
||||
For a cell's current view slot (`portal_view[num_view-1]`):
|
||||
- Early-out if `esi[0xe] == 0` (no active view — `update_count`/`view_count` empty). pc:432903.
|
||||
- `Render::positionPush(3, &cell->pos)` to enter cell-local space.
|
||||
- For each portal: compute the portal polygon's plane vs the **viewer viewpoint**
|
||||
(`Render::FrameCurrent->viewer.viewpoint`), set per-portal `seen`/`inflag`
|
||||
(`portal_info { int seen; int inflag; }`, acclient.h:32459) according to `portal_side` — i.e.
|
||||
which portals face the viewer and can be seen through. Compute `max_indist` and set
|
||||
`update_count = view_count` (the watermark — see §5.4). This is "which of my portals are live
|
||||
for this view slice."
|
||||
|
||||
### 3.4 `PView::AddToCell @ 0x005a4d90` (pc:433050) — incremental view-slice merge
|
||||
|
||||
When a cell is reached **again** through a *different* portal (a second clip region), `AddToCell`
|
||||
processes only the **new** view slices, from the cell's `update_count` watermark up to its current
|
||||
`view_count` (pc:433056-433080). This is how a far cell seen through two doorways accumulates two
|
||||
clip regions without re-walking the slices it already has — the watermark guarantees each slice is
|
||||
expanded once.
|
||||
|
||||
### 3.5 `PView::ClipPortals @ 0x005a5520` (pc:433572) — clip portals + feed `outside_view`
|
||||
|
||||
Two phases. **Phase 1 (pc:433583-433620):** for each portal with `seen != 0 && inflag != 1`,
|
||||
resolve `other_cell_ptr` (via `CEnvCell::GetVisible(other_cell_id)`, cached into the portal); set a
|
||||
local `var_c = 1` if the portal has *any* destination — a loaded neighbour, **or** the exit
|
||||
sentinel `other_cell_id == 0xffffffff`, or a resolvable neighbour. If `var_c == 0` (no portal goes
|
||||
anywhere) the function returns 0 → the cell contributes no neighbours.
|
||||
|
||||
**Phase 2 (pc:433622-…):** for each live view slice `i` of this cell, `Render::set_view`, then per
|
||||
portal compute its screen clip via `GetClip` (`@ 0x005a4320`, projects the portal poly to screen,
|
||||
runs `polyClipFinish`, honors `Sidedness`). The decisive branch on the portal's destination
|
||||
(pc:433651-433701):
|
||||
|
||||
```c
|
||||
GetClip(this, portal.portal_side, portal.portal, &clip_view, &clipNonEmpty, 1);
|
||||
if (clipNonEmpty != 0) {
|
||||
if (portal.other_cell_id == 0xffffffff) { // pc:433662 ── EXIT PORTAL ──
|
||||
if (this->draw_landscape != 0) { // pc:433664 indoor PView built with draw_landscape=1
|
||||
if (cliplandscape != 0)
|
||||
Render::copy_view(this /*->outside_view*/, &clip_view, clipNonEmpty); // pc:433674
|
||||
else /* draw_landscape */
|
||||
Render::copy_view(this /*->outside_view*/, nullptr, 0);
|
||||
}
|
||||
} else if (portal.other_cell_ptr != 0) { // pc:433687 ── INTERIOR PORTAL ──
|
||||
// OtherPortalClip intersects clip with the neighbour's own portal opening, then
|
||||
// copies the resulting clip region into the neighbour cell's portal_view (set_view/copy_view)
|
||||
OtherPortalClip(this, portal, &clip_view, &clipNonEmpty); // pc:433692
|
||||
// → records the clipped view onto edi_1 (the neighbour's portal_view at +0x134/+0x138)
|
||||
}
|
||||
}
|
||||
```
|
||||
**This is the load-bearing seam.** The exit-portal branch (`0xffffffff`) is the *only* place the
|
||||
outdoors enters the indoor traversal: it accumulates the doorway's screen region into
|
||||
`this->outside_view`. The interior branch propagates the clipped region into the neighbour cell so
|
||||
that cell is later drawn only through the intersection of all portals it was seen through.
|
||||
|
||||
### 3.6 `PView::AddViewToPortals @ 0x005a52d0` (pc:433446) — enqueue neighbours
|
||||
|
||||
For each portal of the cell whose `other_cell_ptr` exists and is active:
|
||||
```c
|
||||
neighbour = portal.other_cell_ptr;
|
||||
if (neighbour not yet inited this traversal) {
|
||||
InitCell(this, neighbour, portal.other_portal_id); // pc:433480 set up neighbour's view slot
|
||||
InsCellTodoList(this, neighbour, portal.max_indist); // pc:433485 enqueue it
|
||||
if (portal.flag >= 0) SetOtherSeen(this, cell, portalIdx); // pc:433490 mark exit-seen / matching portal
|
||||
} else {
|
||||
AddToCell(this, neighbour, portal.other_portal_id); // pc:433494 merge a new clip slice
|
||||
}
|
||||
```
|
||||
`SetOtherSeen @ 0x005a4e30` records the reciprocal portal as seen (prevents re-entering the cell
|
||||
you just came from with a redundant slice). `InsCellTodoList @ 0x005a4f50` pushes onto the worklist
|
||||
(growing it as needed).
|
||||
|
||||
### 3.7 The exterior→interior sibling: `ConstructView(CBldPortal*, CPolygon*, int, int) @ 0x005a59a0` (pc:433827)
|
||||
|
||||
Used by `DrawPortal` (Section 6.2) when the camera is **outside** and can see a building door:
|
||||
```c
|
||||
// side-test the building portal polygon vs the viewer viewpoint:
|
||||
d = dot(FrameCurrent->viewer.viewpoint, portal->plane.N) + portal->plane.d;
|
||||
side = (d < 0.0002) ? NEGATIVE : (|d|<eps ? IN_PLANE : POSITIVE); // pc:433832-433849
|
||||
if (side matches the portal's required sidedness) {
|
||||
GetClip(this, side, portal, &clip_view, &arg3, arg4); // pc:433856 clip to the door opening
|
||||
if (clip non-empty) {
|
||||
interior = CEnvCell::GetVisible(bldPortal->other_cell_id); // the room behind the door
|
||||
if (arg5 != 2) DrawPortalPolyInternal(portal, …); // stencil the doorway
|
||||
ConstructView(this, interior, bldPortal->other_portal_id); // pc:433879 RECURSE into the interior
|
||||
}
|
||||
}
|
||||
```
|
||||
So **the same `ConstructView`** builds the interior visible set whether entered from inside (the
|
||||
`CEnvCell` overload) or from outside through a building portal (the `CBldPortal` overload). This is
|
||||
why outside↔inside is seamless.
|
||||
|
||||
---
|
||||
|
||||
## 4. Seal mechanics — `PView::DrawCells @ 0x005a4840` (pc:432709), the exact sequence
|
||||
|
||||
This is the function the prior studies under-covered. Read this section against the verbatim
|
||||
pseudo-C; it has **three sequential per-cell loops** plus the landscape-through-door block.
|
||||
|
||||
### 4.1 The landscape-through-door block + conditional Z-clear (pc:432715-432732)
|
||||
|
||||
```c
|
||||
if (this->outside_view.view_count > 0) { // pc:432715 an exit portal was visible
|
||||
Render::useSunlightSet(1);
|
||||
Render::PortalList = this; // pc:432718 tell LScape to clip to outside_view
|
||||
LScape::draw(this->lscape); // pc:432719 DRAW terrain+sky+rain+exterior, CLIPPED
|
||||
D3DPolyRender::FlushAlphaList(0);
|
||||
render_device->m_nFrameStamp += 1;
|
||||
|
||||
// ── CONDITIONAL Z-ONLY CLEAR ──
|
||||
bool nothingDrawn;
|
||||
if (forceClear == 0) {
|
||||
nothingDrawn = (D3DPolyRender::portalsDrawnCount == 0); // pc:432727
|
||||
D3DPolyRender::portalsDrawnCount = 0;
|
||||
}
|
||||
if (forceClear != 0 || !nothingDrawn)
|
||||
render_device->vtable->Clear(4, 0x820fc0, 1.0f); // pc:432732 flag 4 == Z-BUFFER ONLY
|
||||
... (loops below run inside this `if`) ...
|
||||
}
|
||||
```
|
||||
**Critical seal facts:**
|
||||
- **`Clear(4, …)`** — the `4` is the **depth/Z buffer bit only**, NOT color. There is *no*
|
||||
`Clear(color)` anywhere in this path. The "blue hole" acdream sees is the *absence* of the
|
||||
`LScape::draw` step (the outdoors is never injected), not a stray blue clear.
|
||||
- **The clear is conditional** on `portalsDrawnCount` (and `forceClear`). It re-establishes a clean
|
||||
depth field for the interior cells after the landscape (which is at far depth) was drawn through
|
||||
the doorway, so interior geometry composites correctly over it. Color is preserved → the terrain
|
||||
pixels in the doorway survive.
|
||||
- **`Render::PortalList = this`** before `LScape::draw` is what clips the *entire landscape draw*
|
||||
to the union of exit-portal screen regions in `outside_view`. Outside the doorway region, the
|
||||
landscape contributes nothing.
|
||||
- **`0x820fc0`** is the clear-region/rect parameter (the area to clear), not a color.
|
||||
|
||||
### 4.2 Loop 1 — exit-portal stencil masks (pc:432737-432808)
|
||||
|
||||
Iterates `cell_draw_list` from `cell_draw_num-1` down to 1 (reverse). For each cell whose
|
||||
`structure->drawing_bsp != 0`:
|
||||
```c
|
||||
SetCurrentMaterial(render_device, nullptr, 0);
|
||||
Render::SetSurfaceArray(cell->surfaces);
|
||||
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
|
||||
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1; // -1 == "one default view"
|
||||
for (view = 0; view < |viewCount|; view++) { // pc:432772
|
||||
CEnvCell::setup_view(cell, view); // pc:432774 select this clip slice
|
||||
for (j = 0; j < cell->num_portals; j++) {
|
||||
if (cell->portals[j].other_cell_id == 0xffffffff) // pc:432785 EXIT portal
|
||||
D3DPolyRender::DrawPortalPolyInternal(cell->portals[j].portal, 0); // pc:432786 STENCIL the opening
|
||||
}
|
||||
}
|
||||
Render::framePop();
|
||||
```
|
||||
This stencils each exit-portal polygon (per clip slice) so the interior geometry drawn next is
|
||||
masked to leave the doorway showing the already-drawn landscape. (`setup_view` re-binds the
|
||||
per-slice clip region so multi-doorway cells are handled correctly.)
|
||||
|
||||
### 4.3 Loop 2 — draw the closed interior geometry (pc:432815-432866)
|
||||
|
||||
Runs **unconditionally** (outside the `outside_view>0` block — i.e., for dungeons too). Reverse
|
||||
iterate `cell_draw_list`; for each cell with `drawing_bsp != 0`:
|
||||
```c
|
||||
SetCurrentMaterial(...); Render::SetSurfaceArray(cell->surfaces);
|
||||
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
|
||||
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1;
|
||||
for (view = 0; view < |viewCount|; view++) {
|
||||
CEnvCell::setup_view(cell, view); // pc:432852 select clip slice
|
||||
render_device->vtable->DrawEnvCell(cell); // pc:432853 DRAW floor+walls+CEILING (drawing_bsp)
|
||||
}
|
||||
Render::framePop();
|
||||
```
|
||||
**`DrawEnvCell` draws the cell's full closed mesh** (floor, four walls, ceiling) from
|
||||
`structure->drawing_bsp`, back-face-culled and clipped to the cell's accumulated `portal_view`
|
||||
slices. The ceiling is sealed *because it is part of the cell mesh* — there is no separate cap step.
|
||||
Portal openings are not filled (Loop 1 stenciled them).
|
||||
|
||||
### 4.4 Loop 3 — per-cell objects/particles, clipped (pc:432870-432882)
|
||||
|
||||
```c
|
||||
for (cell = cell_draw_num-1; cell >= 0; cell--) {
|
||||
eax = cell_draw_list[cell];
|
||||
// PortalList = the cell's CURRENT portal_view slot (its accumulated clip region):
|
||||
Render::PortalList = *(eax->portal_view.data[eax->num_view - 1] ... ); // pc:432877
|
||||
render_device->vtable->DrawObjCellForDummies(eax); // pc:432878 draw this cell's objects
|
||||
}
|
||||
Render::object_scale = 1; Render::useSunlightSet(1); // restore
|
||||
```
|
||||
**`DrawObjCellForDummies(cell)`** draws the objects registered in that cell's `object_list`
|
||||
(the same list physics `enter_cell`/`leave_cell` maintains), with `Render::PortalList` set to the
|
||||
cell's clip region — so objects (and their attached particles) are clipped to the portal opening(s)
|
||||
through which their cell is visible. **An object in a cell not in `cell_draw_list` is never drawn**
|
||||
→ no bleed-through. An object straddling a portal is clipped to the opening.
|
||||
|
||||
### 4.5 Why the seal holds — the four guarantees
|
||||
|
||||
1. **No blue hole:** outdoors is drawn first (`LScape::draw`, clipped to `outside_view`); the only
|
||||
clear is Z-only and conditional; color survives in the doorway.
|
||||
2. **Sealed ceiling/walls:** each visible cell's `drawing_bsp` is a closed box; `DrawEnvCell` draws
|
||||
it whole; portal holes are stencil-masked, not filled.
|
||||
3. **No outdoor bleed-in:** the landscape only paints through exit-portal clip regions; if
|
||||
`outside_view.view_count == 0` (dungeon), `LScape::draw` is never called at all.
|
||||
4. **No object/particle bleed:** objects are drawn per-cell, clipped to that cell's `PortalList`,
|
||||
only for cells in `cell_draw_list`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data structures (annotated, verbatim from `acclient.h`)
|
||||
|
||||
### 5.1 The cell hierarchy
|
||||
|
||||
```c
|
||||
// acclient.h:30915 — the base cell. ONE graph for physics AND render.
|
||||
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
|
||||
LandDefs::WaterType water_type;
|
||||
Position pos; // cell-to-world frame
|
||||
unsigned int num_objects;
|
||||
DArray<CPhysicsObj*> object_list; // RENDER + physics: objects in this cell (Loop 3)
|
||||
unsigned int num_lights; DArray<LIGHTOBJ const*> light_list;
|
||||
unsigned int num_shadow_objects;
|
||||
DArray<CShadowObj*> shadow_object_list; // PHYSICS: collision objects registered here
|
||||
unsigned int restriction_obj;
|
||||
ClipPlaneList **clip_planes;
|
||||
unsigned int num_stabs;
|
||||
unsigned int *stab_list; // STATIC visibility set (PVS): cell ids this cell can see
|
||||
int seen_outside; // boolean: this interior can reach the outdoors ★
|
||||
LongNIValHash<GlobalVoyeurInfo>* voyeur_table;
|
||||
CLandBlock *myLandBlock_;
|
||||
};
|
||||
|
||||
// acclient.h:31880 — adds a building pointer
|
||||
struct CSortCell : CObjCell { CBuildingObj* building; };
|
||||
|
||||
// acclient.h:31886 — outdoor surface cell of a landblock (8×8 grid → ids 0x01..0x40)
|
||||
struct CLandCell : CSortCell { CPolygon** polygons; BoundingType in_view; };
|
||||
|
||||
// acclient.h:32072 — interior cell (building room OR dungeon room; id >= 0x100)
|
||||
struct CEnvCell : CObjCell {
|
||||
unsigned int num_surfaces; CSurface** surfaces; // material/surface array (RENDER)
|
||||
CCellStruct *structure; // geometry + 3 BSP trees (see 5.3)
|
||||
CEnvironment *env;
|
||||
unsigned int num_portals; CCellPortal* portals; // portal graph edges
|
||||
unsigned int num_static_objects;
|
||||
IDClass* static_object_ids; Frame* static_object_frames; CPhysicsObj** static_objects;
|
||||
RGBColor *light_array; int incell_timestamp;
|
||||
MeshBuffer *constructed_mesh; int use_built_mesh; unsigned int m_current_render_frame_num;
|
||||
unsigned int num_view; // RENDER: # of active per-portal view slots
|
||||
DArray<portal_view_type*> portal_view; // RENDER: per-portal clip state (see 5.4) ★
|
||||
};
|
||||
```
|
||||
`seen_outside` is the dat flag `EnvCellFlags.SeenOutside = 0x1` (ACViewer
|
||||
`ACE.Entity/Enum/EnvCellFlags.cs:7`). **Confirmed verbatim** at acclient.h:30929.
|
||||
|
||||
### 5.2 The portals
|
||||
|
||||
```c
|
||||
// acclient.h:32300 — interior↔interior (room↔room) AND interior→exterior
|
||||
struct CCellPortal {
|
||||
unsigned int other_cell_id; // 0xFFFFFFFF (low 0xFFFF) ⇒ EXIT PORTAL (opens to the outdoors) ★
|
||||
CEnvCell *other_cell_ptr; // cached resolved neighbour (or null until GetVisible)
|
||||
CPolygon *portal; // the portal polygon (its plane = the doorway plane)
|
||||
int portal_side; // which half-space is "inside" this cell
|
||||
int other_portal_id; // index of the matching portal in the neighbour
|
||||
int exact_match;
|
||||
};
|
||||
|
||||
// acclient.h:32094 — outdoor landblock → building interior (the door from the street)
|
||||
struct CBldPortal {
|
||||
int portal_side;
|
||||
unsigned int other_cell_id; // the interior EnvCell this building portal leads into
|
||||
int other_portal_id;
|
||||
int exact_match;
|
||||
unsigned int num_stabs; unsigned int* stab_list; // PVS of interior cells visible through this door
|
||||
float sidedness;
|
||||
};
|
||||
```
|
||||
Held by `CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves;
|
||||
CPartCell** leaf_cells; ... }` (acclient.h:31908), which a `CLandCell`/`CSortCell` references.
|
||||
|
||||
### 5.3 Per-cell geometry: `CCellStruct` (acclient.h:32275) — THREE BSP trees
|
||||
|
||||
```c
|
||||
struct CCellStruct {
|
||||
... vertex_array; num_portals; CPolygon** portals; surface_strips; polygons;
|
||||
BSPTree* drawing_bsp; // RENDER — back-face order the closed cell mesh (DrawEnvCell)
|
||||
... physics_polygons;
|
||||
BSPTree* physics_bsp; // PHYSICS — collide the sphere against cell walls/floor
|
||||
BSPTree* cell_bsp; // CONTAINMENT — point/sphere-in-cell tests (point_in_cell, membership)
|
||||
};
|
||||
```
|
||||
A dungeon cell and a building-interior cell use the **same** struct; only their portal topology and
|
||||
`seen_outside` differ. **The closed mesh (with ceiling) is authored in the dat** — there is no
|
||||
ceiling-cap code path.
|
||||
|
||||
### 5.4 The render view state — `portal_view_type` (acclient.h:32346) + the watermark
|
||||
|
||||
```c
|
||||
struct portal_view_type { // one slot per active view of a CEnvCell (CEnvCell.portal_view[])
|
||||
DArray<portal_info> portal; // per-portal { int seen; int inflag; } (acclient.h:32459)
|
||||
view_type view; // the screen-clip geometry (poly+vertex)
|
||||
float max_indist;
|
||||
unsigned int view_count; // how many view slices this cell currently has
|
||||
int cell_view_done;
|
||||
int view_timestamp;
|
||||
int update_count; // ★ WATERMARK: slices [update_count..view_count) are "new"
|
||||
};
|
||||
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
|
||||
struct view_poly { int vertex_count; int vertex_index; float xmin, xmax, ymin, ymax; }; // 2D screen clip rect/poly
|
||||
```
|
||||
|
||||
**The `update_count` watermark — how it bounds the BFS without a fixed cap (issue #102):**
|
||||
When a cell is *first* reached, `InitCell` sets `update_count = view_count` (the slices it has).
|
||||
When the cell is reached *again* through another portal, `AddViewToPortals → AddToCell` only
|
||||
processes slices from `update_count` up to the (now larger) `view_count` — i.e., the genuinely new
|
||||
clip regions — then advances `update_count`. A cell therefore expands each distinct view slice
|
||||
**exactly once**; the traversal terminates when no portal produces a new slice. `master_timestamp`
|
||||
(bumped per `ConstructView`) + `view_timestamp`/`cell_view_done` are the per-traversal cycle guards.
|
||||
**This is the retail replacement for acdream's `PortalVisibilityBuilder.MaxReprocessPerCell = 4`**
|
||||
and the correct fix for dungeon PVS blowup (#95): the watermark naturally converges; a fixed cap
|
||||
either truncates (missing cells) or never converges (blowup).
|
||||
|
||||
### 5.5 The traversal owner — `PView` (acclient.h:45934)
|
||||
|
||||
```c
|
||||
struct PView {
|
||||
portal_view_type outside_view; // ★ accumulated clip region of the outdoors seen thru exit portals
|
||||
int draw_landscape; // 1 for indoor_pview ⇒ exit portals feed outside_view (ClipPortals)
|
||||
CBldPortal **outdoor_portal_list; // building doors the camera can see (used by DrawPortal)
|
||||
DArray<CEnvCell*> cell_draw_list; // ★ ordered visible cells (the BFS output)
|
||||
unsigned int cell_draw_num;
|
||||
DArray<CellListType*> cell_todo_list; // the worklist
|
||||
unsigned int cell_todo_num;
|
||||
LScape *lscape; // the landscape to draw through doorways
|
||||
};
|
||||
```
|
||||
|
||||
### 5.6 The physics-membership structs (summary — full detail in the studies)
|
||||
|
||||
```c
|
||||
// acclient.h:32625 — the per-transition working state
|
||||
struct SPHEREPATH {
|
||||
... CObjCell* begin_cell; Position* begin_pos; Position* end_pos;
|
||||
CObjCell* curr_cell; Position curr_pos; // ★ ACCEPTED membership (the answer)
|
||||
... CObjCell* check_cell; Position check_pos; // candidate this sub-step
|
||||
SPHEREPATH::InsertType insert_type;
|
||||
CObjCell* backup_cell; ...
|
||||
int hits_interior_cell; int cell_array_valid; ...
|
||||
};
|
||||
// acclient.h:31574 — the COLLISION candidate set (distinct from curr_cell)
|
||||
struct CELLARRAY { int added_outside; int do_not_load_cells; unsigned int num_cells;
|
||||
DArray<CELLINFO> cells; }; // CELLINFO = { uint cell_id; CObjCell* cell; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Dungeons, outside-looking-in, object/particle clipping
|
||||
|
||||
### 6.1 Dungeons — emergent, no flag
|
||||
|
||||
- **Data:** a dungeon landblock has terrain heights all 0, ≥1 EnvCell, and **no buildings** (ACE's
|
||||
`IsDungeon` heuristic, `references/ACE/.../Landblock.cs:575` — a *server* heuristic; the **client
|
||||
needs no such flag**). Its EnvCells have `seen_outside == 0` and **no exit portals**.
|
||||
- **Movement:** identical to a building interior — `transition` advances `curr_cell` across
|
||||
`CCellPortal`s; `find_transit_cells`' exit-portal flag (`var_44`) never fires (no exit portals),
|
||||
so `add_all_outside_cells` is never called → outdoor cells never enter the candidate set.
|
||||
- **Loading:** `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878) — adds self + every
|
||||
`stab_list` cell to the visible table, then `if (seen_outside == 0) return;` (**pc:311893** —
|
||||
dungeon stops here, never touches the landscape); a `seen_outside` cell tail-calls
|
||||
`LScape::grab_visible_cells`. **This is the exact load-time decision** "stream the outdoor world
|
||||
or not."
|
||||
- **Render:** `DrawInside` runs; `ConstructView` floods the dungeon cells; **no exit portal is ever
|
||||
reached**, so `outside_view.view_count` stays 0; `DrawCells`' landscape block (§4.1) is skipped;
|
||||
Loops 2+3 still draw the closed cells + their objects. Result: sealed dungeon, no terrain, no sky
|
||||
— by construction, same path as a cottage.
|
||||
|
||||
### 6.2 Outside-looking-in — a building interior seen through its door from the street
|
||||
|
||||
Driven by the **outdoor** render (`LScape::draw`), which calls `PView::DrawPortal @ 0x005a5ab0`
|
||||
(pc:433895) for each building door the camera can see:
|
||||
```c
|
||||
Render::m_pRenderer->polyListFinishInternal(); ACRender::backup_curr_state();
|
||||
bldPortal = this->outdoor_portal_list[arg2->portal_index]; // the building's door
|
||||
portal = arg2->portal; // its polygon
|
||||
PView::add_views(this, bldPortal->num_stabs, bldPortal->stab_list); // seed interior PVS
|
||||
result = ConstructView(this, bldPortal, portal, arg3, arg4); // pc:433910 build interior visible set thru door
|
||||
if (result == 0) { // door not actually visible
|
||||
if (arg4 == 3) DrawPortalPolyInternal(portal, 0); // just stencil the closed door
|
||||
ACRender::restore_curr_state();
|
||||
} else {
|
||||
if (arg4 != 1) PView::DrawCells(this, …); // pc:433924 DRAW the interior through the door
|
||||
ACRender::restore_curr_state();
|
||||
Render::positionPush(3, CBuildingObj::curr_pos); Render::obj_view_set();
|
||||
}
|
||||
PView::remove_views(this, bldPortal->num_stabs, bldPortal->stab_list);
|
||||
```
|
||||
So the **same `ConstructView` + `DrawCells`** that render an interior from inside also render it
|
||||
from outside through the door — entered via the `CBldPortal` overload of `ConstructView`
|
||||
(§3.7) which side-tests the door plane, clips to the opening, and recurses into the room. The
|
||||
`outdoor_pview` instance is used here (vs `indoor_pview` for `DrawInside`). This is the path
|
||||
acdream is missing ("outside-looking-in shows no interior" residual, spec §1a).
|
||||
|
||||
### 6.3 Object / particle clipping to the visible cell set
|
||||
|
||||
- **Membership:** objects live in a cell's `object_list` (and collision objects in
|
||||
`shadow_object_list`), maintained by physics `enter_cell @ 0x00510ed0` / `leave_cell @
|
||||
0x00510f50` — the **same** graph render reads. A particle attached to an object inherits the
|
||||
object's cell.
|
||||
- **Draw + clip (indoor):** Loop 3 of `DrawCells` (§4.4) iterates **only** `cell_draw_list`, draws
|
||||
each cell's `object_list` via `DrawObjCellForDummies`, with `Render::PortalList` set to that
|
||||
cell's clip region. Non-visible cells' objects are never iterated → no bleed.
|
||||
- **Child-cell resolution for entities:** `CEnvCell::find_visible_child_cell @ 0x0052dc50`
|
||||
(pc:311397) — given a point, returns the exact child cell containing it:
|
||||
```c
|
||||
if (point_in_cell(this, p)) return this; // pc:311402
|
||||
if (arg3 == 0) { // search via portals:
|
||||
for (portal in this->portals) {
|
||||
n = CCellPortal::GetOtherCell(portal);
|
||||
if (n && n->point_in_cell(p)) return n; // pc:311424
|
||||
}
|
||||
return 0;
|
||||
} else { // search via stab_list (PVS):
|
||||
for (id in this->stab_list) {
|
||||
c = CEnvCell::visible_cell_table[id];
|
||||
if (c && c->point_in_cell(p)) return c; // pc:311456
|
||||
}
|
||||
}
|
||||
```
|
||||
Used for the 3rd-person camera offset (which child cell is the eye in?) and for placing
|
||||
entities/particles into the right cell **via the graph/BSP, never an AABB**. This is the
|
||||
retail-faithful replacement for acdream's `CellVisibility.FindCameraCell` AABB + grace-frame
|
||||
resolver.
|
||||
|
||||
---
|
||||
|
||||
## 7. PORTING CHECKLIST — the spine of the redesign
|
||||
|
||||
Ordered list of every behavior a faithful C# port must implement to get a fully-sealed
|
||||
outdoor+indoor+dungeon render with **no bleed, no flaps, no transparent walls, no blue hole**.
|
||||
Cross-referenced to the Phase W staged plan; each behavior cites its retail anchor.
|
||||
|
||||
### CL-A. Membership foundation (prerequisite — physics owns the cell)
|
||||
*(Detail in the four studies + spec §1/§1a; summarized here because render roots on it.)*
|
||||
- [ ] **A1.** `ResolveWithTransition` returns the swept `sp.CurCellId` (mirror
|
||||
`SetPositionInternal @ 0x00515330` reading `sphere_path.curr_cell`), **not** a static
|
||||
`ResolveCellId(origin,…)`. Demote `ResolveCellId` to seed-only (spawn/teleport/server-set).
|
||||
- [ ] **A2.** Replace `FindEnvCollisions`' early static re-derive (`TransitionTypes.cs:1947→1949`)
|
||||
with a retail directed exit-portal crossing (`CEnvCell::find_transit_cells @ 0x0052c820`
|
||||
exit-portal path) — become outdoor by crossing the doorway polygon, not by re-resolving XYZ.
|
||||
- [ ] **A3.** Port the `find_cell_list` interior-wins pick (`@ 0x0052b4e0`, pc:308814-308819) +
|
||||
`do_not_load_cells` prune (pc:308829-308867) into `CellTransit`; re-gate `FindTransitCellsSphere`'s
|
||||
unconditional `exitOutside=true` (`CellTransit.cs:95-123`).
|
||||
- [ ] **A4.** Commit-on-difference: write `CellGraph.CurrCell` once, fire a "cell changed" event
|
||||
only when it differs (mirror `change_cell @ 0x00513390`, the `if (this->cell != curr_cell)` guard).
|
||||
|
||||
### CL-B. Render root unification (Stage 3)
|
||||
- [ ] **B1.** Port the **single per-frame decision** (`RenderNormalMode @ 0x00453aa0`): viewer cell
|
||||
is an outdoor landcell (`id&0xFFFF < 0x100`, `is_player_outside @ 0x00451e80`) → outdoor path;
|
||||
else → indoor `DrawInside`. Remove the `ACDREAM_A8_INDOOR_BRANCH` two-pipe split.
|
||||
- [ ] **B2.** Root render visibility at the **physics `CellGraph.CurrCell`** (the U.4c flap fix),
|
||||
not an independent AABB `FindCameraCell`. Delete the 3-frame grace-frame hack.
|
||||
- [ ] **B3.** 3rd-person camera offset resolves its child cell via
|
||||
`CEnvCell::find_visible_child_cell @ 0x0052dc50` (graph/BSP), rooted at the player cell — never
|
||||
an AABB reclassification. (Eye drives projection; player cell drives the visibility root.)
|
||||
- [ ] **B4.** Port the **landscape keep/release policy** (`CellManager::ChangePosition @
|
||||
0x004559b0`, pc:94601-94682): keep landscape loaded + sunlight/outdoor-ambient live iff the
|
||||
current cell is a landcell OR `seen_outside`; else `LScape::release_all` + flat indoor ambient.
|
||||
- [ ] **B5.** Port `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878): add self + `stab_list`
|
||||
to the visible set; load the landscape **iff `seen_outside`** (the dungeon gate, pc:311893).
|
||||
- [ ] **B6.** Pre-position the through-door terrain via `Position::get_outside_cell_id @
|
||||
0x004527b0` + `LScape::update_viewpoint` before the indoor draw (when `seen_outside`).
|
||||
|
||||
### CL-C. PView traversal (Stage 4, the big one)
|
||||
- [ ] **C1.** Port the BFS `PView::ConstructView(CEnvCell*) @ 0x005a57b0`: reset `outside_view`,
|
||||
bump `master_timestamp`, `InitCell(root)`, push root, then pop-and-expand into `cell_draw_list`
|
||||
via `ClipPortals` + `AddViewToPortals` until the worklist drains.
|
||||
- [ ] **C2.** Port `PView::InitCell @ 0x005a4b70`: per-portal sidedness vs the **viewer viewpoint**
|
||||
→ `seen`/`inflag`; compute `max_indist`; set `update_count = view_count`.
|
||||
- [ ] **C3.** Port `PView::ClipPortals @ 0x005a5520` with **both** branches: exit portal
|
||||
(`other_cell_id == 0xffffffff`) → `copy_view` into `outside_view` (gated by `draw_landscape`);
|
||||
interior portal → `OtherPortalClip` → propagate clipped region into the neighbour's `portal_view`.
|
||||
- [ ] **C4.** Port `PView::AddViewToPortals @ 0x005a52d0`: enqueue uninited neighbours
|
||||
(`InitCell` + `InsCellTodoList` + `SetOtherSeen`); merge a new slice into already-inited
|
||||
neighbours (`AddToCell @ 0x005a4d90`).
|
||||
- [ ] **C5.** Implement the **`update_count` watermark** convergence (per-cell, slices
|
||||
`[update_count..view_count)` processed once) — **delete the fixed `MaxReprocessPerCell` cap**
|
||||
(closes #102; correct dungeon-PVS fix for #95).
|
||||
- [ ] **C6.** Port `PView::GetClip @ 0x005a4320` (project portal poly → screen, `polyClipFinish`,
|
||||
honor `Sidedness`) producing 2D `view_poly` clip rects/polys.
|
||||
|
||||
### CL-D. Seal mechanics in `DrawCells` (Stage 4)
|
||||
- [ ] **D1.** When `outside_view.view_count > 0`: set `PortalList = this`, `LScape::draw(lscape)`
|
||||
(terrain+sky+rain **clipped to the doorway**) FIRST (`DrawCells @ 0x005a4840`, pc:432715-432719).
|
||||
- [ ] **D2.** Implement the **conditional Z-only clear** (`Clear(4, …)`, pc:432731-432732): depth
|
||||
bit only, NOT color; conditional on `portalsDrawnCount`/`forceClear`. **Never `Clear(color)` in
|
||||
the indoor path** — that is the blue-hole bug.
|
||||
- [ ] **D3.** **Loop 1** — per visible cell, per view slice (`setup_view`), `DrawPortalPolyInternal`
|
||||
every exit portal (`other_cell_id == 0xffffffff`) to stencil the openings (pc:432785-432786).
|
||||
- [ ] **D4.** **Loop 2** — per visible cell, per view slice, `DrawEnvCell` the closed
|
||||
`structure->drawing_bsp` (floor+walls+**ceiling**). Runs for dungeons too (unconditional). No
|
||||
ceiling-cap step — the mesh is closed (pc:432852-432853).
|
||||
- [ ] **D5.** **Loop 3** — per visible cell, set `PortalList` to the cell's clip region, then
|
||||
`DrawObjCellForDummies` (the cell's `object_list`) — objects/particles clipped to the doorway
|
||||
(pc:432876-432878).
|
||||
- [ ] **D6.** Renderer self-contained GL state: the indoor pass must SET every GL state it depends
|
||||
on (view-proj, BLEND, DepthMask, Cull, FrontFace, A2C) — never inherit (memory
|
||||
`render-self-contained-gl-state`; `EnvCellRenderer` hit this 3× in U.4).
|
||||
|
||||
### CL-E. Outside-looking-in (Stage 4/5)
|
||||
- [ ] **E1.** Port `PView::DrawPortal @ 0x005a5ab0` on the **outdoor** path: for each visible
|
||||
building door (`outdoor_portal_list`), `add_views(stab_list)`,
|
||||
`ConstructView(CBldPortal,polygon)`, then `DrawCells` the interior through the door's clip; if
|
||||
the door isn't actually visible, just `DrawPortalPolyInternal` (stencil the closed door).
|
||||
- [ ] **E2.** Port the `ConstructView(CBldPortal*) @ 0x005a59a0` exterior→interior recursion:
|
||||
side-test the door plane vs the viewer, `GetClip` to the opening, `GetVisible(other_cell_id)`,
|
||||
optional `DrawPortalPolyInternal`, then **recurse `ConstructView(interior, other_portal_id)`**.
|
||||
- [ ] **E3.** Use a **separate `outdoor_pview`** instance for E1/E2 (vs `indoor_pview` for
|
||||
`DrawInside`), matching `RenderDeviceD3D::indoor_pview`/`outdoor_pview_1`.
|
||||
|
||||
### CL-F. Entity / particle cell clipping (Stage 5)
|
||||
- [ ] **F1.** Entities/particles are placed into cells via the **physics graph** (`object_list`),
|
||||
resolved with `find_visible_child_cell @ 0x0052dc50` — never an AABB.
|
||||
- [ ] **F2.** Entities/particles draw **only** for cells in `cell_draw_list`, clipped to the cell's
|
||||
`PortalList` (Loop 3). Non-visible-cell entities are not drawn → no NPC/door/smoke bleed-through.
|
||||
- [ ] **F3.** An entity straddling a portal is clipped to the portal opening (inherits the cell's
|
||||
clip region), not drawn full-screen.
|
||||
|
||||
### CL-G. Conformance / acceptance gates
|
||||
- [ ] **G1.** Cottage (`seen_outside`): flicker gone (CL-A); interior sealed; sky/rain visible
|
||||
through the door; **no blue hole**; no transparent walls; no bleed-through.
|
||||
- [ ] **G2.** Dungeon (`seen_outside == 0` everywhere): sealed; **no terrain/sky**;
|
||||
`outside_view.view_count == 0`; traversal converges (watermark) without blowup (#95).
|
||||
- [ ] **G3.** Outside-looking-in: standing in the street facing an open cottage door, the interior
|
||||
(walls/floor/objects) renders through the doorway (CL-E).
|
||||
- [ ] **G4.** Headless asserts: doorway `outside_view` non-empty + `LScape::draw` invoked;
|
||||
sealed-cellar `outside_view` empty + `LScape::draw` NOT invoked; PVS root id == physics
|
||||
`CurrCell.Id` every frame; a cell receiving two clip slices is processed once per slice.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — primary decomp address index (all read/verified this session)
|
||||
|
||||
```
|
||||
Top-level / landscape policy
|
||||
SmartBox::RenderNormalMode 0x00453aa0 pc:92635 (binary decision; DrawInside vs LScape::draw)
|
||||
SmartBox::is_player_outside 0x00451e80 pc:90996 (low-word objcell_id < 0x100)
|
||||
Position::get_outside_cell_id 0x004527b0 pc:91552 (interior pos → outdoor landcell id)
|
||||
CellManager::ChangePosition 0x004559b0 pc:94601 (keep/release landscape on seen_outside)
|
||||
CEnvCell::grab_visible_cells 0x0052e220 pc:311878 (self+stab; landscape iff seen_outside @311893)
|
||||
RenderDeviceD3D::DrawInside 0x0059f0d0 pc:427843 (→ PView::DrawInside(indoor_pview))
|
||||
PView traversal + seal
|
||||
PView::DrawInside 0x005a5860 pc:433793
|
||||
PView::ConstructView(CEnvCell) 0x005a57b0 pc:433750 (the BFS worklist)
|
||||
PView::ConstructView(CBldPortal) 0x005a59a0 pc:433827 (exterior→interior recursion)
|
||||
PView::InitCell 0x005a4b70 pc:432896 (per-portal sidedness; update_count=view_count)
|
||||
PView::AddToCell 0x005a4d90 pc:433050 (incremental new-slice merge)
|
||||
PView::AddViewToPortals 0x005a52d0 pc:433446 (enqueue neighbours / SetOtherSeen)
|
||||
PView::ClipPortals 0x005a5520 pc:433572 (exit→outside_view @433662; interior→OtherPortalClip)
|
||||
PView::OtherPortalClip 0x005a5400 pc:433524
|
||||
PView::GetClip 0x005a4320 pc:432344 (portal poly → screen clip)
|
||||
PView::SetOtherSeen 0x005a4e30 pc:433089
|
||||
PView::InsCellTodoList 0x005a4f50 pc:433183
|
||||
PView::add_views / remove_views 0x005a5210 pc:433382
|
||||
PView::DrawCells 0x005a4840 pc:432709 (LScape-thru-door + Z-clear + 3 loops)
|
||||
PView::DrawPortal 0x005a5ab0 pc:433895 (outside-looking-in entry)
|
||||
LScape::draw 0x00506330 (terrain+sky; clipped via Render::PortalList)
|
||||
CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 (point → child cell via portals/stab_list)
|
||||
Membership (summary; see studies)
|
||||
CTransition::validate_transition 0x0050aa70 pc:272547
|
||||
CTransition::check_other_cells 0x0050ae50 pc:272717
|
||||
CObjCell::find_cell_list 0x0052b4e0 pc:308742
|
||||
CPhysicsObj::SetPositionInternal 0x00515330 pc:283399
|
||||
CPhysicsObj::change_cell 0x00513390 pc:281192
|
||||
CEnvCell::find_transit_cells 0x0052c820 pc:309968
|
||||
CLandCell::add_all_outside_cells 0x00533630 pc:317499
|
||||
CEnvCell::check_building_transit 0x0052c5d0 pc:309827
|
||||
CObjCell::GetVisible 0x0052ad40 pc:308209 (≥0x100→CEnvCell::GetVisible else CLandCell)
|
||||
CEnvCell::GetVisible 0x0052dc10 pc:311378
|
||||
|
||||
Structs (acclient.h)
|
||||
CObjCell 30915 (object_list 30920; shadow_object_list 30924; num_stabs/stab_list 30927-28; seen_outside 30929)
|
||||
CEnvCell 32072 (structure 32076; portals 32079; surfaces 32075; num_view/portal_view 32089-90)
|
||||
CLandCell 31886 | CSortCell 31880 | CBuildingObj 31908
|
||||
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 (drawing_bsp/physics_bsp/cell_bsp)
|
||||
portal_view_type 32346 (view_count, cell_view_done, view_timestamp, update_count) | view_type 32338
|
||||
view_poly 32465 | portal_info 32459 (seen, inflag) | PView 45934 (outside_view, draw_landscape,
|
||||
outdoor_portal_list, cell_draw_list, cell_todo_list, lscape)
|
||||
SPHEREPATH 32625 | CELLARRAY 31574 | CELLINFO 31925
|
||||
|
||||
Reference cross-checks
|
||||
ACE Transition.cs:984 / PhysicsObj.cs:1171 / ObjCell.cs:335 / EnvCell.cs:311 / CellArray.cs:17
|
||||
ACE Landblock.cs:575 (IsDungeon, server heuristic — client needs no flag)
|
||||
ACViewer EnvCellFlags.cs:7 (SeenOutside=0x1); Buffer.cs (brute-force draw, NO PView — divergent)
|
||||
WorldBuilder PortalService / VisibilityManager.RenderInsideOut (flat stencil — Silk.NET base, NOT the algorithm)
|
||||
```
|
||||
|
||||
> **Divergence note (do not copy the references' render model).** ACViewer brute-force draws all
|
||||
> loaded EnvCells with a `DungeonMode` cull toggle; WorldBuilder uses a flat `RenderInsideOut`
|
||||
> stencil pass. **Neither implements retail's portal-clipped `PView` / landscape-through-door.**
|
||||
> For Sections 2–7 the decomp is the sole authority and it wins. WorldBuilder's classes are a
|
||||
> useful Silk.NET *implementation* base (buffer management, shader plumbing) but the *algorithm*
|
||||
> is `PView` as documented above.
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
# Render Pipeline Redesign — Full Staged Plan (2026-06-02)
|
||||
|
||||
> **Read the master handoff first:** `docs/research/2026-06-02-render-pipeline-redesign-handoff.md`
|
||||
> (§2 evidence, §3 root cause, §5 retail target + porting checklist CL-A..G).
|
||||
> Then the 3 research docs. Then **BRAINSTORM** (Phase R0) before any code.
|
||||
|
||||
## The mandate (user, 2026-06-02 — non-negotiable, repeated so it's never lost)
|
||||
**FULLY WORKING outdoor + indoor + dungeon rendering. No flaps, no missing textures, no
|
||||
transparent walls, no terrain leaking into cellars, no entity/particle bleed. NO shortcuts,
|
||||
NO bandaids, NO quick fixes — if the architecturally-correct way is slower, take it. Refactor
|
||||
or redesign the whole pipeline if that's what it takes. Port from retail. Do more research
|
||||
mid-session rather than guess. Start with a brainstorm.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The target architecture (retail-faithful — the ONE model)
|
||||
|
||||
From the retail decomp (handoff §5; doc A). The single inversion that fixes everything:
|
||||
|
||||
> **When the viewer is in an EnvCell, run ONLY `DrawInside` — the one PView portal flood.
|
||||
> Do NOT draw the outdoor world and then gate it. Visibility IS the cull: only the visible
|
||||
> cells and their per-cell objects render; the landscape (terrain/sky/rain) is pulled in
|
||||
> ONLY through clipped exit portals, followed by a conditional depth-only clear.**
|
||||
|
||||
```
|
||||
RenderWorld(viewer):
|
||||
if viewer.cell is outdoor landcell:
|
||||
DrawOutside() # LScape: terrain + scenery + sky + weather (today's outdoor path)
|
||||
# buildings seen from outside render their interiors via DrawPortal (R5, outside-looking-in)
|
||||
else: # viewer in an EnvCell
|
||||
DrawInside(viewer.cell): # ONE flood — nothing else
|
||||
frame = ConstructView(cell) # PView BFS → cell_draw_list + per-cell clip + outside_view
|
||||
if frame.outside_view nonEmpty:
|
||||
DrawLScapeClippedTo(frame.outside_view) # terrain/sky/rain through the doorway
|
||||
ConditionalDepthOnlyClear(frame.outside_view) # Z only — never color → no blue hole
|
||||
for cell in frame.cell_draw_list (closest-first):
|
||||
DrawCellShell(cell, clip=frame.clip[cell]) # closed geometry: floor+walls+ceiling
|
||||
DrawCellObjects(cell, clip=frame.clip[cell]) # ONLY this cell's objects (statics/entities)
|
||||
DrawCellParticles(cell, clip=frame.clip[cell]) # ONLY this cell's particles
|
||||
```
|
||||
|
||||
**Components to KEEP (research says these are correct):** `PortalVisibilityBuilder` (the BFS),
|
||||
`ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`/`PortalView` (the clip machinery), `EnvCellRenderer`
|
||||
mesh/geometry path, `TerrainModernRenderer`, the WB mesh pipeline, the membership fix (`59f3a13`),
|
||||
the Stage-4 sky NDC-clip + doorway Z-clear, the diagnostic probes.
|
||||
|
||||
**The work is RESTRUCTURING THE ORCHESTRATION + the entity/particle draw, not a from-scratch
|
||||
rewrite.** No stencil two-pipe, no `isInside` gate, no AABB grace-frame, no WB `RenderInsideOut`
|
||||
(handoff §6/§9).
|
||||
|
||||
---
|
||||
|
||||
## 2. The phases (each is visually-verifiable; retail-anchored; no bandaids)
|
||||
|
||||
> Sizing note: this is a multi-session arc (M1.5 "indoor world feels right"). Each phase ends
|
||||
> at a **user visual gate** (the only acceptance that counts for a render seal — handoff §9
|
||||
> lesson). Do NOT batch phases past a gate. The phases are ordered so each is independently
|
||||
> testable and the bleed is killed early.
|
||||
|
||||
### Phase R0 — Brainstorm + lock the design (FIRST, with the user)
|
||||
- `superpowers:brainstorming` the §1 architecture with the user. Confirm the single-flood model,
|
||||
the per-cell object draw, the keep/redesign split. Resolve the open questions in §3.
|
||||
- Write the detailed per-phase spec (`docs/superpowers/specs/2026-06-…-render-redesign-design.md`).
|
||||
- **No code.** Gate: design approved.
|
||||
|
||||
### Phase R1 — One visibility authority + kill the outdoor-scenery bleed
|
||||
**Retail anchor:** CL-B (render-root unification), CL-F (visibility is the cull). Fact 8 (§5.1).
|
||||
- Make `PortalVisibilityBuilder.Build`'s `OrderedVisibleCells` / `CellViews` the **single**
|
||||
visibility answer. Route the **entity** dispatch off it (the same `pvFrame` the shells/terrain
|
||||
use) instead of the parallel `CellVisibility.ComputeVisibilityFromRoot` BFS.
|
||||
- **Delete the `ParentCellId==null → return true` bypass** at `WbDrawDispatcher.cs:1756`. Outdoor
|
||||
scenery (houses/trees/stabs) is gated to `OutdoorVisible` (drawn only when an exit portal makes
|
||||
the outdoors visible, and then clipped to it). This is the unification, not a special-case patch.
|
||||
- Decommission the duplicate `CellVisibility` entity path (or make it return the identical set).
|
||||
- **Gate (visual):** standing in the cellar, **no houses/trees/outdoor stabs are visible.** The
|
||||
`[shell]`/`[vis]`/entity probes confirm one visibility set drives all geometry.
|
||||
|
||||
### Phase R2 — The binary render decision: indoor = `DrawInside` only
|
||||
**Retail anchor:** CL-B1 (single decision), `RenderNormalMode @ 0x453aa0`, fact 2 (§5.1).
|
||||
- Restructure `GameWindow.OnRender` so that when `CellGraph.CurrCell` is an EnvCell, the **full
|
||||
outdoor draw is NOT issued** — only the indoor flood runs. The outdoor terrain/scenery/sky are
|
||||
reachable only via the exit-portal path (R3). When outdoors, the existing outdoor path runs.
|
||||
- Remove the "draw outdoor world, then draw cell shells on top, gated" structure. The render root
|
||||
is the physics `CurrCell` (already wired, Stage 3); the *consequence* (only-DrawInside-when-inside)
|
||||
is the new part.
|
||||
- **Gate (visual):** indoors you no longer see the full-screen outdoor "world background"; you see
|
||||
the interior (walls/floor/ceiling) and, through the door, the outside (still rough until R3).
|
||||
|
||||
### Phase R3 — The seal mechanics (`DrawCells` faithful port) — THE seal
|
||||
**Retail anchor:** CL-D, `PView::DrawCells @ 0x5a4840` (the three-loop seal sequence), fact 5–7.
|
||||
- In the indoor flood, when `outside_view` is non-empty: draw `LScape` (terrain/sky/rain) **clipped
|
||||
to the doorway** (reuse the Stage-4 sky NDC-clip + the terrain OutsideView clip) → **conditional
|
||||
depth-only clear** (the Stage-4 Z-clear) scissored to the doorway → then the cells.
|
||||
- Verify cell shells are **closed** (floor + walls + ceiling from `drawing_bsp`); the `[shell]`
|
||||
evidence shows geometry is present — confirm the floor face is in the mesh and faces correctly.
|
||||
- Resolve the terrain *model*: terrain is drawn ONLY through the exit-portal clip, never as a floor
|
||||
under the interior. Remove the `TerrainClipMode.Skip`-as-floor-removal confusion.
|
||||
- Self-contained GL state per draw (memory `render-self-contained-gl-state`).
|
||||
- **Gate (visual):** the cottage interior is **fully sealed** — opaque walls, solid floor, ceiling,
|
||||
**sky + rain visible through the door only** (no blue hole, no full-screen), no terrain under the
|
||||
floor, no grey-floor. The cellar is sealed.
|
||||
|
||||
### Phase R4 — Per-cell object + particle clipping (no bleed)
|
||||
**Retail anchor:** CL-F, fact 8; `find_visible_child_cell @ 0x52dc50`; #104.
|
||||
- Make the object draw **per visible cell** (iterate `cell_draw_list`, draw each cell's objects
|
||||
clipped to that cell's region) instead of one global entity pass. Entities straddling a portal
|
||||
are portal-clipped.
|
||||
- Give particles a cell (`OwnerCellId` from the owning entity's `ParentCellId`) and clip them to
|
||||
the visible set (#104).
|
||||
- **Gate (visual):** no NPC / door / smoke bleed through walls; an NPC just outside the door is
|
||||
visible through the doorway but not through the wall.
|
||||
|
||||
### Phase R5 — Outside-looking-in (U.5)
|
||||
**Retail anchor:** CL-E, `DrawPortal @ 0x5a5ab0`, `ConstructView(CBldPortal) @ 0x5a59a0`, fact 9.
|
||||
- When outdoors and a building's door/window is in view, render the building's **interior through
|
||||
that portal's clip** (the mirror of DrawInside, entered from the outdoor cell). Same machinery.
|
||||
- **Gate (visual):** looking through the cottage door/window from outside shows the **sealed
|
||||
interior**, not transparent walls.
|
||||
|
||||
### Phase R6 — Dungeons
|
||||
**Retail anchor:** fact 10 (emergent), the `update_count` watermark (fact 12 — #95), CL-C5.
|
||||
- Validate the all-EnvCell / `seen_outside==0` / no-exit-portal path on a real dungeon: no terrain,
|
||||
no sky, sealed cells, BFS convergence (the watermark bounds the reprocesses — confirm #95 closed).
|
||||
- **Gate (visual):** a real dungeon is sealed, no terrain/sky, no FPS collapse from the BFS.
|
||||
|
||||
### Phase R7 — Polish + conformance
|
||||
- Resolve the `CullMode.Landblock→None` double-sided stopgap (the actual winding).
|
||||
- Textures: confirm no missing textures anywhere (the `[shell]` zh=0 evidence says good; verify
|
||||
across dungeons).
|
||||
- Conformance tests (headless): the PView frame product, the seal asserts (handoff §8 apparatus).
|
||||
- Update roadmap; flip the M1.5 milestone; memory notes.
|
||||
- **Final gate (visual):** cottage + dungeon + outside-looking-in, all sealed and seamless.
|
||||
|
||||
---
|
||||
|
||||
## 3. Open questions for the R0 brainstorm
|
||||
- **Outdoor scenery while a door is open:** when indoors looking out, the visible outdoor scenery
|
||||
(the cottage across the street) must draw — but clipped to the doorway. Does that come for free
|
||||
from "LScape through the exit portal" (terrain + scenery both), or does scenery need its own
|
||||
exit-portal-clipped pass? (Retail draws LScape — which includes scenery — through the clip.)
|
||||
- **Building shells from outdoors:** when outdoors, the cottage's *exterior* shell must draw (it's a
|
||||
building). Is that an EnvCell shell drawn from the outdoor root, or part of the outdoor scenery?
|
||||
Reconcile with R5 (outside-looking-in) so the exterior + the door-interior compose correctly.
|
||||
- **The `EnvCellRenderer` filter vs per-cell-clip:** today shells use one `Render(filter)` with
|
||||
per-cell clip slots. R4 wants per-cell object draw. Confirm the EnvCellRenderer + WbDrawDispatcher
|
||||
can both be driven per-cell from `cell_draw_list` without a global pass.
|
||||
- **Two cameras (eye vs player cell):** the U.4c flap fix roots visibility at the player cell while
|
||||
projecting from the eye. Confirm the redesign preserves that (the eye can be outside the player
|
||||
cell in 3rd person) — `find_visible_child_cell` for the camera child.
|
||||
- **Scope of the entity-draw restructure:** is per-cell object draw a refactor of `WbDrawDispatcher`
|
||||
or a new dispatch path? (Per CLAUDE.md: don't break the working mesh pipeline; restructure the
|
||||
orchestration around it.)
|
||||
|
||||
## 4. Risks
|
||||
- **Big restructure of the render loop** — do it behind the visual gates, one phase at a time;
|
||||
keep the outdoor path working throughout (it's the 99% case).
|
||||
- **Per-cell object draw vs MDI batching** — the modern dispatcher batches across entities; a naive
|
||||
per-cell loop could regress perf. Design the per-cell clip to preserve batching (the clip-slot SSBO
|
||||
already supports per-instance clip; the cull is the membership, not a per-cell draw call necessarily).
|
||||
- **Don't reintroduce the abandoned approaches** (handoff §9): no stencil two-pipe, no isInside gate,
|
||||
no AABB grace-frame.
|
||||
- **Get the user's eyes early** — every phase ends at a visual gate; never declare a seal off tests.
|
||||
|
||||
## 5. The no-shortcuts rules (enforce on every task)
|
||||
1. If a task tempts a fast-but-wrong path, take the retail-faithful path; note the tradeoff in the commit.
|
||||
2. No suppression flags, grace periods, or "if (problem) return early" guards at a symptom site.
|
||||
3. Every AC-specific behavior cites a retail decomp anchor (address + pseudo-C line).
|
||||
4. Mid-session research over guessing — if the retail behavior is unclear, read the decomp / attach cdb.
|
||||
5. Each phase ends GREEN (build + tests) AND at a user visual gate. The seal is verified on screen.
|
||||
Loading…
Add table
Add a link
Reference in a new issue