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>
806 lines
30 KiB
C#
806 lines
30 KiB
C#
// 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 >= 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 >= 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 > 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) >= 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.");
|
||
}
|
||
}
|