acdream/src/AcDream.App/Rendering/ClipFrameAssembler.cs
Erik 1d2f2f738f
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m55s
CI / release (push) Successful in 2m12s
fix #451: stabilize portal seam rendering
2026-08-27 14:30:21 +02:00

450 lines
17 KiB
C#

// ClipFrameAssembler.cs
//
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
// view graph: one portal_view list per visible cell plus an outside_view list.
// This assembler packs each visible polygon as an individual GPU clip slot so
// the renderer can draw the exact PView order:
//
// outside_view landscape slices
// reverse cell_draw_list exit masks
// reverse cell_draw_list EnvCell shells
// reverse cell_draw_list object lists
//
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
// passes that need that fallback. Empty regions are omitted entirely.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// How the landscape-through-outside_view pass should be interpreted.
/// </summary>
public enum TerrainClipMode
{
/// <summary>All outside_view slices have convex plane clips.</summary>
Planes,
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
Scissor,
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
Skip,
}
/// <summary>
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
/// for passes that cannot write gl_ClipDistance and must use scissor.
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Identifies one cell inside one nested building look-in. The same EnvCell can
/// be reached by more than one building PView, so a cell id alone is not a
/// sufficient routing key.
/// </summary>
public readonly record struct LookInClipCell(int FrameIndex, uint CellId);
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
/// plus routing data consumed by the render orchestration.
/// </summary>
public sealed class ClipFrameAssembly
{
public ClipFrame Frame { get; private set; } = null!;
/// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary>
public Dictionary<uint, int> CellIdToSlot { get; } = new();
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public Dictionary<uint, int[]> CellIdToViewSlots { get; } = new();
/// <summary>Full retail portal_view slices per visible cell.</summary>
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
/// <summary>First drawable slice slot per nested look-in cell.</summary>
public Dictionary<LookInClipCell, int> LookInCellToSlot { get; } = new();
/// <summary>All retail portal_view slices per nested look-in cell.</summary>
public Dictionary<LookInClipCell, ClipViewSlice[]> LookInCellToViewSlices { get; } = new();
/// <summary>Full retail outside_view slices.</summary>
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
public int OutdoorSlot { get; internal set; }
public bool OutdoorVisible { get; internal set; }
public TerrainClipMode TerrainMode { get; internal set; }
public Vector4 TerrainScissorNdcAabb { get; internal set; }
public bool HasOutsideView { get; internal set; }
public Vector4 OutsideViewNdcAabb { get; internal set; }
// Probe data.
public int OutsidePlaneCount { get; internal set; }
public Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
public int ScissorFallbacks { get; internal set; }
// The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the
// dictionaries, per-cell arrays, and construction list after the prior frame is consumed.
// Exact-length pools keep the public array API honest: Length is always the live slice count.
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
private readonly Dictionary<int, Stack<int[]>> _slotArraysByLength = new();
internal List<ClipViewSlice> SliceScratch { get; } = new();
internal int SliceArrayAllocationCount { get; private set; }
internal int SlotArrayAllocationCount { get; private set; }
internal const int MaxRetainedSliceItems = 4096;
internal const int MaxRetainedSlotItems = 8192;
internal const int MaxRetainedArraysPerPool = 128;
internal int RetainedSliceItems { get; private set; }
internal int RetainedSlotItems { get; private set; }
internal int RetainedSliceArrays { get; private set; }
internal int RetainedSlotArrays { get; private set; }
internal void Reset(ClipFrame frame)
{
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values)
ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
ReturnSlices(OutsideViewSlices);
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
LookInCellToSlot.Clear();
LookInCellToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
SliceScratch.Clear();
}
internal ClipViewSlice[] CopySlices(List<ClipViewSlice> source)
{
if (source.Count == 0)
return System.Array.Empty<ClipViewSlice>();
ClipViewSlice[] result = RentSlices(source.Count);
source.CopyTo(result, 0);
return result;
}
internal int[] CopySlots(ClipViewSlice[] slices)
{
if (slices.Length == 0)
return System.Array.Empty<int>();
int[] result = RentSlots(slices.Length);
for (int i = 0; i < slices.Length; i++)
result[i] = slices[i].Slot;
return result;
}
internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices;
private ClipViewSlice[] RentSlices(int length)
{
if (_sliceArraysByLength.TryGetValue(length, out Stack<ClipViewSlice[]>? pool)
&& pool.Count != 0)
{
ClipViewSlice[] result = pool.Pop();
RetainedSliceItems -= result.Length;
RetainedSliceArrays--;
if (pool.Count == 0)
_sliceArraysByLength.Remove(length);
return result;
}
SliceArrayAllocationCount++;
return new ClipViewSlice[length];
}
private int[] RentSlots(int length)
{
if (_slotArraysByLength.TryGetValue(length, out Stack<int[]>? pool)
&& pool.Count != 0)
{
int[] result = pool.Pop();
RetainedSlotItems -= result.Length;
RetainedSlotArrays--;
if (pool.Count == 0)
_slotArraysByLength.Remove(length);
return result;
}
SlotArrayAllocationCount++;
return new int[length];
}
private void ReturnSlices(ClipViewSlice[] array)
{
// ClipViewSlice contains a Vector4[] reference. Clear before either retaining or dropping
// the array so a bounded cache cannot accidentally extend plane-payload lifetimes.
System.Array.Clear(array);
if (array.Length > MaxRetainedSliceItems)
return;
while (RetainedSliceItems + array.Length > MaxRetainedSliceItems
|| RetainedSliceArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSliceArray())
break;
}
if (!_sliceArraysByLength.TryGetValue(array.Length, out Stack<ClipViewSlice[]>? pool))
{
pool = new Stack<ClipViewSlice[]>();
_sliceArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSliceItems += array.Length;
RetainedSliceArrays++;
}
private void ReturnSlots(int[] array)
{
if (array.Length > MaxRetainedSlotItems)
return;
while (RetainedSlotItems + array.Length > MaxRetainedSlotItems
|| RetainedSlotArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSlotArray())
break;
}
if (!_slotArraysByLength.TryGetValue(array.Length, out Stack<int[]>? pool))
{
pool = new Stack<int[]>();
_slotArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSlotItems += array.Length;
RetainedSlotArrays++;
}
private bool EvictOneSliceArray()
{
int selectedLength = -1;
foreach ((int length, Stack<ClipViewSlice[]> pool) in _sliceArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<ClipViewSlice[]> selected = _sliceArraysByLength[selectedLength];
ClipViewSlice[] evicted = selected.Pop();
RetainedSliceItems -= evicted.Length;
RetainedSliceArrays--;
if (selected.Count == 0)
_sliceArraysByLength.Remove(selectedLength);
return true;
}
private bool EvictOneSlotArray()
{
int selectedLength = -1;
foreach ((int length, Stack<int[]> pool) in _slotArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<int[]> selected = _slotArraysByLength[selectedLength];
int[] evicted = selected.Pop();
RetainedSlotItems -= evicted.Length;
RetainedSlotArrays--;
if (selected.Count == 0)
_slotArraysByLength.Remove(selectedLength);
return true;
}
}
public static class ClipFrameAssembler
{
public static ClipFrameAssembly Assemble(
ClipFrame frame,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly? reuseAssembly = null)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset();
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
assembly.Reset(frame);
Dictionary<uint, int> cellIdToSlot = assembly.CellIdToSlot;
Dictionary<uint, int[]> cellIdToViewSlots = assembly.CellIdToViewSlots;
Dictionary<uint, ClipViewSlice[]> cellIdToViewSlices = assembly.CellIdToViewSlices;
Dictionary<uint, int> perCellPlaneCounts = assembly.PerCellPlaneCounts;
int scissorFallbacks = 0;
foreach (uint cellId in pvFrame.OrderedVisibleCells)
{
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue;
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
int maxPlaneCount = 0;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
scissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
ClipViewSlice[] sliceArray = assembly.CopySlices(slices);
cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount;
}
List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
outsideSlicesList.Clear();
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
outsideHasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
? TerrainClipMode.Skip
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero;
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
? outsideViewNdcAabb
: Vector4.Zero;
assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible;
assembly.TerrainMode = terrainMode;
assembly.TerrainScissorNdcAabb = terrainScissor;
assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0;
assembly.ScissorFallbacks = scissorFallbacks;
return assembly;
}
/// <summary>
/// Appends the cell views used by nested <c>DrawBuilding -&gt; DrawPortal</c>
/// PViews to the already assembled frame. Retail installs each nested
/// cell's own <c>portal_view</c> before drawing its shell and object list;
/// these slots must therefore be published with the main frame before the
/// first draw is recorded.
/// </summary>
public static void AppendLookInFrames(
ClipFrame frame,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(lookInFrames);
System.ArgumentNullException.ThrowIfNull(assembly);
if (!ReferenceEquals(frame, assembly.Frame))
throw new System.ArgumentException(
"The look-in slots must be appended to the assembly's clip frame.",
nameof(frame));
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame lookIn = lookInFrames[frameIndex];
foreach (uint cellId in lookIn.OrderedVisibleCells)
{
if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view))
continue;
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
foreach (ViewPolygon poly in view.Polygons)
{
ClipPlaneSet cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
assembly.ScissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
ClipViewSlice[] packed = assembly.CopySlices(slices);
var key = new LookInClipCell(frameIndex, cellId);
assembly.LookInCellToViewSlices.Add(key, packed);
assembly.LookInCellToSlot.Add(key, packed[0].Slot);
}
}
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
}