ComputeVisibilityFromRoot(null, …) now returns null (outdoor root) instead of calling FindCameraCell(fallbackPos). Retail CellManager::ChangePosition (0x004559B0) reads the transition-owned curr_cell — it does NOT re-derive from a static position. W2a guarantees CurrCell is set from the first tick, so the AABB fallback is dead. Deleted: FindCameraCell (389–446), _lastCameraCell, _cellSwitchGraceFrames, CellSwitchGraceFrameCount. GetVisibleCells retains a brute-force AABB scan for test-compat; ComputeVisibility stays for the same reason. Updated 3 null-root tests in CellVisibilityFromRootTests to assert the new null-returns-null behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
506 lines
22 KiB
C#
506 lines
22 KiB
C#
// CellVisibility.cs — portal-based interior cell visibility system.
|
|
//
|
|
// Stage 3 (2026-06-02): FindCameraCell + grace-frame AABB fallback deleted.
|
|
// The physics membership answer (CellGraph.CurrCell) is now the mandatory root;
|
|
// ComputeVisibilityFromRoot(null, …) returns null (outdoor root) rather than
|
|
// falling back to an independent AABB position resolve. This matches retail's
|
|
// CellManager::ChangePosition (0x004559B0) which does not re-derive the cell
|
|
// from a static position — it reads the swept transition-owned CurrCell.
|
|
//
|
|
// 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 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>
|
|
/// 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>
|
|
/// 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 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 for O(1) neighbour resolution during BFS.</summary>
|
|
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
|
|
|
|
/// <summary>The last visibility result produced by <see cref="ComputeVisibilityFromRoot"/>.</summary>
|
|
public VisibilityResult? LastVisibilityResult { get; private set; }
|
|
|
|
/// <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>
|
|
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
|
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</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);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Per-frame entry points
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Computes portal-based visibility from <paramref name="cameraPos"/> using the
|
|
/// AABB FindCameraCell resolver. Retained for test compatibility only; production
|
|
/// code should use <see cref="ComputeVisibilityFromRoot"/> with a physics-supplied
|
|
/// root (Stage 3 demotes the AABB resolver to test-only use).
|
|
/// Returns null when no loaded cell contains <paramref name="cameraPos"/>.
|
|
/// </summary>
|
|
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
|
|
{
|
|
if (_cellLookup.Count == 0)
|
|
{
|
|
LastVisibilityResult = null;
|
|
return null;
|
|
}
|
|
|
|
LastVisibilityResult = GetVisibleCells(cameraPos);
|
|
return LastVisibilityResult;
|
|
}
|
|
|
|
/// <summary>
|
|
/// UCG W2/Stage 3: compute visibility from a supplied root cell (the physics membership
|
|
/// answer). When <paramref name="root"/> is null (pre-spawn, or player outside all indoor
|
|
/// cells), returns <c>null</c> — the caller interprets null as the outdoor root (no portal
|
|
/// frame, everything slot 0, terrain ungated). The legacy AABB FindCameraCell fallback is
|
|
/// deleted as of Stage 3; <see cref="CellGraph.CurrCell"/> is the sole authority.
|
|
/// Retail anchor: CellManager::ChangePosition @ 0x004559B0 reads the transition-owned
|
|
/// curr_cell — it does NOT re-derive from a static position.
|
|
/// </summary>
|
|
/// <param name="root">
|
|
/// The render-registered <see cref="LoadedCell"/> that physics determined the player is inside,
|
|
/// or null when pre-spawn or the player is in an outdoor landcell. Null → outdoor root path.
|
|
/// </param>
|
|
/// <param name="fallbackPos">
|
|
/// 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.
|
|
/// The name "fallback" is historical; it is no longer used as a fallback position.
|
|
/// </param>
|
|
public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos)
|
|
{
|
|
if (root is null)
|
|
return null; // outdoor root: caller handles null as "player is outside"
|
|
// Stage 3: FindCameraCell AABB grace-frame fallback deleted.
|
|
// Retail: CellManager::ChangePosition (0x004559B0) uses transition-owned CurrCell.
|
|
|
|
if (_cellLookup.Count == 0)
|
|
{
|
|
LastVisibilityResult = null;
|
|
return null;
|
|
}
|
|
|
|
LastVisibilityResult = GetVisibleCellsFromRoot(root, fallbackPos);
|
|
return LastVisibilityResult;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// FindCameraCell — DELETED in Stage 3 (2026-06-02)
|
|
// ------------------------------------------------------------------
|
|
// The AABB + grace-frame camera-cell resolver was removed. Production code
|
|
// now exclusively uses ComputeVisibilityFromRoot(root, …) where root is the
|
|
// transition-owned CellGraph.CurrCell (set by ResolveCellId/Stage 2 physics).
|
|
// Retail anchor: CellManager::ChangePosition (0x004559B0) reads curr_cell
|
|
// from the sweep — it never re-derives from a static position.
|
|
//
|
|
// GetVisibleCells (used by ComputeVisibility below for test compatibility)
|
|
// still uses the brute-force AABB scan internally.
|
|
// ------------------------------------------------------------------
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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. Safe to call
|
|
/// independently of <see cref="ComputeVisibilityFromRoot"/> in the same
|
|
/// frame for a different position.
|
|
/// </summary>
|
|
public bool IsInsideAnyCell(Vector3 worldPoint)
|
|
{
|
|
foreach (var cell in _cellLookup.Values)
|
|
if (PointInCell(worldPoint, cell)) return true;
|
|
return false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// GetVisibleCells (BFS)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Performs portal-based BFS visibility traversal starting from the camera
|
|
/// cell found by an AABB brute-force scan. Returns null when no loaded cell
|
|
/// contains <paramref name="cameraPos"/>. Used only by
|
|
/// <see cref="ComputeVisibility"/> (test-compatibility path); production code
|
|
/// uses <see cref="ComputeVisibilityFromRoot"/> with the physics-supplied root.
|
|
/// </summary>
|
|
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
|
|
{
|
|
// Brute-force AABB scan (test-compatibility; FindCameraCell was deleted in Stage 3).
|
|
LoadedCell? cameraCell = null;
|
|
foreach (var kvp in _cellsByLandblock)
|
|
foreach (var cell in kvp.Value)
|
|
if (PointInCell(cameraPos, cell)) { cameraCell = cell; break; }
|
|
|
|
if (cameraCell == null)
|
|
return null;
|
|
|
|
return GetVisibleCellsFromRoot(cameraCell, cameraPos);
|
|
}
|
|
|
|
/// <summary>
|
|
/// UCG W2: BFS visibility traversal from a pre-resolved root cell.
|
|
/// The root is the correct membership answer (supplied by the caller —
|
|
/// physics CurrCell via <see cref="ComputeVisibilityFromRoot"/>, or AABB
|
|
/// scan via <see cref="GetVisibleCells"/> for test compat).
|
|
///
|
|
/// The BFS body is byte-identical to the original 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>();
|
|
|
|
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;
|
|
}
|
|
}
|