# 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).