docs(render): spec — single-viewpoint render (retail viewer, no split)

The inside/outside render currently splits viewpoints: the player cell roots
visibility + the portal side-test, the eye only projects. Retail uses ONE
viewpoint — the collided camera (viewer) — for the mode decision, indoor root,
side-test, AND projection (RenderNormalMode -> DrawInside(viewer_cell) @92675;
InitCell side-test vs viewer.viewpoint @432991; viewer_cell = sphere_path.curr_cell
@92871). The split makes the render mode follow the player while the screen comes
from the camera -> doorway-straddle void + see-through transition (user evidence
2026-06-03). Spec unifies on the viewer: V1 un-split (robust viewer cell from the
camera sweep, no AABB/grace -> no U.4c flap; lighting stays on the player cell),
V2 DrawPortal (outside-looking-in), V3 floor seal. Supersedes residual-A; merges
A+C. Keeps the blue-hole fix (CurrCell player-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 12:24:42 +02:00
parent a1b49f9b24
commit b3fe54a5f4

View file

@ -0,0 +1,159 @@
# Single-Viewpoint Render — Design Spec (2026-06-03)
> **Mandate (user, 2026-06-03):** retail camera behavior, **NO split, ONE viewpoint**.
> Retail uses a single collided-camera ("viewer") viewpoint for the entire render — the
> inside/outside decision, the indoor root, the portal side-test, AND the projection.
> acdream currently splits these (player cell roots visibility + the side-test; the eye only
> projects), which breaks the inside↔outside transition. Fix it now, retail-faithful, no
> shortcuts (oracle: `docs/research/named-retail/`).
>
> This **supersedes** the residual-A "refine the camera-collision sweep so the eye stays
> inside" framing and **merges residuals A + C** into one model. It builds on — and must not
> regress — the U.4c flap fix and the doorway blue-hole fix.
>
> **Read first:** [`2026-06-02-render-pipeline-redesign-design.md`](2026-06-02-render-pipeline-redesign-design.md)
> (the R0 model), [`../../research/2026-06-02-retail-render-pipeline-full-reference.md`](../../research/2026-06-02-retail-render-pipeline-full-reference.md)
> (the retail pipeline; **note its CL-B checklist baked in the acdream player-cell split — the
> decomp §2.1/§3.3 are authoritative: retail keys on `viewer_cell`**).
---
## 1. The problem (evidence, 2026-06-03)
**Retail uses ONE viewpoint — the collided camera ("viewer").** Every render decision keys on it:
- Render mode + indoor root: `SmartBox::RenderNormalMode @ 0x453aa0``DrawInside(this->viewer_cell)` (pc:92675).
- The viewer's cell is the swept camera's cell: `update_viewer @ 0x453ce0``viewer_cell = sphere_path.curr_cell` (pc:92871).
- Portal side-test: `PView::InitCell @ 0x5a4b70` tests each portal vs `Render::FrameCurrent->viewer.viewpoint` (pc:432991-993).
- Projection: from the viewer.
**acdream splits it:**
- Render mode + indoor root key on the **player** cell (`CellGraph.CurrCell`); BFS root pos = `_playerController.Position` ([GameWindow.cs:7162](../../../src/AcDream.App/Rendering/GameWindow.cs), [:7172](../../../src/AcDream.App/Rendering/GameWindow.cs)).
- Portal side-test uses that player position (`Build(clipRoot, visRootPos, …)``CameraOnInteriorSide(…, cameraPos=player)`, [GameWindow.cs:7326](../../../src/AcDream.App/Rendering/GameWindow.cs), [PortalVisibilityBuilder.cs:138/308](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)).
- Projection uses the **eye** (`envCellViewProj`, GameWindow.cs:7260/7330).
- The camera sweep produces a collided eye *position* but **discards its cell**; `FindCameraCell` was deleted (CellVisibility.cs:356) with no graph-based replacement.
**Why it breaks (user screenshots 2026-06-03):** the render *mode* follows the **player** while the screen comes from the **camera**. Step into the doorway → player is indoors → mode flips to `DrawInside` (which stops drawing the outdoor world) → but the camera is still outside → **void**. The side-test answers for the player, not the camera → wrong portals visible → see-through / floating panels.
**Why acdream split (history):** U.4c. Rooting at the eye used the AABB `FindCameraCell`, which returned a **stale cell during 3 grace frames** when the eye drifted into AABB gaps → flap. The split (root at player, project from eye) was a workaround for a *flaky camera-cell resolver* — not a retail-faithful choice.
---
## 2. The one model (retail-faithful)
**ONE viewpoint = the collided camera (the "viewer"):**
| Concern | Keyed on | Retail anchor |
|---|---|---|
| Render mode (inside/outside) | **viewer cell** | `RenderNormalMode` pc:92675 |
| Indoor render root (`DrawInside` root) | **viewer cell** | `DrawInside(viewer_cell)` pc:92675 |
| Portal side-test | **viewer position** (collided eye) | `InitCell` pc:432991 |
| Projection | **viewer** (eye) | (unchanged) |
| Landscape keep / sunlight / `seen_outside` / indoor-ambient | **player cell** | `CellManager::ChangePosition @ 0x4559b0` |
| Physics membership (`CurrCell`) | **player** | (unchanged; blue-hole fix) |
The "split" being removed is the one **inside the render** (player roots visibility, eye projects).
Physics/lighting keying on the player and render keying on the viewer is *retail*`player->cell`
and `SmartBox->viewer_cell` are distinct in retail; they agree when the camera is near the player and
diverge at boundaries (doorway straddle), which is exactly when the camera must drive the render.
**Decision (mode), per frame:** `viewerCell` is outdoor (`id & 0xFFFF < 0x100`) or null → `DrawOutside`;
else → `DrawInside(viewerCell)`. (`is_player_outside`-equivalent test on the *viewer*, `RenderNormalMode`.)
---
## 3. Keystone — the robust viewer cell (what keeps the flap from returning)
`PhysicsCameraCollisionProbe.SweepEye` already sweeps the 0.3 m viewer-sphere pivot→eye through
`PhysicsEngine.ResolveWithTransition` (ports `update_viewer`'s `CTransition`/`find_valid_position`).
`ResolveWithTransition` already tracks the swept cell (`ResolveResult.CellId` = `sp.CurCellId`).
**Change:** `SweepEye` returns **both** the collided eye **and** that resulting cell id — retail's
`viewer_cell = sphere_path.curr_cell` (pc:92871). This is **graph-tracked, deterministic per frame —
no AABB, no grace frames**, so the U.4c flap's root cause (stale AABB resolution over grace frames)
cannot recur. `RetailChaseCamera` exposes it as `ViewerCellId` next to `Position`.
Edge cases:
- Sweep stops at a wall → `viewerCell` = the cell the eye stopped in (interior).
- Sweep exits through an open door → `viewerCell` = the outdoor LandCell → mode = outside (correct).
- Collision disabled (`ACDREAM_CAMERA_COLLIDE=0`, non-default) or no start cell → fall back to the
player cell (degraded, documented; the default path always has the viewer cell).
---
## 4. V1 — un-split the render (THE fix; lands first)
**Scope.** Route the render mode + indoor root + portal side-test through the viewer (collided eye +
`viewerCell`); keep projection on the eye; keep lighting/`seen_outside`/`CurrCell` on the player.
**Changes:**
1. `ICameraCollisionProbe.SweepEye` → return a small struct `(Vector3 Eye, uint ViewerCellId)` instead
of `Vector3`. `PhysicsCameraCollisionProbe` returns `FromSpherePath(r.Position)` + `r.CellId`.
2. `RetailChaseCamera`: store the sweep's cell in a new `public uint ViewerCellId { get; private set; }`;
set it in `Update` (it's `cellId` — the player cell — when collision is off / no sweep).
3. `GameWindow.OnRender` (~7150-7332): introduce `viewerCellId` (from `_retailChaseCamera.ViewerCellId`)
and `viewerEyePos` (= `camPos`). The **render root** resolves from `viewerCellId` (its registered
`LoadedCell`); `ComputeVisibilityFromRoot(viewerRoot, viewerEyePos)`; `PortalVisibilityBuilder.Build(
viewerRoot, viewerEyePos, …, envCellViewProj)`. **Split out lighting:** keep a separate
`playerCell`/`playerSeenOutside`/`playerInsideCell` computed from `CellGraph.CurrCell` for sunlight +
`seen_outside` + indoor ambient (retail `CellManager::ChangePosition`). The binary decision
(`clipRoot != null` → DrawInside) now reads `viewerRoot`.
4. Extend the `[flap-sweep]` probe to log `viewerCell` vs `playerCell` (oscillation watch).
**KEEP / DON'T:** `CurrCell` stays **player-only** (do NOT re-add a per-entity `CurrCell` write); do NOT
re-introduce AABB `FindCameraCell` or grace frames (the robust sweep cell replaces them).
**V1 gate (visual):** the doorway-straddle **void is gone**; the inside/outside transition follows the
**camera**; **no flap** standing or walking inside (verified by `[flap-sweep]` viewerCell stability);
inside-looking-out still correct. From the street the cottage is a **solid exterior** (interior-through-
door is V2).
## 5. V2 — DrawPortal (outside-looking-in)
On the outdoor path, for each visible building door, run the **outdoor pview**:
`ConstructView(CBldPortal, polygon) @ 0x5a59a0` (side-test the door plane vs the viewer, `GetClip` to the
opening, recurse into the interior) → `DrawCells` the interior through the door's clip (`DrawPortal @
0x5a5ab0`). Uses a separate `outdoor_pview` instance. Removes the see-through box.
**V2 gate:** standing in the street facing the door, the **interior renders through the doorway**.
## 6. V3 — floor seal (the blue floor)
Dat-dump this cottage's EnvCell mesh (`ACDREAM_DUMP_CELLS` / `[shell]`) to determine whether the floor
is missing from the cell's `drawing_bsp` mesh or lives in a separate building/landcell stab that
`DrawInside` must also draw. Fix faithfully (retail's EnvCell mesh is a closed box *including* the floor).
No relaxing of the faithful terrain `Skip` (design spec §1.3).
**V3 gate:** solid floor inside the cottage.
---
## 7. Sequence + risk
**Sequence:** V1 → V2 → V3, a user **visual gate** at each (render is verified on screen + probes, never
off the test suite). V1 is "this" (the camera behavior) and lands first.
**Risk (watch at V1 gate):** `viewerCell` must not **oscillate** at a portal boundary (a flipping
viewer cell would flap the render mode). Retail does exactly this without flapping; the membership
Stage 1 port made cell-tracking stable standing still (`[cell-transit]` DELTA=0). The `[flap-sweep]`
viewerCell-vs-playerCell log is the falsifiable check; confirm stability before trusting the gate.
---
## 8. Decomp anchor index
```
SmartBox::RenderNormalMode 0x00453aa0 pc:92635 binary decision; DrawInside(viewer_cell) @92675
SmartBox::update_viewer 0x00453ce0 pc:92761 spring-arm sweep; viewer_cell = sphere_path.curr_cell @92871
SmartBox::is_player_outside 0x00451e80 pc:90996 low-word objcell_id < 0x100 (cell-type test)
CellManager::ChangePosition 0x004559b0 pc:94601 landscape/sunlight keep on the PLAYER cell + seen_outside
PView::InitCell 0x005a4b70 pc:432896 per-portal side-test vs viewer.viewpoint @432991
PView::DrawInside 0x005a5860 pc:433793 ConstructView(viewer_cell) → DrawCells
PView::DrawPortal 0x005a5ab0 pc:433895 outside-looking-in entry (V2)
PView::ConstructView(CBldPortal)0x005a59a0 pc:433827 exterior→interior recursion (V2)
CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 point→child cell via graph/BSP (viewer-cell fallback)
CCellStruct drawing_bsp acclient.h:32275 closed cell mesh incl. floor (V3)
```
Code touch-points: `ICameraCollisionProbe` / `PhysicsCameraCollisionProbe` / `RetailChaseCamera` /
`GameWindow.OnRender` (~7150-7332) (V1); `PortalVisibilityBuilder` + a new DrawPortal path (V2);
`EnvCellRenderer` / `CellMesh` (V3).