diff --git a/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md b/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md new file mode 100644 index 00000000..a84cb27d --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md @@ -0,0 +1,274 @@ +# 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`.