357 lines
15 KiB
C#
357 lines
15 KiB
C#
// CellVisibility.cs — portal-based interior cell visibility system.
|
|
//
|
|
// Campaign FW4 (2026-08-31): the obsolete per-frame portal BFS is deleted.
|
|
// The committed cell registry remains the production walk's authoritative
|
|
// source. Physics supplies the root and RetailFrameWalk owns visibility.
|
|
//
|
|
// 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;
|
|
using AcDream.App.Rendering.Walk;
|
|
|
|
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 polygon vertices in cell-local space, one Vector3[] per
|
|
/// <see cref="CellPortalInfo"/> entry in <see cref="Portals"/>. Index i
|
|
/// in this list corresponds to index i in <see cref="Portals"/> and
|
|
/// <see cref="ClipPlanes"/>. An empty array means the portal's polygon
|
|
/// could not be resolved at load time (degenerate cell or missing
|
|
/// polygon entry).
|
|
/// <para>
|
|
/// Used by the Phase A8 indoor-cell stencil pipeline to build a
|
|
/// per-frame triangle-fan mesh for portal silhouette masking.
|
|
/// </para>
|
|
/// </summary>
|
|
public List<Vector3[]> PortalPolygons = new();
|
|
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-26): the building this cell belongs to, if any.
|
|
/// Set exactly once by <see cref="Wb.BuildingLoader"/> immediately after
|
|
/// LandblockLoader produces the cells. Null when the cell isn't part of
|
|
/// any building (outdoor surface cells; dungeon cells not enumerated in
|
|
/// LandBlockInfo.Buildings).
|
|
///
|
|
/// <para>Used by the render frame to derive the camera-buildings set
|
|
/// via <see cref="Wb.BuildingRegistry.GetBuildingsContainingCell"/>
|
|
/// and route IndoorPass cell scoping.</para>
|
|
/// </summary>
|
|
public uint? BuildingId { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Phase U.4c: the stab_list PVS as full (landblock-prefixed) cell ids — retail
|
|
/// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially
|
|
/// visible from this cell, precomputed by the AC content tools. Refreshed only at
|
|
/// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878).
|
|
/// PortalVisibilityBuilder grounds set membership in it so a brittle per-frame
|
|
/// portal-side test can't drop a potentially-visible cell from the visible set.
|
|
/// Empty when the dat carried no stab list (degenerate / old cell).
|
|
/// </summary>
|
|
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();
|
|
|
|
/// <summary>
|
|
/// Phase U.4c: retail CEnvCell.seen_outside (acclient.h ~30925) — this cell sees
|
|
/// the exterior (an exit portal is reachable from it). Retail gates the landscape
|
|
/// data + draw decision on the camera cell's value (RenderNormalMode decomp:92649,
|
|
/// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test.
|
|
/// </summary>
|
|
public bool SeenOutside;
|
|
|
|
/// <summary>
|
|
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
|
|
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a flood-graph cell whose
|
|
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView
|
|
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
|
|
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
|
|
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
|
|
/// </summary>
|
|
public bool IsOutdoorNode;
|
|
|
|
/// <summary>
|
|
/// Campaign FW3.1: the retail frame walk's model of this cell (portal
|
|
/// side/exact-match decode, portal polygons + planes, stab list,
|
|
/// UNLIFTED transform), built atomically alongside this
|
|
/// <see cref="LoadedCell"/> by
|
|
/// <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c> — see
|
|
/// <c>WalkCellFactory.FromParsed</c>. Null only for hand-built test
|
|
/// fixtures that construct a <see cref="LoadedCell"/> directly instead of
|
|
/// through the streaming build. <c>WalkProductionFrameContext.GetVisible</c>
|
|
/// is the walk's sole read of this field — the committed registry
|
|
/// (<see cref="CellVisibility.TryGetCell"/>) is the walk's only cell
|
|
/// source; its dead BFS role is unrelated.
|
|
/// </summary>
|
|
public WalkCell? Walk { get; internal set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Portal connection to a neighbouring cell.
|
|
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
|
|
/// <para>
|
|
/// <see cref="OtherPortalId"/> is the dat's reciprocal back-link: the index of
|
|
/// the portal WITHIN the neighbour cell's portal list that points back through
|
|
/// this same opening. Retail indexes the reciprocal directly via this field
|
|
/// (<c>arg2->other_portal_id</c>, decomp:433557) rather than scanning — which
|
|
/// is what lets a cell with TWO portals to the same neighbour resolve each
|
|
/// opening against its OWN reciprocal polygon instead of the first match.
|
|
/// </para>
|
|
/// </summary>
|
|
public readonly record struct CellPortalInfo(
|
|
ushort OtherCellId, ushort PolygonId, ushort Flags, ushort OtherPortalId);
|
|
|
|
/// <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 <= 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>
|
|
/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked
|
|
/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained
|
|
/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that
|
|
/// still prints <see cref="LastCameraCellResolution"/> (always None post-Stage 3).
|
|
/// </summary>
|
|
public enum CameraCellResolution
|
|
{
|
|
/// <summary>No cell contains the eye (outdoors), or not yet resolved.</summary>
|
|
None,
|
|
/// <summary>The eye is inside the previously-cached cell (fast path).</summary>
|
|
Cache,
|
|
/// <summary>The eye is inside a one-hop portal neighbour of the cached cell.</summary>
|
|
Neighbour,
|
|
/// <summary>The eye is inside a cell found by the full brute-force scan.</summary>
|
|
BruteForce,
|
|
/// <summary>The eye is inside NO cell, but the previous cell is kept alive for a
|
|
/// few grace frames — the "stale root" case the flap probe watches for.</summary>
|
|
Grace,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Committed EnvCell registry shared by streaming, physics diagnostics, and the
|
|
/// retail frame walk. It owns no visibility algorithm; <see cref="RetailFrameWalk"/>
|
|
/// is the sole per-frame visibility authority.
|
|
/// </summary>
|
|
public sealed class CellVisibility
|
|
{
|
|
// ------------------------------------------------------------------
|
|
// Constants (ACME ground-truth values)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Epsilon applied to AABB containment tests so that a position sitting
|
|
/// exactly on a cell wall is still considered inside.
|
|
/// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f.
|
|
/// </summary>
|
|
private const float PointInCellEpsilon = 0.01f;
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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 used by the production frame walk.</summary>
|
|
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
|
|
|
|
/// <summary>
|
|
/// Stage 3 (2026-06-02): always <see cref="CameraCellResolution.None"/> — the FindCameraCell
|
|
/// AABB grace-frame resolver was deleted; the physics membership answer is the sole root.
|
|
/// Retained for the [flap-cam] probe log line in GameWindow.cs.
|
|
/// </summary>
|
|
public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None;
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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>
|
|
/// Atomically replaces one landblock's complete portal-cell set on the
|
|
/// render thread. Streaming workers build the input privately; no partially
|
|
/// hydrated landblock is ever visible to the per-frame flood.
|
|
/// </summary>
|
|
/// <param name="landblockId">Full landblock id, e.g. 0xA9B4FFFF.</param>
|
|
public void CommitLandblock(uint landblockId, IReadOnlyList<LoadedCell> cells)
|
|
{
|
|
uint prefix = landblockId >> 16;
|
|
if (cells.Any(cell => (cell.CellId >> 16) != prefix))
|
|
throw new ArgumentException(
|
|
"A visibility cell belongs to a different landblock.",
|
|
nameof(cells));
|
|
|
|
if (_cellsByLandblock.TryGetValue(prefix, out var previous))
|
|
{
|
|
foreach (var cell in previous)
|
|
_cellLookup.Remove(cell.CellId);
|
|
}
|
|
|
|
var committed = new List<LoadedCell>(cells);
|
|
_cellsByLandblock[prefix] = committed;
|
|
foreach (var cell in committed)
|
|
_cellLookup[cell.CellId] = cell;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
|
/// landblock prefix. Used by <c>LandblockRenderPublisher</c> when building
|
|
/// the per-landblock <c>BuildingRegistry</c> — the per-frame
|
|
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
|
|
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
|
|
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
|
|
/// </summary>
|
|
/// <param name="lbId">Upper 16 bits of the landblock key (e.g. <c>0xA9B4</c>
|
|
/// for landblock <c>0xA9B40000</c>). NOT the full 32-bit landblock id.</param>
|
|
public IReadOnlyList<LoadedCell> GetCellsForLandblock(uint lbId)
|
|
{
|
|
return _cellsByLandblock.TryGetValue(lbId, out var list)
|
|
? list
|
|
: System.Array.Empty<LoadedCell>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Looks up a currently loaded cell by full 32-bit cell id.
|
|
/// </summary>
|
|
public bool TryGetCell(uint cellId, out LoadedCell? cell)
|
|
=> _cellLookup.TryGetValue(cellId, out 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);
|
|
}
|
|
|
|
_cellsByLandblock.Remove(lbId);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Brute-force scan of every loaded cell to test whether
|
|
/// <paramref name="worldPoint"/> is inside any of them.
|
|
/// </summary>
|
|
public bool IsInsideAnyCell(Vector3 worldPoint)
|
|
{
|
|
foreach (var cell in _cellLookup.Values)
|
|
if (PointInCell(worldPoint, cell)) return true;
|
|
return false;
|
|
}
|
|
|
|
}
|