Canonical handoff: docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md (what shipped: membership Stage 1 ordered-CELLARRAY port + the blue-hole render-root clobbering fix; the full remaining-issues list — A camera-collision, B R1b particles, C R2 outside-looking-in, Stage 2 membership, #7 stairs, the 5-test baseline; KEEP/ DON'T-REDO; key files + decomp anchors; copy-paste pickup prompt for next session). - ISSUES.md: recorded the cottage doorway flap DONE (both causes) in Recently closed. - render design spec §7: R1 + flap marked DONE; A/B/C mapped to the next render phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25 KiB
Render Pipeline Redesign — Locked Design Spec (R0 outcome, 2026-06-02)
This is the design locked in the R0 brainstorm. It supersedes the open questions in
docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md§3 with concrete decisions, and is the inputwriting-plansturns into the implementation plan.Read first (context, in order):
docs/research/2026-06-02-render-pipeline-redesign-handoff.md— proven root cause (§2), the three-gate failure (§3), the retail target (§5).docs/research/2026-06-02-retail-render-pipeline-full-reference.md— the retail PView pipeline + theDrawCellsseal mechanics (the algorithm being ported).docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md— the concrete bugs (theWbDrawDispatcher.cs:1756bypass; the parallel BFS; the terrain Skip model).docs/research/2026-06-02-render-reference-crosscheck.md— why WB's two-pipe stencil is the wrong model (do NOT reintroduce).
0. Mandate (user, 2026-06-02 — 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 (the oracle is docs/research/named-retail/);
do more research mid-session rather than guess.
1. Decisions locked in the R0 brainstorm
These are the resolutions to the plan §3 open questions, decided with the user this session. They are binding for the implementation plan.
-
Object / entity / particle draw = LITERAL PER-CELL LOOP. A faithful port of retail
PView::DrawCells' per-cell loops (iteratecell_draw_listclosest-first; draw each visible cell's shell + objects + particles, clipped to that cell's portal-derived region). NOT the alternative of keeping one globalglMultiDrawElementsIndirectpass with per-instance clip slots. The user's stated priority order: retail-structural fidelity > perf > minimal blast-radius. -
Sequencing = HOLISTIC. Build the per-cell
DrawInsidedirectly. Do not ship an intermediate "fix the gate on the existing global pass" step. The first phase is the full per-cellDrawInside; the first visual gate is the sealed cottage interior with no bleed. (Verification with the probes + the user's eyes still happens at natural checkpoints within the per-cell build — that is evidence discipline on the real architecture, not the rejected throwaway intermediate.) -
Terrain in the seal = FAITHFUL. Terrain (and outdoor scenery, and sky/weather) is drawn only through the exit-portal clip region, never as a floor under the interior. The inventory doc's "REDESIGN item #4" suggestion — relax
TerrainClipMode.Skipforseen_outside=truecottages so the cellar floor stops going grey — is REJECTED as a non-retail workaround. The "grey floor / grey world in cellar" symptom is a sealing bug (the closed cell mesh is not covering those pixels: a missing or back-facing floor polygon, or the GL clear color showing through a gap). The faithful fix is to make the cell mesh seal; verified by the[shell]probe + a dat dump of the cellar EnvCell mesh. -
WB mesh pipeline KEPT; orchestration restructured. The global VAO/VBO/IBO, GfxObj decode, and texture cache stay. What changes is that draws are issued per visible cell from those global buffers (batched within a cell where the geometry allows), not one global batch across all entities. "Keep the WB mesh pipeline, restructure the orchestration" still holds.
-
Two-camera invariant PRESERVED. The 3rd-person eye drives the projection (
envCellViewProj); the player's physics cell (CellGraph.CurrCell) roots visibility and the portal-side test. This is the U.4c flap fix and it is kept verbatim.
2. The one model (the inversion)
There is one top-level decision per frame — a faithful port of retail
SmartBox::RenderNormalMode @ 0x453aa0 (pc:92635):
RenderWorld(viewer):
if clipRoot == null: # viewer in an outdoor LandCell (retail is_player_outside: id&0xFFFF < 0x100)
DrawOutside() # today's outdoor path: terrain + scenery entities + sky/weather.
# building INTERIORS reached via DrawPortal (R2 — outside-looking-in).
else: # viewer in an EnvCell (clipRoot != null)
DrawInside(clipRoot) # ONE PView flood — nothing else. The outdoor world is NOT drawn;
# it enters ONLY through clipped exit portals, inside DrawInside.
DrawInside is a faithful port of PView::DrawInside @ 0x5a5860 → ConstructView @ 0x5a57b0 →
DrawCells @ 0x5a4840:
DrawInside(cell):
frame = PortalVisibilityBuilder.Build(cell, playerPos, eyeViewProj) # KEEP — the PVS is correct
if frame.OutsideView non-empty: # an exit portal is in view
DrawLandscapeClippedTo(frame.OutsideView) # terrain + outdoor scenery entities + sky, thru the doorway
ConditionalDepthOnlyClear(frame.OutsideView)# Z only — never color → no blue hole
for cellId in frame.OrderedVisibleCells: # the per-cell loop (retail DrawCells loops 1-3)
clip = frame.CellViews[cellId]
DrawCellShell(cellId, clip) # EnvCellRenderer.Render(pass, {cellId}) — closed mesh
DrawCellObjects(cellId, clip) # ONLY this cell's entities, clipped to its region
DrawCellParticles(cellId, clip) # ONLY this cell's particles — solves #104 for free
Visibility is the cull. No global entity pass; no second visibility computation. The bleed cannot happen by construction — the outdoor world is never iterated when inside (except the clipped doorway). This is the inversion of the current bug: acdream draws the outdoor world and then gates a few cell shells on top through three inconsistent gates (handoff §3); retail never visits the outdoor world when inside.
3. Keep / Build / Remove
KEEP (proven correct — handoff §4, inventory §5)
PortalVisibilityBuilder— the PView BFS. ProducesOutsideView,CellViews(per-cell NDC clip),OrderedVisibleCells(closest-first),CrossBuildingViews. The single visibility authority.ClipFrame/ClipFrameAssembler/ClipPlaneSet/PortalView— the clip-plane machinery and the per-cell NDC→GPU slot packing. (ClipFrameAssemblyalready exposesCellIdToSlot,OutdoorSlot,OutdoorVisible,TerrainMode,HasOutsideView,OutsideViewNdcAabb— everything the seal needs.)EnvCellRenderermesh/MDI/texture path, includingRender(pass, filter)(a single-cell filter set drives one cell — the hook the per-cell loop uses).TerrainModernRenderer(the renderer; only its caller's clip mode changes).- The WB mesh pipeline (global VAO/VBO, GfxObj decode,
TextureCache). - The membership fix
59f3a13([cell-transit]confirms correct cell tracking — do NOT reopen). - The Stage-4 sky NDC-clip + the conditional doorway Z-clear (
ce2edad/b595cfb). - All diagnostic probes (
ACDREAM_PROBE_CELL/_VIS/_SHELL/_FLAP).
BUILD (the redesign)
- A new
DrawInsideorchestrator (the §2 per-cell loop) that replaces the current "global terrain → global shells → global entity pass" structure inGameWindow.OnRender(~7250–7610). - The binary top-level decision (
clipRoot == null→DrawOutside; else →DrawInsideonly). - A per-cell entity dispatch issued from the global buffers, batched within a cell.
- A per-cell particle draw (each cell's particles in its clip scope).
- The landscape-through-the-door composition (terrain + outdoor scenery + sky clipped to
OutsideView)- the conditional Z-only clear.
- (R2) the outside-looking-in
DrawPortalpath (separate outdoor pview).
REMOVE (dead / divergent — scheduled in the final polish phase so it can't destabilize earlier work)
- The dormant WB-two-pipe scaffolding:
Building,BuildingLoader,ExitPortalPolygonsstencil-marking, the occlusion-query state (QueryId/WasVisible), and theIsShellScopedSet/BuildingShellAnchorCellIdanchor machinery (IsShellScopedSetalready returnsfalse— U.1 deleted the live two-pipe). CellVisibilityas a rendering gate — itsVisibleCellIdsset retires as the entity gate. ItsCameraCell/ root-selection role (the indoor/outdoor root decision) stays.- The
CullMode.Landblock → Nonedouble-sided stopgap (EnvCellRenderer.cs:~1216) — replace with the correct per-polygon winding once the seal is verified.
4. The seal mechanics (PView::DrawCells @ 0x5a4840 port)
Two parts: the landscape-through-the-door block, then the per-cell loop. (Full retail verbatim: research doc A §4.)
4.1 Landscape through the door (only when OutsideView is non-empty)
- Draw the "landscape" clipped to the doorway's screen silhouette (
OutsideView). Retail does this with oneLScape::draw(PortalList = this). In acdream the landscape is split across three renderers, so this one step becomes:- terrain (
TerrainModernRenderer, gated by the clip-plane UBO in Planes mode, orglScissorto theOutsideViewAABB in Scissor mode), - outdoor scenery entities (
ParentCellId == null, clipped toOutsideView/OutdoorSlot), - sky / weather (
SkyRenderer,gl_ClipDistanceagainst the same UBO — the Stage-4 path, kept).
- terrain (
- Then a conditional depth-only clear scissored to the
OutsideViewAABB (Clear(4, …)— flag 4 = depth buffer only, retail pc:432731). Color is preserved (terrain painted through the door stays); depth resets so interior geometry composites without z-fighting at the doorway edge. There is NEVER a color clear in the indoor path — that is structurally why there is no blue hole.
4.2 The per-cell loop over OrderedVisibleCells (closest-first; each cell clipped to CellViews[cellId])
- Shell (retail DrawCells Loop 2 —
DrawEnvCell):EnvCellRenderer.Render(pass, {cellId})draws the cell's closed mesh (floor + walls + ceiling) from the cell's dat geometry. The ceiling and floor seal because they are authored in the dat mesh — there is no "cap the ceiling" step. Doorways are genuine holes in the mesh, so the landscape from §4.1 shows through them. (Retail Loop 1 stencils the exit-portal openings; acdream's mesh-hole + the §4.1 Z-clear achieve the same composition — R1 verifies whether an explicit portal stencil is needed or the mesh-hole + Z-clear suffice.) - Objects (Loop 3 —
DrawObjCellForDummies): only this cell's entities (object_listequivalent), clipped to the cell's region. Live-dynamic entities (player/NPCs/items,serverGuid != 0) render unclipped (depth only) per retail. - Particles: only this cell's particles, in the cell's clip scope (closes #104 — no per-instance slot needed because the draw is already per-cell).
- Self-contained GL state: each per-cell draw SETS every GL state it depends on (view-proj, blend,
depth-mask, cull, front-face, A2C) — never inherited (memory
render-self-contained-gl-state; this bitEnvCellRendererthree times in U.4).
4.3 Why the seal holds (the four guarantees — retail doc A §4.5)
- No blue hole: outdoors is drawn first (clipped to
OutsideView); the only clear is Z-only + conditional; color survives in the doorway. - Sealed ceiling/walls/floor: each visible cell's mesh is a closed box; portal holes are the only openings.
- No outdoor bleed-in: the landscape paints only through exit-portal clip regions; if
OutsideViewis empty (dungeon, or facing away from the door), no terrain/sky is drawn at all. - No object/particle bleed: objects/particles are drawn per-cell, only for cells in
OrderedVisibleCells, clipped to the cell's region.
5. The per-cell loop & batching (how the mesh pipeline is preserved)
The user chose the literal per-cell loop over global MDI; this section pins down how that coexists with the KEPT mesh pipeline so the plan does not regress perf catastrophically or break the working mesh path.
- Geometry storage is unchanged. All cell shells and entity meshes live in the global VAO/VBO/IBO; each
batch references its slice via
BaseVertex/FirstIndex. The per-cell loop issues draws against those slices — it does not re-upload or re-pack geometry per cell. - Batched within a cell. Each cell's draw still groups by mesh/material and issues an MDI call for that cell's object set (per-cell MDI), rather than a draw call per object. So we lose cross-cell batching (the retail-faithful cost the user accepted) but keep within-cell batching. A cottage interior is a handful of visible cells, so the per-cell call count is small.
- Clip is the cell's region, applied per-cell. Because the loop is per-cell, the cell's clip region is
bound once before that cell's draws (clip-plane UBO /
gl_ClipDistance), not per-instance. This is what lets particles (no instanceID) be clipped — the #104 win. EnvCellRendereris driven with a single-cell filter (Render(pass, {cellId})) per loop iteration, or a small per-cell render entry is added if the snapshot model needs it (decided in the plan; the existing filter path is the starting point).- Entity dispatch becomes per-cell: instead of
WbDrawDispatcher.Draw(... visibleCellIds ...)walking all entities once, the orchestrator asks for each visible cell's entities (entities whoseParentCellId== cellId) and issues that cell's batched draw. The outdoor-scenery entities (ParentCellId == null) are drawn in the §4.1 landscape-through-door step, clipped toOutsideView, NOT in any interior cell's loop.
Perf note (risk, not a blocker): per-cell dispatch raises draw-call count vs the single global MDI. The N.6 baseline showed CPU dominates GPU by 30–50× and the GPU sits at ~3.6% of frame budget, so there is headroom; a cottage is a few cells. If a complex interior or dungeon regresses, within-cell batching + the closest-first order (early-Z) are the mitigations. Measure at the R1 gate; do not pre-optimize.
6. Component-level design
| Concern | Component | Change |
|---|---|---|
| Top-level decision | GameWindow.OnRender |
New binary branch: clipRoot == null → DrawOutside (existing); else → DrawInside only. Remove the "global outdoor draw then shells/entities on top" structure. |
| Visibility | PortalVisibilityBuilder |
Keep. Sole authority. Root at the player cell, project from the eye (§1.5 invariant). |
| Clip packing | ClipFrameAssembler / ClipFrame |
Keep. Per-cell clip slots + OutsideView already produced; the per-cell loop consumes CellViews directly and/or the assembled slots. |
| Indoor orchestration | new DrawInside orchestrator (App layer) |
The §2 per-cell loop. Owns: landscape-through-door, conditional Z-clear, the per-cell shell/object/particle draws. Lives in AcDream.App.Rendering (not GameWindow.cs per Code Structure Rule 1). |
| Cell shells | EnvCellRenderer |
Driven per-cell (Render(pass, {cellId})). Keep the mesh/MDI/texture path. Self-contained GL state. |
| Entities | WbDrawDispatcher |
Restructured: per-cell entity draw (entities by ParentCellId == cellId); outdoor scenery drawn in the landscape-through-door step. Delete the :1756 ParentCellId==null → return true bypass and the dead IsShellScopedSet branch. |
| Particles | ParticleRenderer |
Per-cell draw in the cell's clip scope (give particles a cell via the owning entity's ParentCellId). Closes #104. |
| Terrain | TerrainModernRenderer |
Drawn only in the landscape-through-door step (Planes/Scissor clip). When OutsideView empty → not drawn (faithful Skip). |
| Sky/weather | SkyRenderer |
Drawn in the landscape-through-door step, gl_ClipDistance clip (Stage-4, kept). |
| Root selection | CellVisibility |
Keep CameraCell / root selection. Retire VisibleCellIds as a render gate. |
| Outside-looking-in | new DrawPortal path (R2) |
Separate outdoor pview; ConstructView(CBldPortal) recursion; DrawCells the interior through the door's clip. |
7. Phase plan (holistic) + visual gates + retail anchors
Each phase ends GREEN (build +
dotnet test) and at a user visual gate. A seal is verified on screen (probes + the user's eyes), never off the test suite — the lesson that produced this redesign (handoff §9). Phases are NOT batched past a gate.
R1 — Unified per-cell DrawInside (the core) — THE make-or-break phase
STATUS 2026-06-03: R1 + the doorway flap are DONE (user-verified inside-looking-out). The flap had TWO causes, both fixed: (1) the membership pick — ported the verbatim ordered-
CELLARRAYfind_cell_listpick + collide-then-pick (Stage 1); (2) render-root clobbering —CellGraph.CurrCellwas written by the per-entityResolveWithTransition, so a jumping NPC near the doorway overwrote the player's render root → the "blue hole"; fixed by makingCurrCellplayer-only (UpdatePlayerCurrCell). Inside-looking-out renders correctly. Remaining residuals are the next phases, NOT R1 regressions: A = camera-collision (walls grey while inside; eye outside the cell) → fold into R4 or a focused phase; B = particles through the floor → R1b (#104); C = transparent walls from the street → R2 below. Canonical:docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md.
Retail anchors: RenderNormalMode @ 0x453aa0 (binary decision), PView::DrawInside @ 0x5a5860,
ConstructView @ 0x5a57b0, DrawCells @ 0x5a4840 (the seal + the three per-cell loops), fact 8
(visibility is the cull).
- Build the binary top-level decision (indoor →
DrawInsideonly; the global outdoor pass is not issued). - Build the per-cell loop: landscape-through-door (terrain + outdoor scenery + sky, clipped to
OutsideView) → conditional Z-only clear → per-cell closed shells → per-cell objects → per-cell particles. - Delete the
WbDrawDispatcher.cs:1756bypass (outdoor scenery now enters only via the landscape-through-door step). Retire theCellVisibility.VisibleCellIdsrender gate. - Verify the cell mesh seals (the grey-floor investigation):
[shell]probe + a dat dump of the cellar EnvCell mesh; confirm floor + walls + ceiling are present and front-facing; decide stencil-vs-mesh-hole for the doorway (§4.2). - Internal checkpoints (probes + eyes, on the real per-cell architecture): (a) sealed shells + landscape-thru-door; (b) per-cell objects (no entity bleed); (c) per-cell particles (no smoke bleed).
- Gate (visual): Holtburg cottage + cellar — sealed interior (opaque walls, solid floor, ceiling), sky/rain through the door only, no blue hole, no terrain under the floor, no grey floor, no entity/scenery/particle bleed.
R2 — Outside-looking-in (DrawPortal)
Retail anchors: PView::DrawPortal @ 0x5a5ab0, ConstructView(CBldPortal) @ 0x5a59a0, fact 9.
- On the outdoor path, for each visible building door, run a separate
outdoor_pview:ConstructView(CBldPortal)(side-test the door plane, clip to the opening) → recurse into the interior →DrawCellsthe interior through the door's clip. Same machinery asDrawInside. - Gate (visual): standing in the street facing the cottage door/window, the sealed interior renders through the opening — not transparent walls.
R3 — Dungeons
Retail anchors: fact 10 (emergent — all-EnvCell, seen_outside == 0, no exit portals), the
update_count watermark (fact 12 / #95), grab_visible_cells @ 0x52e220 (landscape iff seen_outside).
- Validate the all-EnvCell path on a real dungeon:
OutsideViewstays empty → no terrain/sky by construction; sealed cells; the watermark BFS converges (confirm #95 closed — no FPS collapse). - Gate (visual): a real dungeon is sealed, no terrain/sky, no FPS collapse from the BFS.
R4 — Polish + cleanup + conformance
- Resolve the
CullMode.Landblock → Nonedouble-sided stopgap (the real per-polygon winding). - Remove the dormant WB-two-pipe scaffolding (
Building,BuildingLoader, stencil/occlusion, anchor machinery). - Headless conformance tests (G1–G4 below). Confirm no missing textures across dungeons.
- Update the roadmap; flip the M1.5 milestone; memory notes.
- Gate (visual): cottage + dungeon + outside-looking-in, all sealed and seamless.
8. Risks
- R1 is a large phase (the holistic choice). Mitigation: build behind internal probe/eyes checkpoints on
the real per-cell architecture; keep the outdoor path working throughout (the 99% case is unaffected — the
binary branch leaves
DrawOutsideintact). - Per-cell draw-call count vs the single global MDI (§5). Mitigation: within-cell batching + closest-first early-Z; measure at the R1 gate; the N.6 baseline shows large CPU/GPU headroom.
- The grey-floor cause is unconfirmed — it may be a missing dat floor polygon, a winding/cull issue, or a clear-color gap. Mitigation: evidence-first in R1 (probe + dat dump) before any fix; do not relax the faithful terrain Skip (decision §1.3).
- Do not reintroduce the abandoned approaches (handoff §9): no stencil two-pipe, no
isInsidegate, no AABB grace-frame for the visibility root.
9. No-shortcuts rules (enforced on every task — plan §5)
- A fast-but-wrong path is rejected for the retail-faithful path; the tradeoff is noted in the commit.
- No suppression flags, grace periods, or
if (problem) return earlyguards at a symptom site. - Every AC-specific behavior cites a retail decomp anchor (address + pseudo-C line).
- Mid-session research over guessing — if the retail behavior is unclear, read the decomp / attach cdb.
- Each phase ends GREEN (build + tests) AND at a user visual gate. The seal is verified on screen.
10. Conformance / acceptance gates (headless asserts — retail doc A §7 CL-G)
- G1 (cottage,
seen_outside): interior sealed; sky/rain through the door; no blue hole; no transparent walls; no bleed.OutsideViewnon-empty ⇒ the landscape-through-door step runs. - G2 (dungeon,
seen_outside == 0): sealed; no terrain/sky;OutsideViewempty ⇒ landscape step NOT run; BFS converges (watermark) without blowup (#95). - G3 (outside-looking-in): facing an open cottage door from the street, the interior renders through the doorway (R2).
- G4 (invariants): PVS root id == physics
CurrCell.Idevery frame; a cell receiving two clip slices is processed once per slice (watermark); eye drives projection, player cell roots visibility.
11. Decomp anchor index (verified in the research docs this session)
SmartBox::RenderNormalMode 0x00453aa0 pc:92635 binary decision (DrawInside vs LScape::draw)
SmartBox::is_player_outside 0x00451e80 pc:90996 low-word objcell_id < 0x100
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)
PView::DrawInside 0x005a5860 pc:433793
PView::ConstructView(CEnvCell) 0x005a57b0 pc:433750 the BFS worklist
PView::ConstructView(CBldPortal) 0x005a59a0 pc:433827 exterior→interior recursion (outside-looking-in)
PView::InitCell 0x005a4b70 pc:432896 per-portal sidedness; update_count = view_count
PView::ClipPortals 0x005a5520 pc:433572 exit portal → outside_view (@433662); interior → OtherPortalClip
PView::AddViewToPortals 0x005a52d0 pc:433446 enqueue neighbours / SetOtherSeen
PView::DrawCells 0x005a4840 pc:432709 LScape-thru-door + Z-clear + 3 per-cell loops
PView::DrawPortal 0x005a5ab0 pc:433895 outside-looking-in entry
CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 point → child cell via portals/stab_list (camera child)
LScape::draw 0x00506330 terrain+sky; clipped via Render::PortalList
portal_view_type.update_count acclient.h:32346 watermark — BFS convergence (#95/#102)
CCellStruct drawing_bsp acclient.h:32275 closed cell mesh (floor+walls+ceiling); no cap step
CCellPortal.other_cell_id acclient.h:32300 0xFFFFFFFF (low 0xFFFF) ⇒ EXIT PORTAL