This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
542 lines
24 KiB
C#
542 lines
24 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>
|
|
/// 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>
|
|
/// 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>
|
|
/// 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);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
}
|