feat(render): V1 — render keys on the viewer cell+eye; lighting stays on the player

Phase W single-viewpoint V1 (un-split). The render mode decision, indoor root, and portal
side-test now key on the collided-camera viewer cell + eye (RetailChaseCamera.ViewerCellId +
camPos) — retail RenderNormalMode -> DrawInside(viewer_cell) @92675; InitCell side-test vs
viewer.viewpoint @432991. Lighting / seen_outside / playerInsideCell stay on the PLAYER cell
(CurrCell), retail CellManager::ChangePosition @4559B0. The old per-render player-root +
eye-projection split (U.4c) is removed; the flap is avoided by the robust graph-tracked viewer
cell (no AABB, no grace). [flap-cam] probe extended with viewerCell vs playerCell. CurrCell
stays player-only (blue-hole fix intact). App 176 green; Core 1295/5 baseline (no new fails).

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

View file

@ -7148,42 +7148,48 @@ public sealed class GameWindow : IDisposable
// Step 4: portal visibility — compute BEFORE the UBO upload so
// the indoor flag drives the sun's intensity to zero for
// dungeons (r13 §13.7).
// Phase U.4c (2026-05-31): root indoor visibility at the PLAYER's cell, not the
// camera EYE. Retail's CellManager::ChangePosition (0x004559B0) tracks curr_cell by
// the player/physics position. The 3rd-person chase EYE drifts out of the player's
// cell (through interior walls into AABB gaps); FindCameraCell then can't place the
// eye and returns the STALE previous cell for its 3 grace frames, from which the
// doorway portal is "behind" the eye → culled → the exit cell + terrain + shells
// flap off. ACDREAM_PROBE_FLAP capture (2026-05-31): every flap frame is
// res=Grace eyeInRoot=n terrain=Skip; every good frame is eyeInRoot=Y. The eye is
// still used for the per-frame PROJECTION (envCellViewProj) — only the cell ROOT +
// portal-side test track the player. This mirrors the playerInsideCell lighting
// decision below, which already roots at the player for exactly this reason.
var visRootPos = (_playerMode && _playerController is not null)
? _playerController.Position
: camPos;
// UCG W2: use the physics membership answer (DataCache.CellGraph.CurrCell) as the
// BFS root instead of resolving from position via FindCameraCell. Falls back to the
// original ComputeVisibility path when the physics answer isn't usable yet (null
// CurrCell, or its cell id not yet registered with the render CellVisibility system).
// This closes the render/physics disagreement — both now key off the same BSP-based
// resolution — which is the root cause of the "world from below" spawn flicker.
LoadedCell? physicsRoot = null;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell physCell
&& _cellVisibility.TryGetCell(physCell.Id, out var registeredCell))
physicsRoot = registeredCell;
var visibility = _cellVisibility.ComputeVisibilityFromRoot(physicsRoot, visRootPos);
// Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the
// collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 →
// DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991).
// The viewer cell is the camera-collision sweep's swept cell
// (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell):
// graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source
// (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints.
// SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail
// CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides
// whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs
// SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone.
// ── Lighting root: the PLAYER cell (CurrCell). ──
LoadedCell? playerRoot = null;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj
&& _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell))
playerRoot = playerRegCell;
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
// ── Render root: the VIEWER (collided camera) cell + eye. ──
// Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the
// non-default legacy/debug camera paths: the player's registered cell (or none).
uint viewerCellId =
(_playerMode && _retailChaseCamera is not null
&& AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera)
? _retailChaseCamera.ViewerCellId
: (playerRoot?.CellId ?? 0u);
var viewerEyePos = camPos; // the collided eye drives the side-test AND the projection
LoadedCell? viewerRoot = null;
if ((viewerCellId & 0xFFFFu) >= 0x0100u
&& _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell))
viewerRoot = viewerRegCell;
var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// Stage 3 (2026-06-02): extract seen_outside from the PVS root cell.
// Retail CellManager::ChangePosition @ 0x004559B0 (pseudo_c:94649):
// "if (seen_outside || keep_lscape_loaded) keep landscape + terrain
// else LScape::release_all (dungeon)"
// Outdoor root (physicsRoot==null) → always seen_outside=true.
// Building interior with exit portal → seen_outside=true (sky/terrain kept live;
// clipped to doorway in Stage 4).
// Stage 3 (2026-06-02): the RENDER's seen_outside (gates terrain/sky through the
// doorway) comes from the VIEWER root cell. Retail CellManager::ChangePosition
// @ 0x004559B0 (pseudo_c:94649): keep landscape+terrain iff seen_outside else release.
// Outdoor viewer (viewerRoot==null) → always seen_outside=true.
// Building interior with exit portal → seen_outside=true (terrain clipped to the door).
// Pure dungeon (no exit portal reachable) → seen_outside=false (sky suppressed).
bool rootSeenOutside = physicsRoot?.SeenOutside ?? true;
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true;
// Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified
// gated-draw block (after envCellViewProj exists) where it can report
@ -7198,8 +7204,9 @@ public sealed class GameWindow : IDisposable
// independent AABB containment scan. playerInsideCell = true (kill sunlight) only
// when the player is inside a SEALED interior (seen_outside=false = dungeon).
// Building interiors with seen_outside=true keep the sun (sky visible through door).
// When not in player mode (orbit/fly debug camera) we fall back to cameraInsideCell.
bool playerInsideCell = cameraInsideCell && !rootSeenOutside;
// V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent
// of the camera's viewer cell — retail kills the sun off the player's cell, not the eye.
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
// Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by
@ -7320,12 +7327,13 @@ public sealed class GameWindow : IDisposable
HashSet<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
if (clipRoot is not null)
{
// Phase U.4c: side test + distance ordering use the PLAYER position (visRootPos,
// stable inside the cell); projection uses the eye's envCellViewProj (the screen
// view). See the visRootPos rationale at the ComputeVisibility call above.
// Phase W single-viewpoint V1 (2026-06-03): the portal side test + distance ordering
// use the VIEWER eye (the collided camera) — same viewpoint as the projection
// (envCellViewProj) and the render root (clipRoot = the viewer cell). ONE viewpoint,
// retail InitCell side-test vs viewer.viewpoint (pc:432991). No more player/eye split.
pvFrame = PortalVisibilityBuilder.Build(
clipRoot,
visRootPos,
viewerEyePos,
id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
envCellViewProj);
@ -7369,8 +7377,10 @@ public sealed class GameWindow : IDisposable
{
var flapPlayer = _playerController?.Position ?? camPos;
bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot);
uint flapPlayerCell = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
Console.WriteLine(
$"[flap-cam] root=0x{clipRoot.CellId:X8} res={_cellVisibility.LastCameraCellResolution} " +
$"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{flapPlayerCell:X8} " +
$"res={_cellVisibility.LastCameraCellResolution} " +
$"eyeInRoot={(eyeInRoot ? "Y" : "n")} eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " +
$"player=({flapPlayer.X:F2},{flapPlayer.Y:F2},{flapPlayer.Z:F2}) " +
$"terrain={clipAssembly.TerrainMode} outVisible={clipAssembly.OutdoorVisible}");