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>
This commit is contained in:
parent
1405dd8e90
commit
bb64a674fc
1 changed files with 274 additions and 0 deletions
|
|
@ -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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue