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:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -22,12 +22,13 @@
// terrain OutsideView planes, then points each renderer's per-instance slot
// buffer at the right slots.
//
// Pure CPU byte-packing + a thin GL upload. NO GL types appear except in
// UploadShared. The byte layout is asserted by ClipFrameLayoutTests so a silent
// 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;
@ -78,16 +79,54 @@ public sealed class ClipFrame : IDisposable
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
// ---- GL-side state (lazily created on first UploadShared) ----------------
// ---- GL-side state (lazily created on first upload) ----------------------
private uint _regionSsbo;
private uint _terrainUbo;
private bool _glInitialized;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
// GL reference captured on the first UploadShared so Dispose can delete the two
// buffers. ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip()
// and reuses it every frame), so we DO own buffer teardown — see Dispose.
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)
@ -119,9 +158,7 @@ public sealed class ClipFrame : IDisposable
/// (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 re-uploaded via <see cref="UploadShared"/> (which reuses the same SSBO /
/// UBO ids). This keeps the per-frame cost at one BufferData per buffer instead
/// of leaking a fresh pair of GL buffers each frame.
/// then uploaded through one SSBO and one terrain arena per fenced frame slot.
/// </summary>
public void Reset()
{
@ -139,16 +176,36 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>The shared mesh-clip SSBO id, or 0 before the first
/// <see cref="UploadShared"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadShared"/> already binds it to
/// <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="UploadShared"/>. Handed to <see cref="TerrainModernRenderer"/>
/// so it can re-bind binding=2 (UBO namespace) before its draw.</summary>
/// <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
@ -215,55 +272,265 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>
/// Upload the shared mesh-clip SSBO (binding=2) and the terrain-clip UBO
/// (binding=2, UBO namespace) and bind both to their binding points. Idempotent
/// to call once per frame. Creates the GL buffers lazily on first call.
/// 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)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
ReserveTerrainUploads(gl, 1);
UploadRegions(gl);
UploadTerrainClip(gl);
}
if (!_glInitialized)
/// <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)
{
_gl = gl; // captured for Dispose (single context for the frame's lifetime)
_regionSsbo = gl.GenBuffer();
_terrainUbo = gl.GenBuffer();
_glInitialized = true;
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"));
}
int regionByteCount = _slotCount * CellClipStrideBytes;
gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _regionSsbo);
fixed (byte* p = _regionBytes)
{
gl.BufferData(BufferTargetARB.ShaderStorageBuffer,
(nuint)regionByteCount, p, BufferUsageARB.DynamicDraw);
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
0,
regionByteCount,
p,
"ClipFrame region SSBO update");
}
gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, _regionSsbo);
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);
gl.BindBuffer(BufferTargetARB.UniformBuffer, _terrainUbo);
fixed (byte* p = _terrainBytes)
{
gl.BufferData(BufferTargetARB.UniformBuffer,
(nuint)TerrainUboBytes, p, BufferUsageARB.DynamicDraw);
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.UniformBuffer,
arena.Name,
offsetBytes,
TerrainUboBytes,
p,
"ClipFrame terrain UBO range update");
}
gl.BindBufferBase(BufferTargetARB.UniformBuffer, TerrainClipUboBinding, _terrainUbo);
_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) return;
_disposed = true;
// ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip() and
// reuses it every frame), so we own the two GL buffers and delete them here.
// _glInitialized guards the case where UploadShared never ran (no buffers to
// delete, and _gl was never captured).
if (_glInitialized && _gl is not null)
if (_disposed || _disposing) return;
_disposing = true;
try
{
_gl.DeleteBuffer(_regionSsbo);
_gl.DeleteBuffer(_terrainUbo);
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;
}
}
@ -310,3 +577,230 @@ public sealed class ClipFrame : IDisposable
/// <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.");
}
}