diff --git a/docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md b/docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md new file mode 100644 index 0000000..e5ece8d --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md @@ -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).