fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -14,9 +14,26 @@ namespace AcDream.App.Rendering;
|
|||
/// <summary>Per-frame output of the portal-frame BFS.</summary>
|
||||
public sealed class PortalVisibilityFrame
|
||||
{
|
||||
private const int MaxRetainedCellViews = 512;
|
||||
internal const int MaxRetainedBuildCollectionCapacity = 512;
|
||||
internal const int CapacityTrimIdleFrames = 120;
|
||||
private readonly Stack<CellView> _cellViewPool = new();
|
||||
private readonly PortalPolygonVertexStore _polygonVertices = new();
|
||||
private int _cellViewsUnderusedFrames;
|
||||
private int _crossBuildingViewsUnderusedFrames;
|
||||
private int _queuedUnderusedFrames;
|
||||
private int _drawListedUnderusedFrames;
|
||||
private int _processedViewCountsUnderusedFrames;
|
||||
private int _orderedVisibleCellsUnderusedFrames;
|
||||
private int _todoUnderusedFrames;
|
||||
|
||||
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
|
||||
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
|
||||
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
|
||||
|
||||
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
||||
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
||||
public CellView OutsideView { get; } = new();
|
||||
public CellView OutsideView { get; private set; } = new();
|
||||
|
||||
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
|
||||
public Dictionary<uint, CellView> CellViews { get; } = new();
|
||||
|
|
@ -31,6 +48,199 @@ public sealed class PortalVisibilityFrame
|
|||
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
|
||||
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
|
||||
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
||||
|
||||
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
|
||||
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
|
||||
// where retail runs one small ConstructView flood per nearby building every frame.
|
||||
internal HashSet<uint> QueuedScratch { get; } = new();
|
||||
internal HashSet<uint> DrawListedScratch { get; } = new();
|
||||
internal Dictionary<uint, int> ProcessedViewCountsScratch { get; } = new();
|
||||
internal uint[] PropagationChainScratch { get; } = new uint[128];
|
||||
internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new();
|
||||
|
||||
// A build can recurse while an outer portal's clipped region is still live, so one mutable
|
||||
// singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call
|
||||
// count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets
|
||||
// one per active depth. Dispose returns scratch on every continue/exception path.
|
||||
private readonly Stack<List<ViewPolygon>> _clipRegionPool = new();
|
||||
private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work
|
||||
private const int MaxRetainedClipRegionCapacity = 256;
|
||||
|
||||
internal int ClipRegionScratchCount => _clipRegionPool.Count;
|
||||
internal int RetainedCellViewCount => _cellViewPool.Count;
|
||||
internal int CellViewMapCapacity => CellViews.EnsureCapacity(0);
|
||||
internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0);
|
||||
internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0);
|
||||
internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0);
|
||||
internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0);
|
||||
|
||||
internal ClipRegionScratchLease RentClipRegionScratch()
|
||||
{
|
||||
List<ViewPolygon> region = _clipRegionPool.Count != 0
|
||||
? _clipRegionPool.Pop()
|
||||
: new List<ViewPolygon>(2);
|
||||
region.Clear();
|
||||
return new ClipRegionScratchLease(this, region);
|
||||
}
|
||||
|
||||
private void ReturnClipRegionScratch(List<ViewPolygon> region)
|
||||
{
|
||||
region.Clear();
|
||||
if (region.Capacity <= MaxRetainedClipRegionCapacity
|
||||
&& _clipRegionPool.Count < MaxRetainedClipRegionLists)
|
||||
_clipRegionPool.Push(region);
|
||||
}
|
||||
|
||||
internal readonly ref struct ClipRegionScratchLease
|
||||
{
|
||||
private readonly PortalVisibilityFrame _owner;
|
||||
public List<ViewPolygon> Region { get; }
|
||||
|
||||
internal ClipRegionScratchLease(PortalVisibilityFrame owner, List<ViewPolygon> region)
|
||||
{
|
||||
_owner = owner;
|
||||
Region = region;
|
||||
}
|
||||
|
||||
public void Dispose() => _owner.ReturnClipRegionScratch(Region);
|
||||
}
|
||||
|
||||
internal void ResetForBuild()
|
||||
{
|
||||
int cellViewCount = CellViews.Count;
|
||||
int crossBuildingViewCount = CrossBuildingViews.Count;
|
||||
int queuedCount = QueuedScratch.Count;
|
||||
int drawListedCount = DrawListedScratch.Count;
|
||||
int processedViewCount = ProcessedViewCountsScratch.Count;
|
||||
int orderedVisibleCellCount = OrderedVisibleCells.Count;
|
||||
int todoCount = TodoScratch.Count;
|
||||
|
||||
if (OutsideView.IsRetainable)
|
||||
OutsideView.Reset();
|
||||
else
|
||||
OutsideView = new CellView();
|
||||
ReturnCellViews(CellViews);
|
||||
ReturnCellViews(CrossBuildingViews);
|
||||
CellViews.Clear();
|
||||
OrderedVisibleCells.Clear();
|
||||
CrossBuildingViews.Clear();
|
||||
QueuedScratch.Clear();
|
||||
DrawListedScratch.Clear();
|
||||
ProcessedViewCountsScratch.Clear();
|
||||
TodoScratch.Clear();
|
||||
_polygonVertices.ResetUsage();
|
||||
|
||||
// These collections live as long as RetailPViewRenderer. A fixed
|
||||
// capacity cutoff is unsafe here: a legitimate large portal flood can
|
||||
// exceed it every frame, causing us to discard and rebuild the same
|
||||
// warmed tables forever. Retire high-water storage only after it has
|
||||
// remained at least 50% underused for a sustained idle window. Once a
|
||||
// collection is right-sized for its recurring workload it stays warm.
|
||||
TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames);
|
||||
TrimIfCold(
|
||||
CrossBuildingViews,
|
||||
crossBuildingViewCount,
|
||||
ref _crossBuildingViewsUnderusedFrames);
|
||||
TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames);
|
||||
TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames);
|
||||
TrimIfCold(
|
||||
ProcessedViewCountsScratch,
|
||||
processedViewCount,
|
||||
ref _processedViewCountsUnderusedFrames);
|
||||
TrimIfCold(
|
||||
OrderedVisibleCells,
|
||||
orderedVisibleCellCount,
|
||||
ref _orderedVisibleCellsUnderusedFrames);
|
||||
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
|
||||
}
|
||||
|
||||
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
|
||||
{
|
||||
if (vertices.Length < 3)
|
||||
return default;
|
||||
Vector2[] owned = _polygonVertices.Rent(vertices.Length);
|
||||
vertices.CopyTo(owned);
|
||||
return new ViewPolygon(owned);
|
||||
}
|
||||
|
||||
internal CellView RentCellView(bool fullScreen = false)
|
||||
{
|
||||
CellView result = _cellViewPool.Count != 0
|
||||
? _cellViewPool.Pop()
|
||||
: new CellView();
|
||||
if (fullScreen)
|
||||
result.SetFullScreen();
|
||||
else
|
||||
result.Reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReturnCellViews(Dictionary<uint, CellView> views)
|
||||
{
|
||||
foreach (CellView view in views.Values)
|
||||
{
|
||||
view.Reset();
|
||||
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
|
||||
_cellViewPool.Push(view);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrimIfCold<TKey, TValue>(
|
||||
Dictionary<TKey, TValue> values,
|
||||
int usedCount,
|
||||
ref int underusedFrames)
|
||||
where TKey : notnull
|
||||
{
|
||||
int capacity = values.EnsureCapacity(0);
|
||||
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
|
||||
return;
|
||||
|
||||
if (capacity > MaxRetainedBuildCollectionCapacity)
|
||||
values.TrimExcess();
|
||||
}
|
||||
|
||||
private static void TrimIfCold<T>(
|
||||
HashSet<T> values,
|
||||
int usedCount,
|
||||
ref int underusedFrames)
|
||||
{
|
||||
int capacity = values.EnsureCapacity(0);
|
||||
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
|
||||
return;
|
||||
|
||||
if (capacity > MaxRetainedBuildCollectionCapacity)
|
||||
values.TrimExcess();
|
||||
}
|
||||
|
||||
private static void TrimIfCold<T>(
|
||||
List<T> values,
|
||||
int usedCount,
|
||||
ref int underusedFrames)
|
||||
{
|
||||
int capacity = values.Capacity;
|
||||
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
|
||||
return;
|
||||
|
||||
if (capacity > MaxRetainedBuildCollectionCapacity)
|
||||
values.Capacity = 0;
|
||||
}
|
||||
|
||||
private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames)
|
||||
{
|
||||
if (capacity <= MaxRetainedBuildCollectionCapacity
|
||||
|| (long)usedCount * 2L > capacity)
|
||||
{
|
||||
underusedFrames = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
underusedFrames++;
|
||||
if (underusedFrames < CapacityTrimIdleFrames)
|
||||
return false;
|
||||
|
||||
underusedFrames = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PortalVisibilityBuilder
|
||||
|
|
@ -121,16 +331,18 @@ public static class PortalVisibilityBuilder
|
|||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
Func<uint, bool>? buildingMembership = null,
|
||||
float drawLiftZ = 0f)
|
||||
float drawLiftZ = 0f,
|
||||
PortalVisibilityFrame? reuseFrame = null)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var frame = reuseFrame ?? new PortalVisibilityFrame();
|
||||
frame.ResetForBuild();
|
||||
if (cameraCell == null) return frame;
|
||||
|
||||
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
|
||||
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
|
||||
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
|
||||
|
||||
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
|
||||
frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true);
|
||||
|
||||
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
|
||||
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
|
||||
|
|
@ -142,15 +354,15 @@ public static class PortalVisibilityBuilder
|
|||
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
|
||||
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
|
||||
if (cameraCell.IsOutdoorNode)
|
||||
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||||
frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad));
|
||||
|
||||
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
|
||||
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
|
||||
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
|
||||
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
|
||||
// always pops first.
|
||||
var todo = new CellTodoList();
|
||||
todo.Insert(cameraCell, 0f);
|
||||
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
|
||||
InsertTodo(todo, cameraCell, 0f);
|
||||
|
||||
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
|
||||
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
|
||||
|
|
@ -162,9 +374,10 @@ public static class PortalVisibilityBuilder
|
|||
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
|
||||
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
|
||||
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
|
||||
var queued = new HashSet<uint> { cameraCell.CellId };
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
HashSet<uint> queued = frame.QueuedScratch;
|
||||
queued.Add(cameraCell.CellId);
|
||||
HashSet<uint> drawListed = frame.DrawListedScratch;
|
||||
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
|
||||
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
|
||||
|
||||
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
|
||||
|
|
@ -224,7 +437,7 @@ public static class PortalVisibilityBuilder
|
|||
// reproduce the T5 firing — production-only ingredients (full lookup
|
||||
// graph / real camera path) are suspected; this dump pins them on the
|
||||
// next natural occurrence.
|
||||
var propagationChain = new uint[RecursionTripwire];
|
||||
uint[] propagationChain = frame.PropagationChainScratch;
|
||||
|
||||
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||
{
|
||||
|
|
@ -251,7 +464,6 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
|
|
@ -301,11 +513,18 @@ public static class PortalVisibilityBuilder
|
|||
// homogeneous clip space, clip at the eye, then clip against the current
|
||||
// portal_view region before the divide. Do the same here; the old early
|
||||
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
|
||||
frame.RentClipRegionScratch();
|
||||
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
|
||||
ClipPortalAgainstView(
|
||||
frame,
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
currentView.Polygons,
|
||||
processedCount,
|
||||
endCount - processedCount,
|
||||
clippedRegion,
|
||||
out int clipVerts);
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
||||
|
|
@ -339,16 +558,31 @@ public static class PortalVisibilityBuilder
|
|||
// lifted space or terrain stops a lift-height short of the
|
||||
// drawn lintel (#130 strip). Flood semantics keep the
|
||||
// unlifted clippedRegion path above.
|
||||
var outsideRegion = drawLiftZ == 0f
|
||||
? clippedRegion
|
||||
: ClipPortalAgainstView(
|
||||
int outsideCount;
|
||||
if (drawLiftZ == 0f)
|
||||
{
|
||||
AddRegion(frame.OutsideView, clippedRegion);
|
||||
outsideCount = clippedRegion.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease =
|
||||
frame.RentClipRegionScratch();
|
||||
List<ViewPolygon> outsideRegion = outsideRegionLease.Region;
|
||||
ClipPortalAgainstView(
|
||||
frame,
|
||||
poly,
|
||||
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
currentView.Polygons,
|
||||
processedCount,
|
||||
endCount - processedCount,
|
||||
outsideRegion,
|
||||
out _);
|
||||
AddRegion(frame.OutsideView, outsideRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
|
||||
AddRegion(frame.OutsideView, outsideRegion);
|
||||
outsideCount = outsideRegion.Count;
|
||||
}
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -360,7 +594,7 @@ public static class PortalVisibilityBuilder
|
|||
// call inside ConstructView at decomp:433692.)
|
||||
if (buildingMembership != null && !buildingMembership(neighbourId))
|
||||
{
|
||||
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
|
||||
var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId);
|
||||
bool grewCross = AddRegion(xview, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
|
||||
continue;
|
||||
|
|
@ -391,7 +625,8 @@ public static class PortalVisibilityBuilder
|
|||
// neighbour's side; the old eye-in-opening restore was part of
|
||||
// the deleted rescue.
|
||||
int preReciprocalCount = clippedRegion.Count;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
ApplyReciprocalClip(
|
||||
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
if (churnProbe)
|
||||
churnReciprocal!.Append(System.FormattableString.Invariant(
|
||||
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
|
||||
|
|
@ -402,7 +637,7 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
|
||||
// Union the clipped region into the neighbour's accumulated view.
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
bool inserted = false;
|
||||
bool inPlace = false;
|
||||
|
|
@ -417,7 +652,7 @@ public static class PortalVisibilityBuilder
|
|||
if (queued.Add(neighbourId))
|
||||
{
|
||||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
InsertTodo(todo, neighbour, dist);
|
||||
inserted = true;
|
||||
}
|
||||
// Growth into an already-POPPED cell → retail AdjustCellView:
|
||||
|
|
@ -437,7 +672,7 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
LoadedCell cell = PopNearest(todo);
|
||||
|
||||
// Single pop per cell (enqueue-once) IS the cell's closest-first
|
||||
// draw position (retail appends to cell_draw_list once per pop,
|
||||
|
|
@ -491,13 +726,15 @@ public static class PortalVisibilityBuilder
|
|||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity,
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null)
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null,
|
||||
PortalVisibilityFrame? reuseFrame = null)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var todo = new CellTodoList();
|
||||
var queued = new HashSet<uint>();
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var frame = reuseFrame ?? new PortalVisibilityFrame();
|
||||
frame.ResetForBuild();
|
||||
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
|
||||
HashSet<uint> queued = frame.QueuedScratch;
|
||||
HashSet<uint> drawListed = frame.DrawListedScratch;
|
||||
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
|
||||
|
||||
foreach (var cell in candidateCells)
|
||||
{
|
||||
|
|
@ -534,11 +771,19 @@ public static class PortalVisibilityBuilder
|
|||
if (seedDistance > maxSeedDistance)
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
|
||||
frame.RentClipRegionScratch();
|
||||
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
|
||||
IReadOnlyList<ViewPolygon> activeSeedRegion = seedRegion ?? FullScreenRegion;
|
||||
ClipPortalAgainstView(
|
||||
frame,
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
seedRegion ?? FullScreenRegion,
|
||||
activeSeedRegion,
|
||||
0,
|
||||
activeSeedRegion.Count,
|
||||
clippedRegion,
|
||||
out _);
|
||||
|
||||
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
|
||||
|
|
@ -547,11 +792,11 @@ public static class PortalVisibilityBuilder
|
|||
if (clippedRegion.Count == 0)
|
||||
continue;
|
||||
|
||||
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
|
||||
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
|
||||
bool grew = AddRegion(seedView, clippedRegion);
|
||||
|
||||
if (grew && queued.Add(cell.CellId))
|
||||
todo.Insert(cell, seedDistance);
|
||||
InsertTodo(todo, cell, seedDistance);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +805,7 @@ public static class PortalVisibilityBuilder
|
|||
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
|
||||
// are deleted (empty clip culls, period).
|
||||
const int RecursionTripwire = 128;
|
||||
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build()
|
||||
uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build()
|
||||
|
||||
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||
{
|
||||
|
|
@ -580,7 +825,6 @@ public static class PortalVisibilityBuilder
|
|||
if (processedCount >= endCount)
|
||||
return;
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
uint lbMask = cell.CellId & 0xFFFF0000u;
|
||||
|
||||
|
|
@ -602,11 +846,18 @@ public static class PortalVisibilityBuilder
|
|||
&& !CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
|
||||
frame.RentClipRegionScratch();
|
||||
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
|
||||
ClipPortalAgainstView(
|
||||
frame,
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
currentView.Polygons,
|
||||
processedCount,
|
||||
endCount - processedCount,
|
||||
clippedRegion,
|
||||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
|
|
@ -617,11 +868,12 @@ public static class PortalVisibilityBuilder
|
|||
if (neighbour == null)
|
||||
continue;
|
||||
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
ApplyReciprocalClip(
|
||||
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0)
|
||||
continue;
|
||||
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
|
||||
if (grew)
|
||||
|
|
@ -629,7 +881,7 @@ public static class PortalVisibilityBuilder
|
|||
if (queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
InsertTodo(todo, neighbour, dist);
|
||||
}
|
||||
else if (drawListed.Contains(neighbourId))
|
||||
{
|
||||
|
|
@ -641,7 +893,7 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
LoadedCell cell = PopNearest(todo);
|
||||
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
|
@ -669,8 +921,16 @@ public static class PortalVisibilityBuilder
|
|||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity,
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null)
|
||||
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null,
|
||||
PortalVisibilityFrame? reuseFrame = null)
|
||||
=> BuildFromExterior(
|
||||
buildingCells,
|
||||
cameraPos,
|
||||
lookup,
|
||||
viewProj,
|
||||
maxSeedDistance,
|
||||
seedRegion,
|
||||
reuseFrame);
|
||||
|
||||
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
|
||||
private static readonly Vector2[] FullScreenQuad =
|
||||
|
|
@ -679,30 +939,35 @@ public static class PortalVisibilityBuilder
|
|||
private static readonly ViewPolygon[] FullScreenRegion =
|
||||
{ new ViewPolygon(FullScreenQuad) };
|
||||
|
||||
private static List<ViewPolygon> ClipPortalAgainstView(
|
||||
private static void ClipPortalAgainstView(
|
||||
PortalVisibilityFrame frame,
|
||||
Vector3[] localPoly,
|
||||
Matrix4x4 cellToWorld,
|
||||
Matrix4x4 viewProj,
|
||||
IReadOnlyList<ViewPolygon> viewPolygons,
|
||||
int viewStart,
|
||||
int viewCount,
|
||||
List<ViewPolygon> clippedRegion,
|
||||
out int clipVertexCount)
|
||||
{
|
||||
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
|
||||
clipVertexCount = portalClip.Length;
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
if (portalClip.Length < 3)
|
||||
return clippedRegion;
|
||||
using PortalProjection.ClipPolygonLease portalClip =
|
||||
PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj);
|
||||
clipVertexCount = portalClip.Count;
|
||||
if (portalClip.Count < 3)
|
||||
return;
|
||||
|
||||
foreach (var vp in viewPolygons)
|
||||
int viewEnd = viewStart + viewCount;
|
||||
for (int i = viewStart; i < viewEnd; i++)
|
||||
{
|
||||
ViewPolygon vp = viewPolygons[i];
|
||||
if (vp.IsEmpty)
|
||||
continue;
|
||||
|
||||
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
|
||||
var clipped = PortalProjection.ClipToRegion(
|
||||
portalClip.Span, vp.Vertices, frame.PolygonVertices);
|
||||
if (clipped.Length >= 3)
|
||||
clippedRegion.Add(new ViewPolygon(clipped));
|
||||
}
|
||||
|
||||
return clippedRegion;
|
||||
}
|
||||
|
||||
private const int PortalTraceEmitLimit = 160;
|
||||
|
|
@ -913,6 +1178,7 @@ public static class PortalVisibilityBuilder
|
|||
private const ushort PortalFlagExactMatch = 0x0001;
|
||||
|
||||
private static void ApplyReciprocalClip(
|
||||
PortalVisibilityFrame frame,
|
||||
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
|
||||
LoadedCell neighbour, Matrix4x4 viewProj)
|
||||
{
|
||||
|
|
@ -944,23 +1210,37 @@ public static class PortalVisibilityBuilder
|
|||
// pins this deterministically; the glitch steps die with this change). The old path's other
|
||||
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
|
||||
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
|
||||
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
|
||||
using PortalProjection.ClipPolygonLease reciprocalClip =
|
||||
PortalProjection.ProjectToClipLease(
|
||||
reciprocalPoly,
|
||||
neighbour.WorldTransform,
|
||||
viewProj);
|
||||
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
|
||||
|
||||
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
|
||||
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
|
||||
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
|
||||
for (int k = clippedRegion.Count - 1; k >= 0; k--)
|
||||
{
|
||||
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices);
|
||||
var tightened = PortalProjection.ClipToRegion(
|
||||
reciprocalClip.Span,
|
||||
clippedRegion[k].Vertices,
|
||||
frame.PolygonVertices);
|
||||
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
|
||||
else clippedRegion.RemoveAt(k);
|
||||
}
|
||||
}
|
||||
|
||||
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
|
||||
private static CellView GetOrCreate(
|
||||
PortalVisibilityFrame frame,
|
||||
Dictionary<uint, CellView> map,
|
||||
uint key)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
|
||||
if (!map.TryGetValue(key, out var v))
|
||||
{
|
||||
v = frame.RentCellView();
|
||||
map[key] = v;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
@ -998,30 +1278,22 @@ public static class PortalVisibilityBuilder
|
|||
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
|
||||
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
|
||||
/// </summary>
|
||||
private sealed class CellTodoList
|
||||
private static void InsertTodo(
|
||||
List<(LoadedCell Cell, float Distance)> items,
|
||||
LoadedCell cell,
|
||||
float distance)
|
||||
{
|
||||
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
|
||||
int index = items.Count;
|
||||
while (index > 0 && items[index - 1].Distance < distance)
|
||||
index--;
|
||||
items.Insert(index, (cell, distance));
|
||||
}
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public void Insert(LoadedCell cell, float distance)
|
||||
{
|
||||
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
|
||||
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
|
||||
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
|
||||
// newcomer inserts at the tail and pops first).
|
||||
int idx = _items.Count;
|
||||
while (idx > 0 && _items[idx - 1].Distance < distance)
|
||||
idx--;
|
||||
_items.Insert(idx, (cell, distance));
|
||||
}
|
||||
|
||||
public LoadedCell PopNearest()
|
||||
{
|
||||
int last = _items.Count - 1;
|
||||
var cell = _items[last].Cell;
|
||||
_items.RemoveAt(last);
|
||||
return cell;
|
||||
}
|
||||
private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items)
|
||||
{
|
||||
int last = items.Count - 1;
|
||||
LoadedCell cell = items[last].Cell;
|
||||
items.RemoveAt(last);
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue