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

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

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

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

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

29 KiB
Raw Blame History

Indoor Cell / Portal Visibility Render Reference Cross-Check

Date: 2026-06-02
Purpose: Inform acdream's Phase U unified render pipeline redesign by documenting what each reference client does for indoor cell rendering, portal visibility, the interior seal, outdoor-scenery gating, and object/particle clipping — with explicit ADOPT / AVOID verdicts.


1. WorldBuilder — Indoor Render Approach (RenderInsideOut Two-Pipe Stencil)

What WB actually does

Source files:

  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs lines 8801008

The branch decision

GameScene.cs:881 sets isInside = currentEnvCellId != 0 (the camera is in ANY EnvCell). Two completely different code paths branch on that boolean:

  • Inside (RenderInsideOut)VisibilityManager.RenderInsideOut (lines 73239)
  • Outside (RenderOutsideIn or fallback)VisibilityManager.RenderOutsideIn (lines 241358) or RenderEnvCellsFallback when EnableCameraCollision is off

This is the split acdream inherited from Phase N.4. It is the root cause of every doorway seam bug. The two paths are fundamentally incompatible at a threshold crossing — the hand-off point is where the flap, transparent walls, and terrain bleed all happen.

RenderInsideOut walkthrough (VisibilityManager.cs:73239)

  1. Stencil Bit 1 — doorway mask: All portal polygons of the camera's building are rasterized with StencilOp.Replace → Bit 1 = 1. This marks the screen pixels that correspond to doorways. Done with DepthFunc(Always) so portals draw regardless of what's in front of them.

  2. Punch depth at doorways: The same portal geometry is drawn again with uWriteFarDepth=1 (the stencil shader writes gl_FragDepth = 1.0). This clears depth at the doorway pixels so the outdoor terrain can bleed through them.

  3. Render indoor cells ALWAYS (no stencil guard). The camera building's full EnvCell set is drawn unconditionally (_currentEnvCellIds = union of all cells in the camera building). No per-portal clip. All cells in the building render even if behind the player.

  4. Gate outdoor geometry (terrain/scenery/static) through Bit 1. Stencil func Equal(1, 0x01) — terrain, scenery, and static objects only draw where the portal polygons were rasterized. This is the "seal" against outdoor bleed: if Bit 1 wasn't set, the pixel never gets terrain.

  5. Other buildings' cells (Step 5, lines 157229): For each other building visible through our doorways, WB does a further two-step mask — Bit 2 marks the intersection of our doorway AND the other building's portals (stencil == 3 meaning both bits set), then draws that building's cells only where both portals are open. Uses occlusion queries to skip buildings that were fully occluded last frame.

  6. RenderOutsideIn (outside path, lines 241358): mirrors Step 12 but camera is outside looking in. Portal polygons mark Bit 1; depth is cleared at those pixels; EnvCells render through the mask. Terrain/scenery/statics draw normally (no stencil guard).

What GetVisibleBuildingPortals provides

PortalRenderManager.GetVisibleBuildingPortals returns a BuildingPortalGPU per building (not per cell or per portal). The BuildingPortalGPU is a triangle-fan tessellation of ALL portal polygons for the building concatenated into a single VAO/VBO. This is the flat union — there is no per-portal polygon tracking. One stencil pass per building.

EnvCellRenderManager.GenerateForLandblockAsync discovers cells recursively from building portals (portal.OtherCellId != 0xFFFF — exit portals are skipped). The seenOutsideCells set tracks cells with EnvCellFlags.SeenOutside but WB only stores this for diagnostic use; it does NOT gate the cell draw off SeenOutside.

How WB decides cell visibility for the filter

VisibilityManager.PrepareVisibility (lines 4771): when isInside, adds ALL cells of every building the camera is in (_buildingsWithCurrentCell). No per-portal traversal. No per-portal clip. No VisibleCells stab-list from the dat. The full cell set of the building is the filter.

When isInside=false, GetVisibleBuildingPortals returns frustum-visible building groups; ALL their cells are added to visibleEnvCells. Again, no per-portal traversal.

