// 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
// ---------------------------------------------------------------------------
///
/// A loaded EnvCell with portal connectivity and spatial data, used by
/// for portal-traversal visibility decisions.
///
public sealed class LoadedCell
{
/// Full 32-bit cell ID, e.g. 0xA9B40105.
public uint CellId;
/// Cell origin in world space (used for neighbour distance checks).
public Vector3 WorldPosition;
/// Cell-to-world transform (rotation + translation from EnvCell placement).
public Matrix4x4 WorldTransform;
///
/// Cached inverse of . Pre-computed at load time so
/// PointInCell doesn't pay the inversion cost per frame.
///
public Matrix4x4 InverseWorldTransform;
/// Local-space AABB minimum, computed from CellStruct vertices.
public Vector3 LocalBoundsMin;
/// Local-space AABB maximum, computed from CellStruct vertices.
public Vector3 LocalBoundsMax;
///
/// Ordered portal connections. Index i in Portals corresponds to index i in
/// (when ClipPlanes.Count > i).
///
public List Portals = new();
///
/// 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.
///
public List ClipPlanes = new();
}
///
/// Portal connection to a neighbouring cell.
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
///
public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
///
/// Clip plane derived from a portal polygon, in cell-local space.
/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
///
public struct PortalClipPlane
{
/// Plane normal (cell-local space, unit length).
public Vector3 Normal;
/// Plane offset so that Dot(Normal, point) + D = 0 on the plane.
public float D;
///
/// 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 <= 0 (negative half-space is inside)
/// Determined from cell centroid position relative to the portal plane.
/// Ported from ACME EnvCellManager.cs ~line 404.
///
public int InsideSide;
}
///
/// Result of a portal-based visibility BFS from the camera cell.
///
public sealed class VisibilityResult
{
/// Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.
public HashSet VisibleCellIds { get; init; } = new();
///
/// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during
/// traversal. The caller should render outdoor terrain when this is set.
///
public bool HasExitPortalVisible { get; set; }
/// The cell the camera is currently inside.
public LoadedCell? CameraCell { get; set; }
}
// ---------------------------------------------------------------------------
// CellVisibility
// ---------------------------------------------------------------------------
///
/// Pure-logic portal visibility system for EnvCell interior rooms.
///
/// Maintains a per-landblock registry of 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.
///
public sealed class CellVisibility
{
// ------------------------------------------------------------------
// Constants (ACME ground-truth values)
// ------------------------------------------------------------------
///
/// 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.
///
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
// ------------------------------------------------------------------
/// Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID.
private readonly Dictionary> _cellsByLandblock = new();
/// 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 .
public VisibilityResult? LastVisibilityResult { get; private set; }
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
///
/// 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.
///
public void AddCell(LoadedCell cell)
{
uint lbId = cell.CellId >> 16;
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
{
list = new List();
_cellsByLandblock[lbId] = list;
}
list.Add(cell);
_cellLookup[cell.CellId] = cell;
}
///
/// Removes all cells belonging to (upper 16 bits of
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
/// landblock unloads.
///
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
// ------------------------------------------------------------------
///
/// 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).
///
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
{
if (_cellLookup.Count == 0)
{
LastVisibilityResult = null;
return null;
}
LastVisibilityResult = GetVisibleCells(cameraPos);
return LastVisibilityResult;
}
// ------------------------------------------------------------------
// FindCameraCell
// ------------------------------------------------------------------
///
/// 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))
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
// ------------------------------------------------------------------
///
/// Returns true when lies inside
/// 's local-space AABB (within epsilon).
///
/// The point is transformed into cell-local space via the pre-computed
/// and then tested against
/// / .
///
/// Ported from ACME EnvCellManager.cs PointInCell().
///
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)
// ------------------------------------------------------------------
///
/// 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().
///
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
{
var cameraCell = FindCameraCell(cameraPos);
if (cameraCell == null)
return null;
var result = new VisibilityResult { CameraCell = cameraCell };
var visited = new HashSet();
var queue = new Queue();
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;
}
}