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>
274 lines
15 KiB
Markdown
274 lines
15 KiB
Markdown
# 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`.
|