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>
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 onviewer_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 @ 0x5a4b70tests each portal vsRender::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;
FindCameraCellwas 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:
ICameraCollisionProbe.SweepEye→ return a small struct(Vector3 Eye, uint ViewerCellId)instead ofVector3.PhysicsCameraCollisionProbereturnsFromSpherePath(r.Position)+r.CellId.RetailChaseCamera: store the sweep's cell in a newpublic uint ViewerCellId { get; private set; }; set it inUpdate(it'scellId— the player cell — when collision is off / no sweep).GameWindow.OnRender(~7150-7332): introduceviewerCellId(from_retailChaseCamera.ViewerCellId) andviewerEyePos(=camPos). The render root resolves fromviewerCellId(its registeredLoadedCell);ComputeVisibilityFromRoot(viewerRoot, viewerEyePos);PortalVisibilityBuilder.Build( viewerRoot, viewerEyePos, …, envCellViewProj). Split out lighting: keep a separateplayerCell/playerSeenOutside/playerInsideCellcomputed fromCellGraph.CurrCellfor sunlight +seen_outside+ indoor ambient (retailCellManager::ChangePosition). The binary decision (clipRoot != null→ DrawInside) now readsviewerRoot.- Extend the
[flap-sweep]probe to logviewerCellvsplayerCell(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).