acdream/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md
Erik bb64a674fc docs: spec — render unification (outdoor-as-a-cell, single DrawInside path)
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>
2026-06-07 18:07:33 +02:00

274 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.63.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 25.
## 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 12 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 12 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`.