There is no WB equivalent of retail's per-cell VisibleCells (the PVS stab-list). WB never reads EnvCell.VisibleCells. WB's visibility is building-level, not cell-level.

Why the WB two-pipe diverges from retail's recursive PView

Retail PView::ConstructView (decomp ~433750) and ClipPortals (~433572):

Property WB RenderInsideOut Retail PView
Visibility unit Building (all cells in a building) Per-cell portal traversal
Clip granularity One stencil mask per building Per-portal screen-space clip polygon
Camera branching Hard isInside branch switching two completely different code paths No branch — "which cell is the camera in" changes only the BFS root, not the algorithm
Outdoor geometry gate Stencil Bit 1 derived from the portal polygon raster at the wall OutsideView clip polygon accumulated by clipping through exit portals in the BFS
Per-cell PVS Not used CEnvCell.stab_list + seen_outside read per cell; portal side test per edge
Scenery gating Outdoor scenery draws only where Bit 1 is set (all portals of the camera building) Outdoor entities/scenery assigned OutsideView clip slot; cull when OutsideView empty
Terrain gate Depth punched at portal pixels; terrain only draws stencil==1 TerrainClipMode: Skip when no exit portal visible, Planes/Scissor when one is
Other-building cells 2-bit stencil composed gate with occlusion query fallback Same recursive BFS — other buildings' cells are cells in the PVS; no separate pipe
Seam at doorway Inherent — the two pipes switch at currentEnvCellId != 0; the frame of the switch always tears None — outdoor/indoor are the same draw loop with different clip regions

Why acdream abandoned WB's two-pipe (2026-05-30)

From docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md:

"The A8.F effort tried to graft retail's recursive clip on top of WB's two-pipe stencil (a CPU-built NDC mask bridging the two pipes). That hybrid is inherently fragile and failed its visual gate (issue #103). You cannot make two pipes hand off seamlessly at a doorway; retail avoids the entire bug class by never splitting."

