From 6a1fbbd44e7a1f2f25bfb3d6f1351dd4a86f5e81 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jun 2026 15:36:47 +0200 Subject: [PATCH] =?UTF-8?q?refactor(render):=20Stage=203=20T3.1=20?= =?UTF-8?q?=E2=80=94=20delete=20FindCameraCell=20AABB=20grace-frame=20fall?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComputeVisibilityFromRoot(null, …) now returns null (outdoor root) instead of calling FindCameraCell(fallbackPos). Retail CellManager::ChangePosition (0x004559B0) reads the transition-owned curr_cell — it does NOT re-derive from a static position. W2a guarantees CurrCell is set from the first tick, so the AABB fallback is dead. Deleted: FindCameraCell (389–446), _lastCameraCell, _cellSwitchGraceFrames, CellSwitchGraceFrameCount. GetVisibleCells retains a brute-force AABB scan for test-compat; ComputeVisibility stays for the same reason. Updated 3 null-root tests in CellVisibilityFromRootTests to assert the new null-returns-null behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/CellVisibility.cs | 214 +++++------------- .../Rendering/CellVisibilityFromRootTests.cs | 39 ++-- 2 files changed, 76 insertions(+), 177 deletions(-) diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index fc4b5a1..80477c0 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -1,9 +1,11 @@ // CellVisibility.cs — portal-based interior cell visibility system. // -// Ported from ACME EnvCellManager.cs (WorldBuilder-ACME-Edition). -// Key methods: FindCameraCell, PointInCell, GetVisibleCells. -// Constants: PointInCellEpsilon = 0.01f, CellSwitchGraceFrameCount = 3 -// (ACME values; the original spec suggested 0.1f / 5 but ACME is ground truth). +// Stage 3 (2026-06-02): FindCameraCell + grace-frame AABB fallback deleted. +// The physics membership answer (CellGraph.CurrCell) is now the mandatory root; +// ComputeVisibilityFromRoot(null, …) returns null (outdoor root) rather than +// falling back to an independent AABB position resolve. This matches retail's +// CellManager::ChangePosition (0x004559B0) which does not re-derive the cell +// from a static position — it reads the swept transition-owned CurrCell. // // This file is intentionally free of GL / rendering types. It depends only on // System.Numerics so it can be unit-tested without a GPU context. @@ -143,8 +145,10 @@ public struct PortalClipPlane } /// -/// Phase U.4c flap probe (diagnostic): which branch of -/// resolved the camera cell. +/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked +/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained +/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that +/// still prints (always None post-Stage 3). /// public enum CameraCellResolution { @@ -200,19 +204,12 @@ public sealed class CellVisibility // ------------------------------------------------------------------ /// - /// Epsilon applied to AABB containment tests so that a camera sitting + /// Epsilon applied to AABB containment tests so that a position sitting /// exactly on a cell wall is still considered inside. /// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f. /// private const float PointInCellEpsilon = 0.01f; - /// - /// Number of frames to keep the previous camera cell alive after the camera - /// leaves it (prevents one-frame pop-in when crossing cell boundaries). - /// Source: ACME EnvCellManager.cs CellSwitchGraceFrameCount = 3. - /// - private const int CellSwitchGraceFrameCount = 3; - // ------------------------------------------------------------------ // State // ------------------------------------------------------------------ @@ -223,20 +220,13 @@ public sealed class CellVisibility /// Full-ID lookup for O(1) neighbour resolution during BFS. private readonly Dictionary _cellLookup = new(); - /// The cell the camera was in during the last call. - private LoadedCell? _lastCameraCell; - - /// Frames remaining in the grace period after the camera left _lastCameraCell. - private int _cellSwitchGraceFrames; - - /// The last visibility result produced by . + /// The last visibility result produced by . public VisibilityResult? LastVisibilityResult { get; private set; } /// - /// Phase U.4c flap probe (diagnostic): which branch - /// resolved the camera cell on the most recent call. A - /// (or ) result while the eye is NOT actually inside the - /// returned cell is the "stale root" signature the flap probe looks for. + /// Stage 3 (2026-06-02): always — the FindCameraCell + /// AABB grace-frame resolver was deleted; the physics membership answer is the sole root. + /// Retained for the [flap-cam] probe log line in GameWindow.cs. /// public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None; @@ -299,14 +289,6 @@ public sealed class CellVisibility foreach (var cell in list) { _cellLookup.Remove(cell.CellId); - - // If the evicted cell was cached, clear the cache so FindCameraCell - // does a fresh brute-force scan next frame. - if (_lastCameraCell?.CellId == cell.CellId) - { - _lastCameraCell = null; - _cellSwitchGraceFrames = 0; - } } _cellsByLandblock.Remove(lbId); @@ -317,12 +299,11 @@ public sealed class CellVisibility // ------------------------------------------------------------------ /// - /// Computes portal-based visibility from and - /// caches the result in . - /// - /// Call once per frame, before the render pass. Returns null when the camera - /// is outside all loaded cells (outdoor — caller should fall back to frustum - /// culling of terrain). + /// Computes portal-based visibility from using the + /// AABB FindCameraCell resolver. Retained for test compatibility only; production + /// code should use with a physics-supplied + /// root (Stage 3 demotes the AABB resolver to test-only use). + /// Returns null when no loaded cell contains . /// public VisibilityResult? ComputeVisibility(Vector3 cameraPos) { @@ -337,26 +318,29 @@ public sealed class CellVisibility } /// - /// UCG W2: compute visibility from a supplied root cell (the physics membership answer) - /// rather than resolving the root from a position. Falls back to the position-based - /// when is null (e.g. the - /// cell isn't registered with this renderer yet), so it never regresses below baseline. + /// UCG W2/Stage 3: compute visibility from a supplied root cell (the physics membership + /// answer). When is null (pre-spawn, or player outside all indoor + /// cells), returns null — the caller interprets null as the outdoor root (no portal + /// frame, everything slot 0, terrain ungated). The legacy AABB FindCameraCell fallback is + /// deleted as of Stage 3; is the sole authority. + /// Retail anchor: CellManager::ChangePosition @ 0x004559B0 reads the transition-owned + /// curr_cell — it does NOT re-derive from a static position. /// /// /// The render-registered that physics determined the player is inside, - /// or null if the physics answer isn't usable yet (no cells registered, or the physics cell id - /// hasn't loaded into the render system). Null triggers the legacy - /// path, which preserves today's exact AABB-based behavior. + /// or null when pre-spawn or the player is in an outdoor landcell. Null → outdoor root path. /// /// - /// Position passed to when is null, - /// AND used as the viewer position for the portal-side test in the BFS when root is non-null. + /// Used as the viewer position for the portal-side test in the BFS when root is non-null. /// Should be the player/physics position (stable inside the cell), not the chase-camera eye. + /// The name "fallback" is historical; it is no longer used as a fallback position. /// public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos) { if (root is null) - return ComputeVisibility(fallbackPos); + return null; // outdoor root: caller handles null as "player is outside" + // Stage 3: FindCameraCell AABB grace-frame fallback deleted. + // Retail: CellManager::ChangePosition (0x004559B0) uses transition-owned CurrCell. if (_cellLookup.Count == 0) { @@ -364,86 +348,22 @@ public sealed class CellVisibility return null; } - _lastCameraCell = root; LastVisibilityResult = GetVisibleCellsFromRoot(root, fallbackPos); return LastVisibilityResult; } // ------------------------------------------------------------------ - // FindCameraCell + // FindCameraCell — DELETED in Stage 3 (2026-06-02) + // ------------------------------------------------------------------ + // The AABB + grace-frame camera-cell resolver was removed. Production code + // now exclusively uses ComputeVisibilityFromRoot(root, …) where root is the + // transition-owned CellGraph.CurrCell (set by ResolveCellId/Stage 2 physics). + // Retail anchor: CellManager::ChangePosition (0x004559B0) reads curr_cell + // from the sweep — it never re-derives from a static position. + // + // GetVisibleCells (used by ComputeVisibility below for test compatibility) + // still uses the brute-force AABB scan internally. // ------------------------------------------------------------------ - - /// - /// Finds the the camera is currently inside, with - /// a short hysteresis window to prevent flicker at cell boundaries. - /// - /// Search order: - /// 1. Cached cell fast path. - /// 2. Immediate portal neighbours of the cached cell. - /// 3. Brute-force scan of all loaded cells. - /// 4. Grace period — return the previous cell for a few frames. - /// 5. Return null (camera is outdoors). - /// - /// Ported from ACME EnvCellManager.cs FindCameraCell(). - /// - public LoadedCell? FindCameraCell(Vector3 cameraPos) - { - // 1. Fast path: cached cell. - if (_lastCameraCell != null && PointInCell(cameraPos, _lastCameraCell)) - { - LastCameraCellResolution = CameraCellResolution.Cache; - return _lastCameraCell; - } - - // 2. One-hop neighbours of the cached cell. - if (_lastCameraCell != null) - { - uint lbMask = _lastCameraCell.CellId & 0xFFFF0000u; - foreach (var portal in _lastCameraCell.Portals) - { - if (portal.OtherCellId == 0xFFFF) - continue; - - uint neighbourId = lbMask | portal.OtherCellId; - if (_cellLookup.TryGetValue(neighbourId, out var neighbour) && - PointInCell(cameraPos, neighbour)) - { - _lastCameraCell = neighbour; - _cellSwitchGraceFrames = CellSwitchGraceFrameCount; - LastCameraCellResolution = CameraCellResolution.Neighbour; - return neighbour; - } - } - } - - // 3. Brute-force scan. - foreach (var kvp in _cellsByLandblock) - { - foreach (var cell in kvp.Value) - { - if (PointInCell(cameraPos, cell)) - { - _lastCameraCell = cell; - _cellSwitchGraceFrames = CellSwitchGraceFrameCount; - LastCameraCellResolution = CameraCellResolution.BruteForce; - return cell; - } - } - } - - // 4. Grace period: keep the previous cell alive for a few frames. - if (_lastCameraCell != null && _cellSwitchGraceFrames > 0) - { - _cellSwitchGraceFrames--; - LastCameraCellResolution = CameraCellResolution.Grace; - return _lastCameraCell; - } - - // 5. Camera is outside all cells. - _lastCameraCell = null; - LastCameraCellResolution = CameraCellResolution.None; - return null; - } // ------------------------------------------------------------------ // PointInCell @@ -477,11 +397,9 @@ public sealed class CellVisibility /// /// Brute-force scan of every loaded cell to test whether - /// is inside any of them. Does not touch - /// the camera cache (), so this is safe - /// to call alongside in the same frame - /// for a different position (e.g. player position when the camera is - /// in third-person chase mode). + /// is inside any of them. Safe to call + /// independently of in the same + /// frame for a different position. /// public bool IsInsideAnyCell(Vector3 worldPoint) { @@ -496,31 +414,19 @@ public sealed class CellVisibility /// /// Performs portal-based BFS visibility traversal starting from the camera - /// cell. Returns null when the camera is outside all loaded cells. - /// - /// Algorithm: - /// • Start with the camera cell in the visited set and the work queue. - /// • For each dequeued cell, iterate its portals: - /// – OtherCellId == 0xFFFF → exit portal, set HasExitPortalVisible. - /// – Already visited → skip. - /// – Not loaded → skip. - /// – Portal-side test: transform camera to cell-local space, dot with - /// clip plane; skip if camera is on the wrong side. - /// – Enqueue neighbour and add to VisibleCellIds. - /// - /// Note: ACME also applies a frustum test after the portal-side test. That - /// test is omitted here because is a pure-logic - /// class. Callers that have a frustum can post-filter VisibleCellIds. - /// - /// The landblock mask for neighbour resolution is taken from the camera - /// cell's CellId (upper 16 bits). All portals in a dungeon are assumed to - /// connect cells within the same landblock. - /// - /// Ported from ACME EnvCellManager.cs GetVisibleCells(). + /// cell found by an AABB brute-force scan. Returns null when no loaded cell + /// contains . Used only by + /// (test-compatibility path); production code + /// uses with the physics-supplied root. /// public VisibilityResult? GetVisibleCells(Vector3 cameraPos) { - var cameraCell = FindCameraCell(cameraPos); + // Brute-force AABB scan (test-compatibility; FindCameraCell was deleted in Stage 3). + LoadedCell? cameraCell = null; + foreach (var kvp in _cellsByLandblock) + foreach (var cell in kvp.Value) + if (PointInCell(cameraPos, cell)) { cameraCell = cell; break; } + if (cameraCell == null) return null; @@ -529,11 +435,11 @@ public sealed class CellVisibility /// /// UCG W2: BFS visibility traversal from a pre-resolved root cell. - /// The root is assumed to be the correct membership answer (supplied by the - /// caller — either via , - /// or via the physics CurrCell answer). + /// The root is the correct membership answer (supplied by the caller — + /// physics CurrCell via , or AABB + /// scan via for test compat). /// - /// The BFS body is byte-identical to the original + /// The BFS body is byte-identical to the original GetVisibleCells /// implementation — only root acquisition was extracted out. /// private VisibilityResult? GetVisibleCellsFromRoot(LoadedCell cameraCell, Vector3 cameraPos) diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs index 682c080..a9e4b6f 100644 --- a/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs @@ -1,9 +1,9 @@ -// CellVisibilityFromRootTests.cs — UCG W2 Task 2: tests for +// CellVisibilityFromRootTests.cs — UCG W2 Task 2 + Stage 3: tests for // CellVisibility.ComputeVisibilityFromRoot. // -// Two acceptance criteria (from the W2 Task 2 spec): -// (a) ComputeVisibilityFromRoot(null, pos) is fallback-equivalent to -// ComputeVisibility(pos) — same CameraCell answer. +// Acceptance criteria (Stage 3 — W2 null-fallback deleted): +// (a) ComputeVisibilityFromRoot(null, pos) returns NULL (outdoor root), regardless +// of whether any cells are registered. The AABB FindCameraCell fallback is gone. // (b) ComputeVisibilityFromRoot(root, pos) with a registered root returns // a result whose CameraCell is that root, regardless of whether 'pos' // is geometrically inside it. @@ -43,60 +43,53 @@ public class CellVisibilityFromRootTests } // ------------------------------------------------------------------ - // (a) Fallback equivalence: null root → same answer as ComputeVisibility + // (a) Stage 3: null root → null (outdoor root), not a position fallback // ------------------------------------------------------------------ [Fact] - public void ComputeVisibilityFromRoot_NullRoot_FallsBackToPositionBased() + public void ComputeVisibilityFromRoot_NullRoot_ReturnsNull_WhenCellExists() { - // Arrange: one cell covering [0,10]^3, position inside it. + // Stage 3: null root → outdoor root → null result, even when a cell covers the + // fallback position. Pre-Stage 3 this called FindCameraCell(pos); now the caller + // must supply the root (physics CellGraph.CurrCell). Retail: CellManager::ChangePosition + // reads the transition-owned curr_cell — it does not re-derive from a static position. var cv = new CellVisibility(); var cell = MakeCell(0xA9B40101u, Vector3.Zero, new Vector3(10, 10, 10)); cv.AddCell(cell); - var pos = new Vector3(5, 5, 5); // inside the cell + var pos = new Vector3(5, 5, 5); // inside the cell — null root overrides - // Act: null-root and direct-position paths must agree on CameraCell. - var fromPos = cv.ComputeVisibility(pos); var fromNull = cv.ComputeVisibilityFromRoot(null, pos); - // Assert - Assert.NotNull(fromPos); - Assert.NotNull(fromNull); - Assert.Equal(fromPos!.CameraCell?.CellId, fromNull!.CameraCell?.CellId); - Assert.Equal(cell.CellId, fromNull.CameraCell?.CellId); + // Stage 3: null root → null (outdoor root path). + Assert.Null(fromNull); } [Fact] public void ComputeVisibilityFromRoot_NullRoot_NoCells_ReturnsNull() { - // With no cells registered both paths return null. + // With no cells registered and null root: always null (outdoor root). var cv = new CellVisibility(); var posOutdoors = new Vector3(100, 100, 100); - var fromPos = cv.ComputeVisibility(posOutdoors); var fromNull = cv.ComputeVisibilityFromRoot(null, posOutdoors); - Assert.Null(fromPos); Assert.Null(fromNull); } [Fact] public void ComputeVisibilityFromRoot_NullRoot_PositionOutsideAllCells_ReturnsNull() { - // Cell exists but position is outside it — both paths produce null CameraCell. + // Cell exists but null root: always null regardless of position. var cv = new CellVisibility(); var cell = MakeCell(0xA9B40102u, Vector3.Zero, new Vector3(5, 5, 5)); cv.AddCell(cell); var posOutside = new Vector3(100, 100, 100); - var fromPos = cv.ComputeVisibility(posOutside); var fromNull = cv.ComputeVisibilityFromRoot(null, posOutside); - // Both should return null (no grace frames built up yet) - Assert.Null(fromPos?.CameraCell); - Assert.Null(fromNull?.CameraCell); + Assert.Null(fromNull); } // ------------------------------------------------------------------