Brainstormed design to collapse acdream's two render paths (OutdoorRoot vs RetailPViewInside) into one, matching retail SmartBox::RenderNormalMode -> DrawInside(viewer_cell). Roots the FLAP as the two-branch split toggling on the viewer cell crossing the indoor/outdoor boundary (pinned 2026-06-07 via live render-sig); the 2026-06-05 viewer-cell-stability plan (boom + dead-zone + w-clip) is exhausted. Models the outdoor world as a flood-graph cell node whose shell is the landscape, so one flood + one draw handle indoor and outdoor uniformly. Clean cutover, 4-phase plan (phases 1-2 additive, phase 3 the visual-gated cutover). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 KiB
Render Unification — Outdoor-as-a-Cell (single DrawInside path) — Design
Date: 2026-06-07
Status: Design approved (brainstorm), pending spec review → implementation plan
Branch: claude/thirsty-goldberg-51bb9b
Supersedes (for the flap): the 2026-06-05 3-part viewer-cell-stability plan
(boom snap + dead-zone + w-clip) — exhausted; see "Why prior fixes failed".
1. Context & problem
The indoor render FLAP: textures "battle"/oscillate at every transition (outdoor↔indoor, room↔room, cellar). Walls flash transparent, the world background covers windows, the doorway appears to "teleport". This has resisted weeks of incremental fixes.
Root cause (pinned 2026-06-07 with live ACDREAM_PROBE_FLAP render-sig at the
Holtburg/Arcanum cottage): the renderer chooses one of two structurally
different branches per frame, keyed on whether the viewer (camera-eye) cell is
indoor or outdoor (GameWindow.cs:7342-7349):
clipRoot = playerIndoorGate && viewerRoot != null ? viewerRoot : null;
renderBranch = clipRoot == null ? "OutdoorRoot" : "RetailPViewInside";
The two branches draw the same scene differently:
| Viewer cell | Branch | Terrain | Interior cells | depth-clear |
|---|---|---|---|---|
outdoor (e.g. 0xA9B40031) |
OutdoorRoot |
full-screen draw | 4 via 2D "look-in" | no |
indoor (0170/0171) |
RetailPViewInside |
skipped (door-clipped only) | 6 via portal flood | yes |
The 3rd-person eye sits 2.6–3.9 m behind the player and crosses the indoor/outdoor
boundary by metres as the player stands at a doorway. Each crossing toggles the
branch → terrain pops, the interior cell set jumps 4↔6, depth-clear toggles → the
flap. When the eye stays indoor (0170↔0171) both solves draw the same 6 cells
— no flap (verified: adjacent frames, eye ~still). So the flap is specifically
the indoor/outdoor branch switch — the "two-pipe split" CLAUDE.md's 2026-05-30
banner marked abandoned, still alive as this gate.
2. Why prior fixes failed (do not retry)
- Viewer-cell dead-zone (±0.2 mm in
PointInsideCellBsp) — the eye crosses by metres; a sub-mm dead-zone is irrelevant. Tried 2026-06-07, had zero visible effect and regressed the cellar roof (it shifted the flood root via the cell pick). Reverted. The faithful Part-1 (boom snap,d2212cf) and Part-3 (w-space portal clip,ProjectToClip/ClipToRegion) are already shipped. The 3-part viewer-cell-stability plan is exhausted and the flap remains. - Gating the branch on the PLAYER cell — a documented dead-end (GameWindow.cs:7207-7211): forcing an indoor draw while the camera is outside "drops the outdoor pass and leaves clear color around a floating doorway slice." When the eye is genuinely outside, the outdoor view is correct — so a stable branch can't be a pure function of the player cell either.
- A render-side debounce/grace on the branch — forbidden (no-workarounds rule; 2026-06-05 plan §5).
The lesson: the flap is architectural, not a stability tweak. Two structurally-different render paths cannot be made seamless at the boundary by adjusting when you switch between them. They must become one path.
3. The retail model (the oracle)
Retail has one render path. SmartBox::RenderNormalMode
(0x00453aa0, pc:92635) calls, in the normal case,
RenderDeviceD3D::DrawInside(viewer_cell) (0x0059f0d0) →
PView::DrawInside(viewer_cell) (0x005a5860, pc:433793) → PView::DrawCells
(0x005a4840). It does not branch on inside/outside.
PView::DrawInside(cell) (pc:433793):
CEnvCell::curr_view_push(cell)PView::add_views(cell->num_stabs, cell->stab_list)— the cell's visible objects. For an outdoor cell the stab list includes the landscape.ConstructView(cell, 0xffff)(0x005a59a0) — recursive portal-clip flood. Uses the ±0.000199999995fplane side-test (POSITIVE / NEGATIVE / IN_PLANE, pc:433834) — this is where that 0.2 mm constant belongs, not in cell membership. An exit/portal leads toCEnvCell::GetVisible(other_cell_id)and recurses.DrawCells(view)— draw every visible cell.
The outdoor world is a cell (a landcell / CObjCell) with portals to buildings
and a stab list that carries the landscape. There is no outdoor "mode" — outdoors
is just the cell you're standing in, drawn by the same DrawInside.
4. Goal & non-goals
Goal: collapse acdream's two render paths into one, matching retail: a single flood rooted at the viewer cell (indoor or outdoor) and a single draw of every visible cell. The flap dies by construction — there is no branch to flip, and crossing a doorway is one continuous flood whose output varies continuously.
Approach chosen (brainstorm 2026-06-07): "A — make outdoor a cell" (the true retail model), with a clean cutover (no toggle; git revert is the safety net).
Non-goals (out of scope for this work):
- Camera collision / viewer-cell resolution behaviour (kept as-is — it is correct; the flap is not a camera bug).
#78outdoor terrain gating over indoor floor holes (tracked separately).- L-spotlight point-light artifact (separate).
- Per-landcell outdoor granularity (retail has 64 landcells/landblock for its own landscape culling; we model the outdoor world as one flood node whose shell is the terrain — acdream's terrain renderer already does its own frustum culling).
5. Design overview
One operation per frame: flood from the viewer's cell; draw every visible cell. The only new concept is an outdoor cell node: a synthetic cell whose "shell" is the landscape and whose "doorways" are nearby building entrances. With it, the outdoor case and the indoor case are the same graph problem.
resolve viewer cell ─► Build(flood) from viewer cell ─► for each visible cell in
draw order: (outdoor node → terrain+sky+scenery clipped to its region │ interior
cell → shell+objects clipped to its region) ─► entities membership-gated
6. Components
6.1 Outdoor cell node — CellVisibility (+ a small new type)
A synthetic node representing the outdoor world near the player. One node per
frame, keyed by the viewer's current outdoor landcell id (the id the camera already
produces, e.g. 0xA9B40031). All building exit portals collapse to this single node
(the landscape is global, so we do not model 64 landcells/landblock — the node's shell
is the whole visible landscape, drawn by the terrain renderer's own frustum cull).
SeenOutside = true, world transform = identity.- Portals = the entrances of nearby buildings — the reverse of each building's
exit portal (
OtherCellId == 0xFFFF) polygon, with its clip plane reversed so the flood can traverse outdoor→building. Built per-frame from the same nearby-building enumeration the current exterior look-in already does (GameWindow.cs:~7538-7565). - Marked so the draw path knows "this cell's shell is the terrain, not EnvCell geometry."
CellVisibility.TryGetCell/ the viewer-cell resolution at GameWindow.cs:7201-7204 returns this node when the camera's viewer-cell id is an outdoor id, soviewerRootis non-null outdoors andComputeVisibilityFromRootruns the flood.
Interface: what — represents the outdoor world as a flood graph node;
how to use — CellVisibility builds/refreshes it per frame and returns it from the
viewer-cell lookup when outdoors; depends on — the nearby-building portal
enumeration and the loaded landblock set.
6.2 One flood — PortalVisibilityBuilder.Build
Buildroots at the viewer cell — interiorEnvCellor the outdoor node (today it requires a non-null interiorLoadedCell, PortalVisibilityBuilder.cs:63).- Building exit portals (
OtherCellId == 0xFFFF, PortalVisibilityBuilder.cs:234) now lead to the outdoor node and enqueue it (clipped to the door opening), instead of terminating into a 2DOutsideView. - The outdoor↔building cycle is bounded by the existing visited-set / per-cell
reprocess guard (
MaxReprocessPerCell). BuildFromExterioris deleted — the exterior look-in becomes "the flood, rooted at the outdoor node."
Interface: what — given any root cell + eye, returns the set of visible cells
(interior + outdoor node) with per-cell screen-space clip regions; how to use —
called once per frame from the viewer cell; depends on — the cell graph (now
including the outdoor node) and PortalProjection.
6.3 One draw path — RetailPViewRenderer + GameWindow
- Delete the
OutdoorRoot/RetailPViewInsidebranch (GameWindow.cs:7342-7349) and its two blocks. - Walk the flood's visible cells in draw order; for each:
- Outdoor node → draw terrain + sky + outdoor scenery, clipped to that node's view region.
- Interior cell → draw shell + objects, clipped to its region (today's
IndoorDrawPlan.ShellPass/ObjectPass).
- Entities membership-gated as today (
InteriorEntityPartition). - Delete the separate
OutsideViewmechanism +DrawLandscapeThroughOutsideView/DrawRetailPViewLandscapeSlice(GameWindow.cs:~9239) andRetailPViewRenderer.DrawPortal— terrain-through-door is now "the outdoor node drawn clipped to its doorway region," produced by the unified flood.
6.4 Terrain-clipped-to-region — TerrainModernRenderer (reused)
No new terrain machinery. Terrain already clips to the OutsideView doorway planes
in-shader via gl_ClipDistance (binding=2 clip UBO,
TerrainModernRenderer.cs:206).
We drive that clip from the outdoor node's view region instead: full-screen (no
clip — byte-identical to today's open-world draw) when the outdoor node is the root,
the doorway region when it is reached through a portal.
7. Per-frame data flow
- Resolve the viewer cell: interior
EnvCellif the eye is indoors, else the outdoor node (CellVisibility). Buildthe visibility flood from the viewer cell (one call).- Draw every visible cell in order: outdoor node → terrain/sky/scenery clipped to its region; interior cell → shell/objects clipped to its region.
- Draw entities, membership-gated to visible cells.
- Sky draws when the outdoor node is in the visible set, clipped to its region.
No branch anywhere in steps 2–5.
8. What gets deleted (clean cutover)
- The two-branch gate + blocks (GameWindow.cs:7342-7349 and the outdoor/indoor draw blocks).
PortalVisibilityBuilder.BuildFromExterior.RetailPViewRenderer.DrawPortal(the exterior look-in).- The
OutsideView2D-region path +DrawLandscapeThroughOutsideView/DrawRetailPViewLandscapeSlice.
Net result: less code, one path.
9. Phasing (implementation order)
- Outdoor cell node — construct it + wire building-entrance portals; resolve
viewerRootto it outdoors. Unit-tested (node has the right portals; viewer resolution returns it for outdoor ids). Additive — not yet consumed by the draw. - Outdoor-root flood capability —
Buildcan root at the outdoor node and flood into buildings through their entrances; cycle-safe. This is a new, additive path: the existing indoorBuildand its exit-portal→OutsideViewbehaviour are left untouched so the live draw stays correct. Unit-tested (flood from the outdoor node reaches buildings; termination on the outdoor↔building cycle). - Cutover (the one risky, visual-gated step) — switch the draw to the single
unified path; repoint building exit portals from
OutsideViewto the outdoor node (so indoor→outdoor floods into the node through the door); delete theOutdoorRoot/RetailPViewInsidebranch,BuildFromExterior,RetailPViewRenderer.DrawPortal, and theOutsideView/DrawLandscapeThroughOutsideViewpath. Visual gate (user's eyes) at the cottage doorway/cellar/look-in-from-outside. - Cleanup — remove any remaining dead code; reconcile the
[render-sig]probe to the single path.
Phases 1–2 are purely additive — the new node + outdoor-root flood are not yet
consumed by the draw, and the exit-portal→OutsideView behaviour is unchanged — so
the build stays green and the game renders exactly as today. The disruptive change
(repointing exit portals + switching the draw + deleting the old paths) is isolated to
phase 3 and is git-revertible as a unit.
10. Testing strategy
- Unit (TDD): outdoor-node portal wiring; viewer-cell resolution to the node;
Buildrooted at the outdoor node returns the expected cell set; indoor→outdoor flood through a door; cycle termination. New tests intests/AcDream.App.Tests/Rendering/. - Regression guard: the pure-outdoor case (no building in view) must stay byte-for-byte today's behaviour — full-screen terrain, no clip — so open-world rendering cannot regress. Assert the outdoor node's root region is full-screen and the terrain clip is the no-clip UBO in that case.
- Visual gate (acceptance): user walks in/out of the cottage, pans the camera at the threshold, drops to the cellar and back, looks at the cottage from outside — no flap, no missing textures, terrain/sky correct, no see-through walls.
11. Risks & mitigations
- Occlusion/ordering when terrain + interiors share one flood — keep the draw order retail-faithful (far→near, exit-portal masks as today); mitigate by keeping the pure-outdoor path identical (regression guard above).
- Cycle blow-up (outdoor→building→outdoor) — reuse the existing visited-set +
MaxReprocessPerCellcap; unit-test termination at the cottage. - Performance — the outdoor node is a single flood node (terrain is its shell, drawn once with its own frustum cull), not millions of cells, so the flood does not become combinatorial. Watch outdoor FPS at the visual gate.
- Clean cutover (no toggle) — phases 1–2 are additive and green; phase 3 is the only risky step and is git-revertible as a unit.
12. References / decomp anchors
- Retail:
SmartBox::RenderNormalMode0x00453aa0(pc:92635);RenderDeviceD3D::DrawInside0x0059f0d0;PView::DrawInside0x005a5860(pc:433793);PView::ConstructView0x005a59a0(side-test pc:433834);PView::DrawCells0x005a4840;PView::GetClip0x005a4320. - acdream:
GameWindow.cs7183-7204 (viewer/player root), 7342-7349 (branch), ~7538-7601 (look-in), ~9239 (landscape-through-OutsideView);PortalVisibilityBuilder.cs63 (Build), 234 (exit portal), 339 (BuildFromExterior), 664 (side-test);PortalProjection.cs(w-space clip);TerrainModernRenderer.cs206 (Draw/clip);CellVisibility.cs276 (TryGetCell), 338 (ComputeVisibilityFromRoot). - Memory:
project_indoor_flap_rootcause,reference_render_pipeline_state,feedback_render_one_gate,feedback_render_downstream_of_membership,project_camera_visibility_coupling.