// 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; } }