acdream/src/AcDream.App/Rendering/CellVisibility.cs
Erik 5dc4140c11 feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:14:50 +02:00

491 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
// ---------------------------------------------------------------------------
/// <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>
/// Portal connection to a neighbouring cell.
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
/// </summary>
public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
/// <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 &lt;= 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>
/// 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 camera sitting
/// exactly on a cell wall is still considered inside.
/// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f.
/// </summary>
private const float PointInCellEpsilon = 0.01f;
/// <summary>
/// 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.
/// </summary>
private const int CellSwitchGraceFrameCount = 3;
// ------------------------------------------------------------------
// 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 cell the camera was in during the last <see cref="ComputeVisibility"/> call.</summary>
private LoadedCell? _lastCameraCell;
/// <summary>Frames remaining in the grace period after the camera left _lastCameraCell.</summary>
private int _cellSwitchGraceFrames;
/// <summary>The last visibility result produced by <see cref="ComputeVisibility"/>.</summary>
public VisibilityResult? LastVisibilityResult { get; private set; }
// ------------------------------------------------------------------
// 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);
// 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
// ------------------------------------------------------------------
/// <summary>
/// Computes portal-based visibility from <paramref name="cameraPos"/> and
/// caches the result in <see cref="LastVisibilityResult"/>.
///
/// 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).
/// </summary>
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
{
if (_cellLookup.Count == 0)
{
LastVisibilityResult = null;
return null;
}
LastVisibilityResult = GetVisibleCells(cameraPos);
return LastVisibilityResult;
}
// ------------------------------------------------------------------
// FindCameraCell
// ------------------------------------------------------------------
/// <summary>
/// Finds the <see cref="LoadedCell"/> 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().
/// </summary>
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
// ------------------------------------------------------------------
/// <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. Does not touch
/// the camera cache (<see cref="_lastCameraCell"/>), so this is safe
/// to call alongside <see cref="ComputeVisibility"/> in the same frame
/// for a different position (e.g. player position when the camera is
/// in third-person chase mode).
/// </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. 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 <see cref="CellVisibility"/> 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().
/// </summary>
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
{
var cameraCell = FindCameraCell(cameraPos);
if (cameraCell == null)
return null;
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;
}
}