acdream/docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md
Erik b3fe54a5f4 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>
2026-06-03 12:24:42 +02:00

10 KiB

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 (the R0 model), ../../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 @ 0x453aa0DrawInside(this->viewer_cell) (pc:92675).
  • The viewer's cell is the swept camera's cell: update_viewer @ 0x453ce0viewer_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, :7172).
  • Portal side-test uses that player position (Build(clipRoot, visRootPos, …)CameraOnInteriorSide(…, cameraPos=player), GameWindow.cs:7326, PortalVisibilityBuilder.cs:138/308).
  • 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 retailplayer->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).