From 02acac55728eff9fde696265e12b6c6e4a1dc79d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jun 2026 10:24:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20UCG=20W2=20Task=202=20=E2=80=94=20?= =?UTF-8?q?render=20root=20from=20physics=20CurrCell=20(FindCameraCell=20f?= =?UTF-8?q?allback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the BFS visibility root to DataCache.CellGraph.CurrCell (the physics membership answer written in W2 Task 1) rather than resolving independently from a position via FindCameraCell. Closes the render/physics disagreement that causes the "world from below" spawn-in flicker. Changes: - CellVisibility.GetVisibleCells: extracted BFS body into new private GetVisibleCellsFromRoot(LoadedCell root, Vector3 cameraPos); existing GetVisibleCells delegates to it after FindCameraCell (behavior unchanged). - CellVisibility.ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos): new public entry point; when root is null falls through to ComputeVisibility (exact today's behavior), otherwise sets _lastCameraCell = root and delegates to GetVisibleCellsFromRoot — cannot regress below baseline. - GameWindow (line 7156): replaced ComputeVisibility(visRootPos) with ComputeVisibilityFromRoot(physicsRoot, visRootPos) where physicsRoot is resolved from _physicsEngine.DataCache.CellGraph.CurrCell via TryGetCell. physicsRoot is null whenever CurrCell is null or its id is not yet in the render registry, so the fallback fires until the cell loads. - 6 new tests in CellVisibilityFromRootTests: null-root fallback equivalence (3 cases), registered root → CameraCell == root (3 cases). All 160 App.Tests pass, 0 regressions. Visual verification PENDING — behavior change; do not claim it works visually. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/CellVisibility.cs | 49 +++++- src/AcDream.App/Rendering/GameWindow.cs | 12 +- .../Rendering/CellVisibilityFromRootTests.cs | 150 ++++++++++++++++++ 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index 7dde694..fc4b5a1 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -313,7 +313,7 @@ public sealed class CellVisibility } // ------------------------------------------------------------------ - // Per-frame entry point + // Per-frame entry points // ------------------------------------------------------------------ /// @@ -336,6 +336,39 @@ public sealed class CellVisibility return LastVisibilityResult; } + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// Should be the player/physics position (stable inside the cell), not the chase-camera eye. + /// + public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos) + { + if (root is null) + return ComputeVisibility(fallbackPos); + + if (_cellLookup.Count == 0) + { + LastVisibilityResult = null; + return null; + } + + _lastCameraCell = root; + LastVisibilityResult = GetVisibleCellsFromRoot(root, fallbackPos); + return LastVisibilityResult; + } + // ------------------------------------------------------------------ // FindCameraCell // ------------------------------------------------------------------ @@ -491,6 +524,20 @@ public sealed class CellVisibility if (cameraCell == null) return null; + return GetVisibleCellsFromRoot(cameraCell, cameraPos); + } + + /// + /// 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 BFS body is byte-identical to the original + /// implementation — only root acquisition was extracted out. + /// + private VisibilityResult? GetVisibleCellsFromRoot(LoadedCell cameraCell, Vector3 cameraPos) + { var result = new VisibilityResult { CameraCell = cameraCell }; var visited = new HashSet(); var queue = new Queue(); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 79f79c9..6431682 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7153,7 +7153,17 @@ public sealed class GameWindow : IDisposable var visRootPos = (_playerMode && _playerController is not null) ? _playerController.Position : camPos; - var visibility = _cellVisibility.ComputeVisibility(visRootPos); + // 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); bool cameraInsideCell = visibility?.CameraCell is not null; // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs new file mode 100644 index 0000000..682c080 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityFromRootTests.cs @@ -0,0 +1,150 @@ +// CellVisibilityFromRootTests.cs — UCG W2 Task 2: 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. +// (b) ComputeVisibilityFromRoot(root, pos) with a registered root returns +// a result whose CameraCell is that root, regardless of whether 'pos' +// is geometrically inside it. +// +// CellVisibility is intentionally free of GL types — it can be unit-tested +// without a GPU context (confirmed: only System.Numerics dependency). + +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class CellVisibilityFromRootTests +{ + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// + /// Build a minimal LoadedCell with an axis-aligned bounding box and identity + /// transform so PointInCell works for a position inside the box. + /// + private static LoadedCell MakeCell(uint cellId, Vector3 boundsMin, Vector3 boundsMax) + { + return new LoadedCell + { + CellId = cellId, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + LocalBoundsMin = boundsMin, + LocalBoundsMax = boundsMax, + Portals = new(), + ClipPlanes = new(), + PortalPolygons = new(), + }; + } + + // ------------------------------------------------------------------ + // (a) Fallback equivalence: null root → same answer as ComputeVisibility + // ------------------------------------------------------------------ + + [Fact] + public void ComputeVisibilityFromRoot_NullRoot_FallsBackToPositionBased() + { + // Arrange: one cell covering [0,10]^3, position inside it. + 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 + + // 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); + } + + [Fact] + public void ComputeVisibilityFromRoot_NullRoot_NoCells_ReturnsNull() + { + // With no cells registered both paths return null. + 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. + 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); + } + + // ------------------------------------------------------------------ + // (b) Supplied root is used as BFS root → CameraCell == root + // ------------------------------------------------------------------ + + [Fact] + public void ComputeVisibilityFromRoot_RegisteredRoot_CameraCellIsSuppliedRoot() + { + // Arrange: cell registered in CellVisibility. + var cv = new CellVisibility(); + var cell = MakeCell(0xA9B40103u, Vector3.Zero, new Vector3(10, 10, 10)); + cv.AddCell(cell); + + // The position can be OUTSIDE the cell — physics already determined membership + // via BSP, we just trust that answer. + var posAnywhere = new Vector3(999, 999, 999); + + // Act + var result = cv.ComputeVisibilityFromRoot(cell, posAnywhere); + + // Assert: CameraCell is the supplied root. + Assert.NotNull(result); + Assert.Same(cell, result!.CameraCell); + } + + [Fact] + public void ComputeVisibilityFromRoot_RegisteredRoot_IncludesRootInVisibleCells() + { + var cv = new CellVisibility(); + var cell = MakeCell(0xA9B40104u, Vector3.Zero, new Vector3(10, 10, 10)); + cv.AddCell(cell); + + var result = cv.ComputeVisibilityFromRoot(cell, Vector3.Zero); + + Assert.NotNull(result); + Assert.Contains(cell.CellId, result!.VisibleCellIds); + } + + [Fact] + public void ComputeVisibilityFromRoot_RegisteredRoot_LastVisibilityResultUpdated() + { + var cv = new CellVisibility(); + var cell = MakeCell(0xA9B40105u, Vector3.Zero, new Vector3(10, 10, 10)); + cv.AddCell(cell); + + var result = cv.ComputeVisibilityFromRoot(cell, Vector3.Zero); + + Assert.Same(result, cv.LastVisibilityResult); + } +}