acdream/src/AcDream.App/Rendering/CellVisibility.cs
Erik cffc3ee343 feat(render): portal-based EnvCell visibility (Step 4)
Port ACME's EnvCellManager portal visibility system:

- New CellVisibility class: BFS portal traversal from camera cell,
  portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
  world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
  interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
  camera is inside a cell (ACME GameScene.cs pattern)

When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:20:52 +02:00

426 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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).
//
// 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.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------
/// <summary>
/// A loaded EnvCell with portal connectivity and spatial data, used by
/// <see cref="CellVisibility"/> for portal-traversal visibility decisions.
/// </summary>
public sealed class LoadedCell
{
/// <summary>Full 32-bit cell ID, e.g. 0xA9B40105.</summary>
public uint CellId;
/// <summary>Cell origin in world space (used for neighbour distance checks).</summary>
public Vector3 WorldPosition;
/// <summary>Cell-to-world transform (rotation + translation from EnvCell placement).</summary>
public Matrix4x4 WorldTransform;
/// <summary>
/// Cached inverse of <see cref="WorldTransform"/>. Pre-computed at load time so
/// PointInCell doesn't pay the inversion cost per frame.
/// </summary>
public Matrix4x4 InverseWorldTransform;
/// <summary>Local-space AABB minimum, computed from CellStruct vertices.</summary>
public Vector3 LocalBoundsMin;
/// <summary>Local-space AABB maximum, computed from CellStruct vertices.</summary>
public Vector3 LocalBoundsMax;
/// <summary>
/// Ordered portal connections. Index i in Portals corresponds to index i in
/// <see cref="ClipPlanes"/> (when ClipPlanes.Count > i).
/// </summary>
public List<CellPortalInfo> Portals = new();
/// <summary>
/// One clip plane per portal polygon, in cell-local space. Used by the
/// portal-side test to decide whether the camera can see through a portal.
/// Derived from portal polygon geometry during cell preparation.
/// </summary>
public List<PortalClipPlane> ClipPlanes = new();
}
/// <summary>
/// Portal connection to a neighbouring cell.
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
/// </summary>
public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
/// <summary>
/// Clip plane derived from a portal polygon, in cell-local space.
/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
/// </summary>
public struct PortalClipPlane
{
/// <summary>Plane normal (cell-local space, unit length).</summary>
public Vector3 Normal;
/// <summary>Plane offset so that Dot(Normal, point) + D = 0 on the plane.</summary>
public float D;
/// <summary>
/// Which half-space is "inside" this cell (the side from which you look outward
/// through the portal):
/// 0 → camera dot-product must be >= 0 (positive half-space is inside)
/// 1 → camera dot-product must be &lt;= 0 (negative half-space is inside)
/// Determined from cell centroid position relative to the portal plane.
/// Ported from ACME EnvCellManager.cs ~line 404.
/// </summary>
public int InsideSide;
}
/// <summary>
/// Result of a portal-based visibility BFS from the camera cell.
/// </summary>
public sealed class VisibilityResult
{
/// <summary>Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.</summary>
public HashSet<uint> VisibleCellIds { get; init; } = new();
/// <summary>
/// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during
/// traversal. The caller should render outdoor terrain when this is set.
/// </summary>
public bool HasExitPortalVisible { get; set; }
/// <summary>The cell the camera is currently inside.</summary>
public LoadedCell? CameraCell { get; set; }
}
// ---------------------------------------------------------------------------
// CellVisibility
// ---------------------------------------------------------------------------
/// <summary>
/// Pure-logic portal visibility system for EnvCell interior rooms.
///
/// Maintains a per-landblock registry of <see cref="LoadedCell"/> objects and
/// performs a BFS through portal connections each frame to determine which cells
/// should be rendered given the current camera position.
///
/// Ported faithfully from ACME's EnvCellManager.cs portal-visibility region.
/// Constants and control flow match the ACME implementation.
/// </summary>
public sealed class CellVisibility
{
// ------------------------------------------------------------------
// Constants (ACME ground-truth values)
// ------------------------------------------------------------------
/// <summary>
/// Epsilon applied to AABB containment tests so that a camera sitting
/// exactly on a cell wall is still considered inside.
/// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f.
/// </summary>
private const float PointInCellEpsilon = 0.01f;
/// <summary>
/// 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.
/// </summary>
private const int CellSwitchGraceFrameCount = 3;
// ------------------------------------------------------------------
// State
// ------------------------------------------------------------------
/// <summary>Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID.</summary>
private readonly Dictionary<uint, List<LoadedCell>> _cellsByLandblock = new();
/// <summary>Full-ID lookup for O(1) neighbour resolution during BFS.</summary>
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
/// <summary>The cell the camera was in during the last <see cref="ComputeVisibility"/> call.</summary>
private LoadedCell? _lastCameraCell;
/// <summary>Frames remaining in the grace period after the camera left _lastCameraCell.</summary>
private int _cellSwitchGraceFrames;
/// <summary>The last visibility result produced by <see cref="ComputeVisibility"/>.</summary>
public VisibilityResult? LastVisibilityResult { get; private set; }
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
/// <summary>
/// Registers a newly-loaded cell. Called from the streaming loader after
/// CPU preparation (transforms, clip planes, bounds) is complete.
/// Thread-safety: caller must not call this concurrently with rendering.
/// </summary>
public void AddCell(LoadedCell cell)
{
uint lbId = cell.CellId >> 16;
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
{
list = new List<LoadedCell>();
_cellsByLandblock[lbId] = list;
}
list.Add(cell);
_cellLookup[cell.CellId] = cell;
}
/// <summary>
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
/// landblock unloads.
/// </summary>
public void RemoveLandblock(uint lbId)
{
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
return;
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);
}
// ------------------------------------------------------------------
// Per-frame entry point
// ------------------------------------------------------------------
/// <summary>
/// Computes portal-based visibility from <paramref name="cameraPos"/> and
/// caches the result in <see cref="LastVisibilityResult"/>.
///
/// 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).
/// </summary>
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
{
if (_cellLookup.Count == 0)
{
LastVisibilityResult = null;
return null;
}
LastVisibilityResult = GetVisibleCells(cameraPos);
return LastVisibilityResult;
}
// ------------------------------------------------------------------
// FindCameraCell
// ------------------------------------------------------------------
/// <summary>
/// Finds the <see cref="LoadedCell"/> 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().
/// </summary>
public LoadedCell? FindCameraCell(Vector3 cameraPos)
{
// 1. Fast path: cached cell.
if (_lastCameraCell != null && PointInCell(cameraPos, _lastCameraCell))
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;
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;
return cell;
}
}
}
// 4. Grace period: keep the previous cell alive for a few frames.
if (_lastCameraCell != null && _cellSwitchGraceFrames > 0)
{
_cellSwitchGraceFrames--;
return _lastCameraCell;
}
// 5. Camera is outside all cells.
_lastCameraCell = null;
return null;
}
// ------------------------------------------------------------------
// PointInCell
// ------------------------------------------------------------------
/// <summary>
/// Returns true when <paramref name="worldPoint"/> lies inside
/// <paramref name="cell"/>'s local-space AABB (within epsilon).
///
/// The point is transformed into cell-local space via the pre-computed
/// <see cref="LoadedCell.InverseWorldTransform"/> and then tested against
/// <see cref="LoadedCell.LocalBoundsMin"/> / <see cref="LoadedCell.LocalBoundsMax"/>.
///
/// Ported from ACME EnvCellManager.cs PointInCell().
/// </summary>
public static bool PointInCell(Vector3 worldPoint, LoadedCell cell)
{
// Degenerate cell (no geometry baked yet).
if (cell.LocalBoundsMin.X >= cell.LocalBoundsMax.X)
return false;
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return local.X >= cell.LocalBoundsMin.X - PointInCellEpsilon &&
local.X <= cell.LocalBoundsMax.X + PointInCellEpsilon &&
local.Y >= cell.LocalBoundsMin.Y - PointInCellEpsilon &&
local.Y <= cell.LocalBoundsMax.Y + PointInCellEpsilon &&
local.Z >= cell.LocalBoundsMin.Z - PointInCellEpsilon &&
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
}
// ------------------------------------------------------------------
// GetVisibleCells (BFS)
// ------------------------------------------------------------------
/// <summary>
/// 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 <see cref="CellVisibility"/> 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().
/// </summary>
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
{
var cameraCell = FindCameraCell(cameraPos);
if (cameraCell == null)
return null;
var result = new VisibilityResult { CameraCell = cameraCell };
var visited = new HashSet<uint>();
var queue = new Queue<LoadedCell>();
visited.Add(cameraCell.CellId);
result.VisibleCellIds.Add(cameraCell.CellId);
queue.Enqueue(cameraCell);
// All portals in a dungeon connect cells in the same landblock.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
while (queue.Count > 0)
{
var cell = queue.Dequeue();
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
// Exit portal → outdoor terrain should be visible.
if (portal.OtherCellId == 0xFFFF)
{
result.HasExitPortalVisible = true;
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
if (visited.Contains(neighbourId))
continue;
if (!_cellLookup.TryGetValue(neighbourId, out var neighbour))
continue;
// Portal-side test: camera must be on the interior side of the
// portal clip plane to see through into the neighbouring cell.
if (i < cell.ClipPlanes.Count)
{
var plane = cell.ClipPlanes[i];
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
// InsideSide == 0 → inside is positive half-space; reject if dot < -ε.
// InsideSide == 1 → inside is negative half-space; reject if dot > ε.
// Source: ACME EnvCellManager.cs lines 1458-1459.
if (plane.InsideSide == 0 && dot < -PointInCellEpsilon)
continue;
if (plane.InsideSide == 1 && dot > PointInCellEpsilon)
continue;
}
visited.Add(neighbourId);
result.VisibleCellIds.Add(neighbourId);
queue.Enqueue(neighbour);
}
}
return result;
}
}