feat(app): UCG W2 Task 2 — render root from physics CurrCell (FindCameraCell fallback)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 10:24:23 +02:00
parent 0e27a6cc3f
commit 02acac5572
3 changed files with 209 additions and 2 deletions

View file

@ -313,7 +313,7 @@ public sealed class CellVisibility
}
// ------------------------------------------------------------------
// Per-frame entry point
// Per-frame entry points
// ------------------------------------------------------------------
/// <summary>
@ -336,6 +336,39 @@ public sealed class CellVisibility
return LastVisibilityResult;
}
/// <summary>
/// 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
/// <see cref="ComputeVisibility(Vector3)"/> when <paramref name="root"/> is null (e.g. the
/// cell isn't registered with this renderer yet), so it never regresses below baseline.
/// </summary>
/// <param name="root">
/// The render-registered <see cref="LoadedCell"/> 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 <see cref="ComputeVisibility"/>
/// path, which preserves today's exact AABB-based behavior.
/// </param>
/// <param name="fallbackPos">
/// Position passed to <see cref="ComputeVisibility"/> when <paramref name="root"/> 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.
/// </param>
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);
}
/// <summary>
/// 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 <see cref="GetVisibleCells"/> via <see cref="FindCameraCell"/>,
/// or <see cref="ComputeVisibilityFromRoot"/> via the physics CurrCell answer).
///
/// The BFS body is byte-identical to the original <see cref="GetVisibleCells"/>
/// implementation — only root acquisition was extracted out.
/// </summary>
private VisibilityResult? GetVisibleCellsFromRoot(LoadedCell cameraCell, Vector3 cameraPos)
{
var result = new VisibilityResult { CameraCell = cameraCell };
var visited = new HashSet<uint>();
var queue = new Queue<LoadedCell>();

View file

@ -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

View file

@ -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
// ------------------------------------------------------------------
/// <summary>
/// Build a minimal LoadedCell with an axis-aligned bounding box and identity
/// transform so PointInCell works for a position inside the box.
/// </summary>
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);
}
}