The specific failure modes WB's architecture cannot fix without replacing the architecture:

  1. The flap — The isInside / currentEnvCellId != 0 gate flips on the same frame the camera crosses the doorway. On the flip frame WB switches from RenderInsideOut to RenderOutsideIn (or vice versa). The stencil state from the prior frame is cleared; the new portal polygon raster hasn't loaded. There is one frame where the indoor draw is missing OR the outdoor draw bleeds through. This is inherent to the architecture.

  2. Terrain bleed indoors — WB's stencil Bit 1 mask is derived from the camera-building portal polygons rasterized with DepthFunc(Always) BEFORE terrain. If the portal polygon is degenerate, or the stencil clear races the portal raster, terrain bleeds through. The mask is not ground in the per-cell PVS (VisibleCells / SeenOutside) so it cannot make a definitive "no terrain here EVER" decision the way retail's seen_outside=false does for sealed dungeons.

  3. Transparent walls — In RenderOutsideIn, WB clears depth with uWriteFarDepth=1 at the portal stencil, then renders EnvCells into the cleared region. The wall geometry adjacent to the portal also had its depth cleared (the portal polygon doesn't exactly hug the wall opening), so a wall polygon's depth test can fail against the cleared far-plane, making it appear transparent.

  4. Outdoor scenery entities indoors — WB's RenderInsideOut gates terrain/scenery/static-objects through Bit 1 (only where the portal polygon rasters hit). But if the entity's ParentCellId is not tracked or is 0 (outdoor scenery has no indoor cell parent), it routes to the stencil unguarded path and draws everywhere.

  5. Cannot be fixed without rebuilding — The stencil approach is a GPU-side approximation of what retail does on the CPU (per-portal clip-polygon BFS). Every "fix" to the stencil approach adds more GPU state to paper over a case where the stencil and the intended visibility diverge. The correct fix replaces the stencil entirely with retail's CPU-driven visibility.

VERDICT: DO NOT REINTRODUCE the WB RenderInsideOut/RenderOutsideIn two-pipe stencil or any approximation of it. The architecture is the bug.


2. ACViewer — Cell/Portal Rendering and Sealing Approach

Source files:

  • references/ACViewer/ACViewer/Render/R_Landblock.cs
  • references/ACViewer/ACViewer/Render/R_EnvCell.cs
  • references/ACViewer/ACViewer/Render/Buffer.cs
  • references/ACViewer/ACViewer/Physics/Common/EnvCell.cs

What ACViewer does

ACViewer is a dat viewer and map editor (MonoGame/DirectX), not a game client. Its rendering is a brute-force draw with no runtime visibility or sealing:

R_Landblock.cs:83101 BuildEnvCells(): For each cell id from 0x100 to 0x100 + NumCells - 1 in the landblock, creates an R_EnvCell and adds it to the list. All cells for the landblock are built, with no portal traversal.

Buffer.Draw() / Buffer.DrawWithZSlicing(): Draws all batches in RB_EnvCell, RB_StaticObjs, RB_Buildings, RB_Scenery unconditionally (gated only by the Z-slicing filter for multi-floor dungeon inspection). No portal culling. No stencil. No clip.

R_EnvCell.Draw() (R_EnvCell.cs:7087): Calls DrawEnv() (sets xWorld + draws the environment cell struct mesh) + DrawStaticObjs() (draws each stab). No filter.

There is no portal-based visibility in ACViewer at all. ACViewer draws ALL cells and ALL objects in every loaded landblock every frame. Culling is done only by the MonoGame frustum culling on the DirectX state (backface culling ON in dungeon mode per Buffer.cs:166).

ACViewer's EnvCell data model — what it DOES read

EnvCell.cs (Physics/Common, the ACViewer version used by R_EnvCell):

  • VisibleCellIDs — list of low-byte cell IDs from the dat (stab_list / numStabs). This is the DAT-baked PVS for this cell. ACViewer reads it in the constructor (VisibleCellIDs = envCell.VisibleCells) and builds VisibleCells dict via build_visible_cells().
  • SeenOutsideenvCell.SeenOutside flag. ACViewer reads it.
  • Portals — the CellPortal[] list. ACViewer reads it.
  • find_visible_child_cell(Vector3 origin, bool searchCells) (lines 206231): Checks if origin is in this cell's AABB; if not, searches VisibleCells.Values (or falls back to Portals) for a cell containing origin. This is the retail CEnvCell::find_visible_child_cell ported to ACViewer's physics tree — it is the cell-membership resolver for moving objects in physics.

Critically, ACViewer reads and maintains the PVS / portal data but uses it ONLY for physics collision, not for rendering. The render path is entirely brute-force.

Does ACViewer seal interiors?

No. ACViewer does not try to occlude the outdoor world when drawing from inside a building. It draws everything: terrain + scenery + building geometry + dungeon cells simultaneously. It relies on correct depth testing to show the right surfaces. This works for a static viewer (you can rotate to any position and inspect geometry) but would be completely broken for a game client (outdoor terrain bleeds through dungeon floors when the camera is inside).

ACViewer is GPL-licensed (read for understanding only; do not copy code).

What is reusable from ACViewer

  • The EnvCell data model (especially find_visible_child_cell, build_visible_cells, VisibleCellIDs, SeenOutside): these are faithful ports of the retail data structures. acdream already has its own equivalent (LoadedCell.VisibleCells, LoadedCell.SeenOutside in CellVisibility.cs). The ACViewer source confirms the field interpretations.
  • CellPortal struct (PolygonId, OtherCellId, OtherPortalId, PortalSide): confirms the exact field layout. acdream's CellPortalInfo record matches.
  • Algorithm understanding: ACViewer's find_visible_child_cell confirms the retail pattern — first check point_in_cell(origin) (self), then search VisibleCells by AABB, then fallback to portal-linked neighbours. This is the retail CEnvCell::find_visible_child_cell at acclient_2013_pseudo_c.txt:311397.

3. ACE — Cell/Portal/Visibility Data Model

Source files:

  • references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs
  • references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs
  • references/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs
  • references/ACE/Source/ACE.DatLoader/Entity/BuildInfo.cs
  • references/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs
  • references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs

ACE is the server — it defines the canonical dat file format that both the retail client and acdream read. The data model here is authoritative.

EnvCell fields (ACE DatLoader, EnvCell.cs)

Field Type Purpose
Flags EnvCellFlags Bitflags: SeenOutside (0x1), HasStaticObjs (0x2), HasRestrictionObj (0x8)
Surfaces List<uint> Surface IDs (`0x08000000
EnvironmentId uint Environment dat id (`0x0D000000
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
  • PortalsList<CBldPortal> — the building-level portal list (distinct from the per-cell CellPortals)

Each CBldPortal has:

  • OtherCellId — the EnvCell low-byte id that this portal opens into (0xFFFF = no indoor side)
  • OtherPortalId — back-link into that cell's portal list
  • StabList — list of cells visible through this portal opening (the per-building-portal PVS)

This is the entry-point graph acdream uses in EnvCellRenderManager.GenerateForLandblockAsync (line 657) to discover which EnvCells belong to which building: start from BuildInfo.Portals, follow CBldPortal.OtherCellId → first EnvCell of the building → follow CellPortal connections recursively to discover all cells in the building.

WB's cell discovery correctly skips portal.OtherCellId == 0xFFFF — these are exit portals that lead outside, not to another EnvCell (EnvCellRenderManager.cs:658,780). Acdream's CellVisibility.GetVisibleCellsFromRoot also skips them (line 467) and sets HasExitPortalVisible.

What the seal means in the data model

A cell is "sealed" (truly indoor, no outdoor bleed) if:

  • SeenOutside == false: no exit portal reachable from this cell via the connectivity graph. Retail's CellManager::ChangePosition releases the landscape (terrain) when seen_outside is false on the current cell, because there is definitively nothing outdoor to draw.

A cell "sees outside" if:

  • SeenOutside == true: at least one exit portal (OtherCellId == 0xFFFF) is reachable. Terrain may be visible; the outdoor world should be drawn, clipped to the visible exit portal.

Acdream already reads this correctly. The LoadedCell.SeenOutside field is set from the dat flag (GameWindow.cs:7704), and rootSeenOutside = physicsRoot?.SeenOutside ?? true gates terrain.


4. Chorizite.ACProtocol

references/Chorizite.ACProtocol/ — generated from the protocol XML. No rendering-relevant files; it covers network wire types only. Not applicable to this cross-check.


5. AC2D

references/AC2D/ — C++ fixed-function OpenGL client. Does not implement indoor rendering (it renders everything via the server's authoritative Z; no client-side interior/exterior split). Not applicable to indoor rendering cross-check.


6. Reusability Table

Reference Component Reusable? License Caveat
WorldBuilder ObjectMeshManager, WbMeshAdapter, WbDrawDispatcher mesh pipeline YES (in-tree) MIT Keep — Phase U is about visibility, not mesh extraction
WorldBuilder EnvCellRenderManager.GenerateForLandblockAsync cell discovery via CBldPortal / recursive CellPortal YES (in-tree, adapted) MIT The discovery loop is correct; WB already skips 0xFFFF exit portals
WorldBuilder EnvCellRenderManager.PrepareRenderBatches frustum + filter YES (in-tree) MIT Used by acdream's EnvCellRenderer; only the visibility FILTER needs replacing (use stab-list PVS instead of per-building set)
WorldBuilder PortalRenderManager.GetVisibleBuildingPortals + BuildingPortalGPU mesh AVOID MIT This is the per-building stencil mesh — only useful for WB's two-pipe stencil; not needed for a unified PView pipeline
WorldBuilder VisibilityManager.RenderInsideOut / RenderOutsideIn two-pipe stencil DO NOT USE MIT Root cause of all indoor seam bugs; see §1
WorldBuilder EnvCellFlags.SeenOutside / CBldPortal.OtherCellId != 0xFFFF sentinel YES (already used) MIT Data semantics confirmed correct
ACViewer EnvCell.find_visible_child_cell READ-ONLY (GPL) GPL Confirms retail CEnvCell::find_visible_child_cell interpretation; already ported in acdream's CellVisibility
ACViewer EnvCell.build_visible_cells + VisibleCells dict READ-ONLY (GPL) GPL Confirms PVS stab-list usage; acdream has LoadedCell.VisibleCells equivalent
ACViewer Brute-force render of all cells (Buffer.cs) DO NOT USE GPL No visibility, no seal, not usable for a game client
ACE EnvCell dat file format (all fields) YES (already used) AGPL acdream reads these via DatReaderWriter; field semantics confirmed authoritative
ACE CellPortal.OtherCellId == 0xFFFF exit-portal sentinel YES (already used) AGPL Critical for detecting the indoor/outdoor boundary
ACE LandblockInfo.Buildings / BuildInfo.Portals entry-point graph YES (already used) AGPL The cell discovery starting point; WB and acdream both use this correctly
ACE CBldPortal.StabList (per-building-portal PVS) INVESTIGATE AGPL Per-portal stab-list not currently used by acdream; may supplement the per-cell stab-list for cross-building portal resolution

7. Recommendations for Phase U Redesign

Adopt

  1. Retail PView portal-traversal as the single unified visibility pass (retail anchor: PView::ConstructView ~433750, ClipPortals ~433572, GetClip ~432344). acdream already has PortalVisibilityBuilder and CellVisibility.GetVisibleCellsFromRoot which are correct unit-tested ports. These are the keepers.

  2. Single code path regardless of camera position. The camera being inside or outside an EnvCell changes only which LoadedCell is the BFS root — null (outdoor root: player in a LandCell) vs non-null (indoor root: player in an EnvCell). The draw algorithm does NOT branch on this.

  3. Per-cell PVS stab-list (LoadedCell.VisibleCells) as the filter ground. Retail's grab_visible_cells (decomp 311878) loads the stab-list into VisibleCells at cell entry time. The per-frame BFS walks portal connectivity PLUS the stab-list to determine the drawable set. The stab-list is precomputed by AC's content tools and is more conservative than a per-frame portal clip (it includes cells reachable from any viewpoint in the cell, not just from the current camera position). Use it as the BFS frontier expansion to avoid missed cells at glancing angles.

  4. SeenOutside gate for terrain (already in acdream at GameWindow.cs:7177). seen_outside=false → skip terrain entirely (pure dungeon). seen_outside=true and an exit portal is visible → draw terrain clipped to the OutsideView clip region. This is retail's CellManager::ChangePosition @ 0x004559B0 behavior.

  5. CellPortal.OtherCellId == 0xFFFF as the outdoor-world gate (already in acdream at CellVisibility.cs:467, PortalVisibilityBuilder). Every exit portal reached in the BFS contributes to OutsideView. When OutsideView is empty, NOTHING outdoor draws (terrain, scenery, outdoor entities all cull). This is the closed seal.

  6. Outdoor scenery/entity gating via ParentCellId (already in WbDrawDispatcher.ResolveEntitySlot):

    • Live-dynamic entities (server GUID != 0): always slot 0 (no clip) — retail draws players/NPCs through depth without portal clipping.
    • Indoor entities (ParentCellId is a full EnvCell id): route to that cell's clip slot; cull if the cell is not in the visible set.
    • Outdoor scenery/statics (ParentCellId == null or is a LandCell id): route to OutdoorSlot; cull when OutdoorVisible = false (no exit portal in view). This is the outdoor-scenery seal.
  7. The WB-derived mesh pipeline (ObjectMeshManager, WbMeshAdapter, WbDrawDispatcher, TerrainModernRenderer) is NOT the visibility problem and should not be replaced. Phase U replaces the draw ORCHESTRATION (what gets drawn, when, with what clip), not the mesh extraction (what vertices are in the VBO).

  8. EnvCellRenderer.PrepareRenderBatches with an explicit filter set (already wired: envCellShellFilter in GameWindow.cs:7333). The filter set is the BFS-visible cell ids from ClipFrameAssembly.CellIdToSlot.Keys. This correctly excludes cells outside the visible set (e.g. the other side of a multi-story building when the player is only in one floor).

Avoid

  1. WB's RenderInsideOut / RenderOutsideIn two-pipe stencil — abandoned 2026-05-30, do not reintroduce. The architecture is the bug, not a parameter of the architecture.

  2. Per-building stencil mesh (BuildingPortalGPU, PortalRenderManager.RenderBuildingStencilMask, the _stencilShader + InitializeStencilShader machinery from WB) — only useful for WB's stencil two-pipe. If acdream needs per-portal depth-clip, the retail mechanism is a software clip plane (gl_ClipDistance) set from the per-portal NDC clip region, not a GPU stencil.

  3. The isInside / cameraInsideBuilding gate — this is the two-pipe switch. Phase U's redesign must not have any version of this. The outdoor case is root == null (player in a LandCell); the indoor case is root != null (player in an EnvCell). These are inputs to the SAME algorithm, not selectors for different algorithms.

  4. ACViewer's brute-force all-cells draw — usable for map viewing tools, not for a game client. The Buffer.Draw() approach will render hundreds of EnvCells including those in completely different buildings on the other side of the landblock, causing massive overdraw and incorrect visibility.

  5. Any "grace frame" or fallback AABB resolver for the portal-visibility root. The root comes from the physics-ownership answer (CellGraph.CurrCell) exclusively — retail's CellManager::ChangePosition reads the transition-owned curr_cell with no AABB fallback. Stage 3 (2026-06-02) deleted the FindCameraCell AABB grace-frame fallback from acdream.

  6. Re-porting the outdoor-terrain or EnvCell mesh extraction from retail decomp. The WB inventory doc (docs/architecture/worldbuilder-inventory.md) classifies these as green (already correctly extracted from WB into src/AcDream.{Core,App}/Rendering/Wb/). The rendering orchestration is 🔴 (must come from retail), the mesh extraction is 🟢 (WB has a tested port). Do not re-port what WB already got right.


8. Summary of WB-vs-Retail Divergence (10-line version)

  1. WB branches hard on isInside (camera in ANY EnvCell) → two completely different render paths.
  2. Retail has ONE path — portal-traversal BFS from the camera cell; indoor and outdoor are just cells.
  3. WB's "seal" is a per-building GPU stencil derived from portal polygon rasters (flat, building-level).
  4. Retail's seal is the CPU-derived OutsideView clip polygon (recursive, per-portal, per-cell).
  5. WB uses NO per-cell PVS stab-list (VisibleCells) for rendering; retail uses it as the BFS frontier.
  6. WB's outdoor gate (terrain/scenery draw only where stencil=1) fails at doorway-crossing frames (the flap).
  7. Retail's outdoor gate (terrain clips to OutsideView; skip when empty) is frame-exact and derived from the same BFS as the cell draw.
  8. WB cannot express per-portal clip precision (one stencil per building); retail clips each portal opening independently.
  9. WB's approach is sound for a static dat-viewer where you never cross thresholds; it is architecturally wrong for a live game client.
  10. The Phase U unified pipeline (retail PView port) is the correct fix; grafting anything onto WB's two-pipe stencil is not.

Key File Paths Referenced

  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs — WB RenderInsideOut/RenderOutsideIn (full stencil two-pipe; read for understanding; DO NOT reintroduce)
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs — WB building portal GPU mesh + stencil shader
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs — WB cell discovery loop (reusable), PrepareRenderBatches with filter (reusable)
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs lines 8801008 — the isInside branch; root of the two-pipe problem
  • references/ACViewer/ACViewer/Physics/Common/EnvCell.csfind_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.csOtherCellId == 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