# 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](../../../src/AcDream.App/Rendering/GameWindow.cs)): ``` 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](../../../src/AcDream.App/Rendering/GameWindow.cs)): 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): 1. `CEnvCell::curr_view_push(cell)` 2. `PView::add_views(cell->num_stabs, cell->stab_list)` — the cell's visible objects. **For an outdoor cell the stab list includes the landscape.** 3. `ConstructView(cell, 0xffff)` (`0x005a59a0`) — recursive portal-clip flood. Uses the ±`0.000199999995f` plane 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 to `CEnvCell::GetVisible(other_cell_id)` and recurses. 4. `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). - `#78` outdoor 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](../../../src/AcDream.App/Rendering/GameWindow.cs)). - 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](../../../src/AcDream.App/Rendering/GameWindow.cs) returns this node when the camera's viewer-cell id is an outdoor id, so `viewerRoot` is **non-null outdoors** and `ComputeVisibilityFromRoot` runs 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` - `Build` roots at the viewer cell — interior `EnvCell` **or** the outdoor node (today it requires a non-null interior `LoadedCell`, [PortalVisibilityBuilder.cs:63](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)). - Building exit portals (`OtherCellId == 0xFFFF`, [PortalVisibilityBuilder.cs:234](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) now **lead to the outdoor node** and enqueue it (clipped to the door opening), instead of terminating into a 2D `OutsideView`. - The outdoor↔building cycle is bounded by the existing visited-set / per-cell reprocess guard (`MaxReprocessPerCell`). - **`BuildFromExterior` is 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`/`RetailPViewInside` branch ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs)) 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 `OutsideView` mechanism + `DrawLandscapeThroughOutsideView`/`DrawRetailPViewLandscapeSlice` ([GameWindow.cs:~9239](../../../src/AcDream.App/Rendering/GameWindow.cs)) and `RetailPViewRenderer.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](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)). 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 1. Resolve the viewer cell: interior `EnvCell` if the eye is indoors, else the outdoor node (`CellVisibility`). 2. `Build` the visibility flood from the viewer cell (one call). 3. Draw every visible cell in order: outdoor node → terrain/sky/scenery clipped to its region; interior cell → shell/objects clipped to its region. 4. Draw entities, membership-gated to visible cells. 5. 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](../../../src/AcDream.App/Rendering/GameWindow.cs) and the outdoor/indoor draw blocks). - `PortalVisibilityBuilder.BuildFromExterior`. - `RetailPViewRenderer.DrawPortal` (the exterior look-in). - The `OutsideView` 2D-region path + `DrawLandscapeThroughOutsideView` / `DrawRetailPViewLandscapeSlice`. Net result: less code, one path. ## 9. Phasing (implementation order) 1. **Outdoor cell node** — construct it + wire building-entrance portals; resolve `viewerRoot` to it outdoors. *Unit-tested* (node has the right portals; viewer resolution returns it for outdoor ids). Additive — not yet consumed by the draw. 2. **Outdoor-root flood capability** — `Build` *can* root at the outdoor node and flood into buildings through their entrances; cycle-safe. This is a **new, additive** path: the existing indoor `Build` and its exit-portal→`OutsideView` behaviour are left untouched so the live draw stays correct. *Unit-tested* (flood from the outdoor node reaches buildings; termination on the outdoor↔building cycle). 3. **Cutover (the one risky, visual-gated step)** — switch the draw to the single unified path; repoint building exit portals from `OutsideView` to the outdoor node (so indoor→outdoor floods into the node through the door); delete the `OutdoorRoot`/`RetailPViewInside` branch, `BuildFromExterior`, `RetailPViewRenderer.DrawPortal`, and the `OutsideView` / `DrawLandscapeThroughOutsideView` path. **Visual gate (user's eyes)** at the cottage doorway/cellar/look-in-from-outside. 4. **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; `Build` rooted at the outdoor node returns the expected cell set; indoor→outdoor flood through a door; cycle termination. New tests in `tests/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 + `MaxReprocessPerCell` cap; 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::RenderNormalMode` `0x00453aa0` (pc:92635); `RenderDeviceD3D::DrawInside` `0x0059f0d0`; `PView::DrawInside` `0x005a5860` (pc:433793); `PView::ConstructView` `0x005a59a0` (side-test pc:433834); `PView::DrawCells` `0x005a4840`; `PView::GetClip` `0x005a4320`. - acdream: `GameWindow.cs` 7183-7204 (viewer/player root), 7342-7349 (branch), ~7538-7601 (look-in), ~9239 (landscape-through-OutsideView); `PortalVisibilityBuilder.cs` 63 (Build), 234 (exit portal), 339 (BuildFromExterior), 664 (side-test); `PortalProjection.cs` (w-space clip); `TerrainModernRenderer.cs` 206 (Draw/clip); `CellVisibility.cs` 276 (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`.