acdream/src/AcDream.App/Rendering/ClipFrame.cs
Erik 749e8ceeb1 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>
2026-07-18 21:35:16 +02:00

806 lines
30 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.

// ClipFrame.cs
//
// Phase U.3: the GPU-side container + uploader for the SHARED per-frame clip
// data consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert
// (UBO binding=2). This is the "shared" half of the U.3 clip mechanism; the
// per-instance slot index buffer (SSBO binding=3) is PER-RENDERER and owned by
// each renderer (WbDrawDispatcher / EnvCellRenderer), parallel to its instance
// buffer — it is NOT here.
//
// === The contract (both shader sides obey) ===================================
// binding=2 mesh SSBO holds an array of CellClip, one per "slot":
// struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
// std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16
// (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0).
// binding=2 terrain UBO holds the single OutsideView region:
// layout(std140) { int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; };
// std140 layout: count at byte 0 (padded to 16), planes[8] at 16 → 144 bytes.
//
// In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0) and a
// terrain count of 0. Everything renders exactly as before. U.4 populates real
// slots from a PortalVisibilityFrame (one CellClip per visible cell) and sets the
// terrain OutsideView planes, then points each renderer's per-instance slot
// buffer at the right slots.
//
// Pure CPU byte-packing + a thin GL upload. The byte layout is asserted by
// ClipFrameLayoutTests so a silent
// std430/std140 drift can't reach the GPU.
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Per-frame container + uploader for the SHARED clip data: the binding=2 mesh
/// SSBO (one <c>CellClip</c> per slot, slot 0 reserved no-clip) and the binding=2
/// terrain UBO (the single OutsideView region). See the file header for the exact
/// std430 / std140 byte layout. Per-instance slot buffers (binding=3) are owned by
/// each renderer, not here.
/// </summary>
public sealed class ClipFrame : IDisposable
{
// ---- Layout constants (mirror mesh_modern.vert + terrain_modern.vert) ----
/// <summary>Max planes per clip region — matches the shader's <c>planes[8]</c>
/// and GL's guaranteed <c>GL_MAX_CLIP_DISTANCES &gt;= 8</c>.</summary>
public const int MaxPlanes = 8;
/// <summary>std430 stride of one <c>CellClip</c>: 16 (count + 3 pad uints) +
/// 8 × 16 (vec4 planes) = 144 bytes.</summary>
public const int CellClipStrideBytes = 16 + MaxPlanes * 16; // 144
/// <summary>Byte offset of <c>planes[0]</c> within a <c>CellClip</c> (after the
/// count + 3 pad uints).</summary>
public const int CellClipPlanesOffset = 16;
/// <summary>std140 size of the terrain UBO block: int count padded to 16, then
/// 8 × 16 (vec4 planes) = 144 bytes. Same number as the SSBO stride by
/// coincidence of the 16-byte vec4 rule, but a DIFFERENT layout family.</summary>
public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144
/// <summary>SSBO binding index for the shared per-cell clip regions
/// (mesh_modern.vert binding=2).</summary>
public const uint MeshClipSsboBinding = 2;
/// <summary>UBO binding index for the terrain OutsideView clip region
/// (terrain_modern.vert binding=2). UBO namespace — distinct from the SSBO
/// binding=2 above.</summary>
public const uint TerrainClipUboBinding = 2;
// ---- CPU-side state ------------------------------------------------------
// Packed std430 bytes for clipRegions[]. Always holds at least slot 0.
private byte[] _regionBytes;
private int _slotCount;
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
// ---- GL-side state (lazily created on first upload) ----------------------
private uint _regionSsbo;
private uint _terrainUbo;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight;
private sealed class RegionBuffer
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
private sealed class TerrainBufferArena
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
// A full clip-region table is immutable once ClipFrameAssembler has packed
// the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip
// state changes between outside-view slices, so each frame slot owns one
// UBO arena and each draw binds a distinct aligned range within that arena.
// This bounds native object retention at six buffers total instead of one
// region/terrain pair per outside-view slice.
private readonly ClipFrameResourceRing<RegionBuffer> _regionBuffers =
new(FrameSlotCount);
private readonly ClipFrameResourceRing<TerrainBufferArena> _terrainBuffers =
new(FrameSlotCount);
private readonly ClipFrameUploadState _uploadState = new();
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
private int _terrainRecordStrideBytes;
private TerrainClipBufferBinding _terrainBinding;
internal int DynamicBufferSetCount =>
Math.Max(_regionBuffers.Count, _terrainBuffers.Count);
internal int RegionBufferCount => _regionBuffers.Count;
internal int TerrainBufferCount => _terrainBuffers.Count;
internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes;
// GL reference captured on the first upload so Dispose can delete each
// frame-slot buffer. ClipFrame is long-lived and owns their teardown.
private GL? _gl;
private ClipFrame(byte[] regionBytes, int slotCount)
{
_regionBytes = regionBytes;
_slotCount = slotCount;
// Terrain defaults to count 0 (ungated). _terrainBytes is already all
// zeros, which encodes count=0 + zeroed (unused) planes.
}
/// <summary>
/// The U.3 default frame: exactly slot 0 (no-clip, count 0) and a terrain
/// count of 0. The whole scene renders ungated — identical to pre-U.3. U.4
/// replaces this with a frame built from real portal visibility.
/// </summary>
public static ClipFrame NoClip()
{
// One slot, all zeros: count=0 ⇒ shader passes every plane.
var bytes = new byte[CellClipStrideBytes];
return new ClipFrame(bytes, slotCount: 1);
}
/// <summary>Number of clip slots currently packed (always &gt;= 1 — slot 0 is
/// the reserved no-clip slot).</summary>
public int SlotCount => _slotCount;
/// <summary>
/// Phase U.4: reset this frame back to the NoClip state — exactly slot 0
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new
/// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
/// then uploaded through one SSBO and one terrain arena per fenced frame slot.
/// </summary>
public void Reset()
{
// Slot 0 = no-clip (count 0). Zero just the slot-0 region; the tail beyond
// _slotCount is never uploaded, so it needn't be cleared. AppendSlot writes
// each new slot's count + planes in full, so stale bytes there are
// overwritten before they can be uploaded.
if (_regionBytes.Length < CellClipStrideBytes)
EnsureRegionCapacity(CellClipStrideBytes);
Array.Clear(_regionBytes, 0, CellClipStrideBytes);
_slotCount = 1;
// Terrain back to count 0 (ungated) until SetTerrainClip is called again.
Array.Clear(_terrainBytes);
}
/// <summary>The shared mesh-clip SSBO id, or 0 before the first
/// <see cref="UploadRegions"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadRegions"/> already binds it to
/// <see cref="MeshClipSsboBinding"/>.</summary>
public uint RegionSsbo => _regionSsbo;
/// <summary>The terrain-clip UBO id, or 0 before the first
/// <see cref="UploadTerrainClip"/>. The buffer alone does not identify the
/// active range; consumers should use <see cref="TerrainBinding"/>.</summary>
public uint TerrainUbo => _terrainUbo;
/// <summary>The currently installed terrain-clip range. The buffer name is
/// stable for the current GPU-fenced frame slot; the offset advances once
/// for every outside-view draw.</summary>
public TerrainClipBufferBinding TerrainBinding => _terrainBinding;
/// <summary>
/// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are
/// safe to reuse because the frame-flight controller already waited for the
/// slot's preceding submission.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= FrameSlotCount)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
_terrainBinding = default;
_uploadState.BeginFrame();
}
/// <summary>
/// Append one clip region (becomes the next slot index) from a
/// <see cref="ClipPlaneSet"/>. Only the convex-plane case is supported in
/// U.3 — <c>Count &gt; 0</c> packs that many planes; <c>Count == 0</c> packs a
/// no-clip region (pass-all). The scissor / nothing-visible fallbacks that
/// <see cref="ClipPlaneSet"/> can carry are deferred to U.4 (which will draw
/// the AABB box or skip the cell on the CPU side, not via this slot). Returns
/// the new slot's index.
/// </summary>
public int AppendSlot(ClipPlaneSet set)
{
int count = Math.Min(set.Count, MaxPlanes);
if (count == 0)
return AppendSlot(ReadOnlySpan<Vector4>.Empty);
Span<Vector4> planes = stackalloc Vector4[count];
for (int i = 0; i < count; i++)
planes[i] = set.Planes[i];
return AppendSlot(planes);
}
/// <summary>
/// Append one clip region from a raw plane list. <paramref name="planes"/>
/// length 0 packs a no-clip (pass-all) region; otherwise up to
/// <see cref="MaxPlanes"/> planes are packed (extras ignored). Each plane is
/// <c>(nx, ny, 0, dw)</c> in clip space; a clip-space vertex is inside iff
/// <c>dot(plane, gl_Position) &gt;= 0</c> for every plane. Returns the new
/// slot index.
/// </summary>
public int AppendSlot(ReadOnlySpan<Vector4> planes)
{
int count = Math.Min(planes.Length, MaxPlanes);
int slot = _slotCount;
int byteOffset = slot * CellClipStrideBytes;
EnsureRegionCapacity(byteOffset + CellClipStrideBytes);
// count (uint) at byteOffset; the 3 pad uints stay zero.
WriteUInt(_regionBytes, byteOffset, (uint)count);
for (int i = 0; i < count; i++)
{
int po = byteOffset + CellClipPlanesOffset + i * 16;
WriteVec4(_regionBytes, po, planes[i]);
}
_slotCount++;
return slot;
}
/// <summary>
/// Set the terrain OutsideView clip region (the single region the terrain
/// shader gates against). <paramref name="planes"/> length 0 ungates terrain
/// (count 0). U.3 callers never touch this — <see cref="NoClip"/> leaves it
/// at count 0. U.4 calls it with the OutsideView planes.
/// </summary>
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
{
int count = Math.Min(planes.Length, MaxPlanes);
Array.Clear(_terrainBytes);
WriteInt(_terrainBytes, 0, count);
for (int i = 0; i < count; i++)
WriteVec4(_terrainBytes, CellClipPlanesOffset + i * 16, planes[i]);
}
/// <summary>
/// Compatibility entry point for one-region/one-terrain callers. Complex
/// PView frames should reserve their complete terrain sequence, upload the
/// regions once, then call <see cref="UploadTerrainClip"/> per slice.
/// </summary>
public unsafe void UploadShared(GL gl)
{
ReserveTerrainUploads(gl, 1);
UploadRegions(gl);
UploadTerrainClip(gl);
}
/// <summary>
/// Allocates the current frame slot's terrain arena before any draw uses it.
/// The arena has one alignment-safe range per subsequent
/// <see cref="UploadTerrainClip"/> call, so later slices never overwrite
/// uniform data referenced by earlier GPU commands.
/// </summary>
public void ReserveTerrainUploads(GL gl, int uploadCount)
{
ValidateGlContext(gl);
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
_uploadState.ValidateTerrainReservation(uploadCount);
if (_terrainRecordStrideBytes == 0)
{
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)");
gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query");
_terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment);
}
TerrainBufferArena arena = GetOrCreateTerrainArena(gl);
int requiredBytes = ClipFrameArenaLayout.RequiredBytes(
uploadCount,
_terrainRecordStrideBytes);
int targetCapacity = arena.CapacityPolicy.SelectCapacity(
arena.CapacityBytes,
requiredBytes);
if (targetCapacity != arena.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref arena.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.UniformBuffer,
arena.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame terrain UBO arena resize"));
}
_terrainUbo = arena.Name;
_uploadState.CommitTerrainReservation(uploadCount);
}
/// <summary>Uploads and binds the immutable clip-region table once for the
/// assembled frame.</summary>
public unsafe void UploadRegions(GL gl)
{
ValidateGlContext(gl);
_uploadState.ValidateRegionsNotUploaded();
RegionBuffer region = GetOrCreateRegionBuffer(gl);
int regionByteCount = checked(_slotCount * CellClipStrideBytes);
int targetCapacity = region.CapacityPolicy.SelectCapacity(
region.CapacityBytes,
regionByteCount);
if (targetCapacity != region.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref region.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame region SSBO resize"));
}
fixed (byte* p = _regionBytes)
{
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
0,
regionByteCount,
p,
"ClipFrame region SSBO update");
}
gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
MeshClipSsboBinding,
region.Name);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding");
_regionSsbo = region.Name;
_uploadState.MarkRegionsUploaded();
}
/// <summary>Uploads the current terrain clip bytes into the next unique
/// aligned arena record and installs that range at UBO binding 2.</summary>
public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl)
{
ValidateGlContext(gl);
int recordIndex = _uploadState.NextTerrainRecord();
TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot);
int offsetBytes = ClipFrameArenaLayout.RecordOffset(
recordIndex,
_terrainRecordStrideBytes);
fixed (byte* p = _terrainBytes)
{
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.UniformBuffer,
arena.Name,
offsetBytes,
TerrainUboBytes,
p,
"ClipFrame terrain UBO range update");
}
_terrainBinding = new TerrainClipBufferBinding(
arena.Name,
offsetBytes,
TerrainUboBytes);
_terrainBinding.Bind(gl);
_terrainUbo = arena.Name;
return _terrainBinding;
}
public void BindTerrainClip(GL gl)
{
ValidateGlContext(gl);
if (!_terrainBinding.IsValid)
throw new InvalidOperationException("No terrain clip range has been uploaded for this frame.");
_terrainBinding.Bind(gl);
}
private RegionBuffer GetOrCreateRegionBuffer(GL gl)
{
if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation");
var created = new RegionBuffer { Name = name };
try
{
_regionBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback");
throw;
}
}
private TerrainBufferArena GetOrCreateTerrainArena(GL gl)
{
if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation");
var created = new TerrainBufferArena { Name = name };
try
{
_terrainBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback");
throw;
}
}
private void ValidateGlContext(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
if (_gl is null)
_gl = gl;
else if (!ReferenceEquals(_gl, gl))
throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts.");
}
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_gl is not null)
{
int regionIndex = 0;
foreach (RegionBuffer set in _regionBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
set.Name,
set.CapacityBytes,
"ClipFrame region SSBO disposal");
releases.Add(($"region-ssbo-{regionIndex++}", release.Run));
}
int terrainIndex = 0;
foreach (TerrainBufferArena arena in _terrainBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
arena.Name,
arena.CapacityBytes,
"ClipFrame terrain UBO disposal");
releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run));
}
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more ClipFrame GPU buffers could not be released.");
}
_regionSsbo = 0;
_terrainUbo = 0;
_terrainBinding = default;
_dynamicFrameStarted = false;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"ClipFrame GPU buffers released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
// ---- byte helpers (little-endian; matches x86/x64 GPU upload) ------------
private void EnsureRegionCapacity(int requiredBytes)
{
if (_regionBytes.Length >= requiredBytes) return;
int newLen = Math.Max(requiredBytes, _regionBytes.Length * 2);
Array.Resize(ref _regionBytes, newLen);
}
private static void WriteUInt(byte[] dst, int offset, uint value)
{
dst[offset + 0] = (byte)(value & 0xFF);
dst[offset + 1] = (byte)((value >> 8) & 0xFF);
dst[offset + 2] = (byte)((value >> 16) & 0xFF);
dst[offset + 3] = (byte)((value >> 24) & 0xFF);
}
private static void WriteInt(byte[] dst, int offset, int value)
=> WriteUInt(dst, offset, unchecked((uint)value));
private static void WriteVec4(byte[] dst, int offset, Vector4 v)
{
WriteFloat(dst, offset + 0, v.X);
WriteFloat(dst, offset + 4, v.Y);
WriteFloat(dst, offset + 8, v.Z);
WriteFloat(dst, offset + 12, v.W);
}
private static void WriteFloat(byte[] dst, int offset, float value)
{
uint bits = BitConverter.SingleToUInt32Bits(value);
WriteUInt(dst, offset, bits);
}
// ---- Test seams ----------------------------------------------------------
/// <summary>Test seam: the packed std430 region bytes (slot 0..SlotCount-1).
/// Read-only snapshot used by ClipFrameLayoutTests to assert the byte layout.</summary>
internal ReadOnlySpan<byte> RegionBytesForTest => _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes);
/// <summary>Test seam: the packed std140 terrain UBO bytes.</summary>
internal ReadOnlySpan<byte> TerrainBytesForTest => _terrainBytes;
}
/// <summary>A single std140 terrain-clip record within a frame-slot UBO arena.</summary>
public readonly record struct TerrainClipBufferBinding(
uint Buffer,
int OffsetBytes,
int SizeBytes)
{
public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0;
public void Bind(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
if (!IsValid)
throw new InvalidOperationException("Cannot bind an empty terrain clip range.");
gl.BindBufferRange(
BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding,
Buffer,
(nint)OffsetBytes,
(nuint)SizeBytes);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding");
}
}
internal static class ClipFrameArenaLayout
{
public static int RecordStride(int uniformBufferOffsetAlignment)
{
ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1);
long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L)
/ uniformBufferOffsetAlignment
* uniformBufferOffsetAlignment;
if (stride > int.MaxValue)
throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue.");
return (int)stride;
}
public static int RecordOffset(int recordIndex, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(recordIndex);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordIndex * recordStrideBytes);
}
public static int RequiredBytes(int recordCount, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordCount * recordStrideBytes);
}
}
internal static class ClipBufferCapacityTransaction
{
/// <summary>Publishes capacity only after BufferData succeeds, and before
/// any later upload/bind operation can throw.</summary>
public static void Resize(
ref int publishedCapacityBytes,
int targetCapacityBytes,
Action<int, int> allocate)
{
ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1);
ArgumentNullException.ThrowIfNull(allocate);
int previousCapacityBytes = publishedCapacityBytes;
allocate(previousCapacityBytes, targetCapacityBytes);
publishedCapacityBytes = targetCapacityBytes;
}
}
/// <summary>
/// Growth is immediate; shrinking requires repeated severe under-use. This
/// avoids reallocating during ordinary portal-count jitter while ensuring a
/// one-off pathological flood does not permanently pin its peak GPU storage.
/// </summary>
internal struct ClipBufferCapacityPolicy
{
internal const int ShrinkAfterUnderusedFrames = 3;
internal const int ShrinkUtilizationDivisor = 4;
private int _underusedFrames;
public int SelectCapacity(int currentBytes, int requiredBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1);
if (requiredBytes > currentBytes)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(currentBytes, requiredBytes);
}
if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes)
{
_underusedFrames++;
if (_underusedFrames >= ShrinkAfterUnderusedFrames)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(0, requiredBytes);
}
}
else
{
_underusedFrames = 0;
}
return currentBytes;
}
}
/// <summary>Fixed-cardinality resource ownership indexed by the same frame
/// slots protected by <see cref="GpuFrameFlightController"/>.</summary>
internal sealed class ClipFrameResourceRing<T>(int slotCount) where T : class
{
private readonly T?[] _slots = new T?[slotCount > 0
? slotCount
: throw new ArgumentOutOfRangeException(nameof(slotCount))];
public int Count { get; private set; }
public bool TryGet(int slot, out T? resource)
{
ValidateSlot(slot);
resource = _slots[slot];
return resource is not null;
}
public T GetRequired(int slot)
{
ValidateSlot(slot);
return _slots[slot]
?? throw new InvalidOperationException($"Frame slot {slot} has no resource.");
}
public void Set(int slot, T resource)
{
ValidateSlot(slot);
ArgumentNullException.ThrowIfNull(resource);
if (_slots[slot] is not null)
throw new InvalidOperationException($"Frame slot {slot} already owns a resource.");
_slots[slot] = resource;
Count++;
}
public IEnumerable<T> Values
{
get
{
for (int i = 0; i < _slots.Length; i++)
if (_slots[i] is T value)
yield return value;
}
}
private void ValidateSlot(int slot)
{
if ((uint)slot >= (uint)_slots.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
}
}
/// <summary>Pure sequencing state for one ClipFrame render submission.</summary>
internal sealed class ClipFrameUploadState
{
private bool _frameStarted;
private bool _regionsUploaded;
private int _terrainReserved;
private int _terrainCursor;
public int TerrainReserved => _terrainReserved;
public int TerrainUploaded => _terrainCursor;
public bool RegionsUploaded => _regionsUploaded;
public void BeginFrame()
{
_frameStarted = true;
_regionsUploaded = false;
_terrainReserved = 0;
_terrainCursor = 0;
}
public void ValidateRegionsNotUploaded()
{
EnsureFrameStarted();
if (_regionsUploaded)
throw new InvalidOperationException("Clip regions may be uploaded only once per frame.");
}
public void MarkRegionsUploaded()
{
ValidateRegionsNotUploaded();
_regionsUploaded = true;
}
public void ValidateTerrainReservation(int uploadCount)
{
EnsureFrameStarted();
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
if (_terrainReserved != 0)
throw new InvalidOperationException("Terrain uploads have already been reserved for this frame.");
}
public void CommitTerrainReservation(int uploadCount)
{
ValidateTerrainReservation(uploadCount);
_terrainReserved = uploadCount;
}
public int NextTerrainRecord()
{
EnsureFrameStarted();
if (_terrainReserved == 0)
throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads.");
if (_terrainCursor >= _terrainReserved)
throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count.");
return _terrainCursor++;
}
private void EnsureFrameStarted()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
}
}