acdream/docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md
Erik 7aca79f8eb docs(render): Phase R0 — lock the render-redesign design spec (brainstorm outcome)
Resolves the plan §3 open questions with the user this session:
- object/entity/particle draw = LITERAL PER-CELL LOOP (retail DrawCells),
  not a global MDI batch with per-instance clip. Fidelity > perf > blast-radius.
- sequencing = HOLISTIC: build the per-cell DrawInside directly; no intermediate
  global-pass gate-fix. First visual gate = sealed cottage interior, no bleed.
- terrain in the seal = FAITHFUL: drawn only through the exit-portal clip, never
  as a floor under the interior. Inventory's 'relax Skip' suggestion REJECTED as a
  non-retail workaround; grey-floor = a sealing bug (verify cell mesh in R1).
- WB mesh pipeline KEPT (per-cell draws from the global buffers, batched within a
  cell); two-camera invariant preserved (eye projects, player cell roots visibility).

Phases (holistic): R1 unified per-cell DrawInside (the core) -> R2 outside-looking-in
(DrawPortal) -> R3 dungeons -> R4 polish+cleanup. Each ends GREEN + a user visual gate.
Retail anchors cited throughout (RenderNormalMode 0x453aa0, DrawCells 0x5a4840, etc).

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

24 KiB
Raw Blame History

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 input writing-plans turns into the implementation plan.

Read first (context, in order):

  1. docs/research/2026-06-02-render-pipeline-redesign-handoff.md — proven root cause (§2), the three-gate failure (§3), the retail target (§5).
  2. docs/research/2026-06-02-retail-render-pipeline-full-reference.md — the retail PView pipeline + the DrawCells seal mechanics (the algorithm being ported).
  3. docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md — the concrete bugs (the WbDrawDispatcher.cs:1756 bypass; the parallel BFS; the 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).

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.

  1. Object / entity / particle draw = LITERAL PER-CELL LOOP. A faithful port of retail PView::DrawCells' per-cell loops (iterate cell_draw_list closest-first; draw each visible cell's shell + objects + particles, clipped to that cell's portal-derived region). NOT the alternative of keeping one global glMultiDrawElementsIndirect pass with per-instance clip slots. The user's stated priority order: retail-structural fidelity > perf > minimal blast-radius.

  2. Sequencing = HOLISTIC. Build the per-cell DrawInside directly. Do not ship an intermediate "fix the gate on the existing global pass" step. The first phase is the full per-cell DrawInside; 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.)

  3. 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.Skip for seen_outside=true cottages 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.

  4. 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.

  5. 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 @ 0x5a5860ConstructView @ 0x5a57b0DrawCells @ 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. Produces OutsideView, 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. (ClipFrameAssembly already exposes CellIdToSlot, OutdoorSlot, OutdoorVisible, TerrainMode, HasOutsideView, OutsideViewNdcAabb — everything the seal needs.)
  • EnvCellRenderer mesh/MDI/texture path, including Render(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 DrawInside orchestrator (the §2 per-cell loop) that replaces the current "global terrain → global shells → global entity pass" structure in GameWindow.OnRender (~72507610).
  • The binary top-level decision (clipRoot == nullDrawOutside; else → DrawInside only).
  • 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 DrawPortal path (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, ExitPortalPolygons stencil-marking, the occlusion-query state (QueryId/WasVisible), and the IsShellScopedSet / BuildingShellAnchorCellId anchor machinery (IsShellScopedSet already returns false — U.1 deleted the live two-pipe).
  • CellVisibility as a rendering gate — its VisibleCellIds set retires as the entity gate. Its CameraCell / root-selection role (the indoor/outdoor root decision) stays.
  • The CullMode.Landblock → None double-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 one LScape::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, or glScissor to the OutsideView AABB in Scissor mode),
    • outdoor scenery entities (ParentCellId == null, clipped to OutsideView / OutdoorSlot),
    • sky / weather (SkyRenderer, gl_ClipDistance against the same UBO — the Stage-4 path, kept).
  • Then a conditional depth-only clear scissored to the OutsideView AABB (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_list equivalent), 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 bit EnvCellRenderer three times in U.4).

4.3 Why the seal holds (the four guarantees — retail doc A §4.5)

  1. No blue hole: outdoors is drawn first (clipped to OutsideView); the only clear is Z-only + conditional; color survives in the doorway.
  2. Sealed ceiling/walls/floor: each visible cell's mesh is a closed box; portal holes are the only openings.
  3. No outdoor bleed-in: the landscape paints only through exit-portal clip regions; if OutsideView is empty (dungeon, or facing away from the door), no terrain/sky is drawn at all.
  4. 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.
  • EnvCellRenderer is 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 whose ParentCellId == 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 to OutsideView, 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 3050× 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 == nullDrawOutside (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

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 → DrawInside only; 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:1756 bypass (outdoor scenery now enters only via the landscape-through-door step). Retire the CellVisibility.VisibleCellIds render 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 → DrawCells the interior through the door's clip. Same machinery as DrawInside.
  • 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: OutsideView stays 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 → None double-sided stopgap (the real per-polygon winding).
  • Remove the dormant WB-two-pipe scaffolding (Building, BuildingLoader, stencil/occlusion, anchor machinery).
  • Headless conformance tests (G1G4 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 DrawOutside intact).
  • 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 isInside gate, no AABB grace-frame for the visibility root.

9. No-shortcuts rules (enforced on every task — plan §5)

  1. A fast-but-wrong path is rejected for the retail-faithful path; the tradeoff is noted 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.

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. OutsideView non-empty ⇒ the landscape-through-door step runs.
  • G2 (dungeon, seen_outside == 0): sealed; no terrain/sky; OutsideView empty ⇒ 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.Id every 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