acdream/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.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

949 lines
37 KiB
C#

using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
internal sealed record GlobalMeshAllocation(
MeshBufferRange Vertices,
MeshBufferRange Indices,
IReadOnlyList<int> BatchFirstIndices);
internal readonly record struct GlobalMeshUploadPlan(
long UploadBytes,
long AllocationBytes,
long CopyBytes,
int NewBufferCount);
internal readonly record struct GlobalMeshMaintenanceStep(
long AllocationBytes,
long CopyBytes,
int NewBufferCount,
bool Completed);
/// <summary>
/// Retains the staged GL name and its exact release cursor while an aborted
/// arena migration is being unwound. The owner may only forget the migration
/// after this ticket has converged.
/// </summary>
internal sealed class GlobalMeshMigrationAbortTicket
{
private readonly RetryableGpuResourceRelease _release;
public GlobalMeshMigrationAbortTicket(
uint buffer,
long capacityBytes,
RetryableGpuResourceRelease release)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
Buffer = buffer;
CapacityBytes = capacityBytes;
_release = release ?? throw new ArgumentNullException(nameof(release));
}
public uint Buffer { get; }
public long CapacityBytes { get; }
public bool IsComplete => _release.IsComplete;
public void Advance() => _release.Run();
}
internal static class GlobalMeshVaoAccounting
{
public static void TrackAllocation() =>
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
public static void TrackDeallocation() =>
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
internal enum GlobalMeshCapacityResult
{
Ready,
MigrationStarted,
MigrationInProgress,
NeedsReclamation,
}
/// <summary>
/// Shared modern-rendering vertex/index buffers with reclaimable ranges.
/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
/// when its zero-reference LRU entry is evicted.
/// </summary>
public sealed class GlobalMeshBuffer : IDisposable
{
internal const int InitialVertexCapacity = 1024 * 1024;
internal const int InitialIndexCapacity = 3 * 1024 * 1024;
internal const int VertexGrowthQuantum = 256 * 1024;
internal const int IndexGrowthQuantum = 1024 * 1024;
internal const long MaximumVertexBufferBytes = 384L * 1024 * 1024;
internal const long MaximumIndexBufferBytes = 128L * 1024 * 1024;
// Worst legal dual-buffer overlap: just-under-384 MiB old vertex store +
// 384 MiB destination + the 128 MiB active index store. No route can
// accumulate a second staged/retired generation beyond this ceiling.
internal const long MaximumPhysicalArenaBytes = 896L * 1024 * 1024;
internal static readonly int MaximumVertexCapacity = checked(
(int)(MaximumVertexBufferBytes / VertexPositionNormalTexture.Size));
internal const int MaximumIndexCapacity =
(int)(MaximumIndexBufferBytes / sizeof(ushort));
private readonly GL _gl;
private readonly GpuRetirementLedger _retirementLedger;
private readonly GpuRetiredRangeAllocator _vertices;
private readonly GpuRetiredRangeAllocator _indices;
private BufferMigration? _migration;
private GlobalMeshMigrationAbortTicket? _migrationAbort;
private long _retiredCapacityBytes;
private bool _disposed;
private RetryableResourceReleaseLedger? _disposeResources;
private enum BufferKind
{
Vertices,
Indices,
}
private sealed record BufferMigration(
BufferKind Kind,
uint OldBuffer,
uint NewBuffer,
int OldCapacity,
int NewCapacity,
long OldCapacityBytes,
long NewCapacityBytes,
long CopyBytes)
{
public long CopiedBytes { get; set; }
}
public uint VAO { get; private set; }
public uint VBO { get; private set; }
public uint IBO { get; private set; }
internal long UploadCount { get; private set; }
internal long UploadedBytes { get; private set; }
internal long CapacityBytes =>
(long)_vertices.Capacity * VertexPositionNormalTexture.Size
+ (long)_indices.Capacity * sizeof(ushort);
internal long PhysicalCapacityBytes => checked(
CapacityBytes + (_migration?.NewCapacityBytes ?? 0) + _retiredCapacityBytes);
internal bool IsMigrationInProgress => _migration is not null;
internal bool HasPendingReclamation =>
_migration is not null
|| _vertices.PendingReleaseCount != 0
|| _indices.PendingReleaseCount != 0
|| _retiredCapacityBytes != 0;
internal int VertexHighWaterMark => _vertices.HighWaterMark;
internal int IndexHighWaterMark => _indices.HighWaterMark;
internal GlobalMeshUploadPlan PlanUpload(int vertexCount, int indexCount)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
if (_migration is not null)
throw new InvalidOperationException("Upload planning is unavailable while a backing-buffer migration is in progress.");
ArgumentOutOfRangeException.ThrowIfNegative(vertexCount);
ArgumentOutOfRangeException.ThrowIfNegative(indexCount);
long allocationBytes = 0;
long copyBytes = 0;
int newBuffers = 0;
if (vertexCount > _vertices.LargestFreeRange)
{
int newCapacity = CalculateGrowthCapacity(
_vertices.Capacity, _vertices.TrailingFreeLength,
vertexCount, VertexGrowthQuantum, MaximumVertexCapacity);
allocationBytes = checked(allocationBytes
+ (long)newCapacity * VertexPositionNormalTexture.Size);
copyBytes = checked(copyBytes
+ (long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size);
newBuffers++;
}
if (indexCount > _indices.LargestFreeRange)
{
int newCapacity = CalculateGrowthCapacity(
_indices.Capacity, _indices.TrailingFreeLength,
indexCount, IndexGrowthQuantum, MaximumIndexCapacity);
allocationBytes = checked(allocationBytes + (long)newCapacity * sizeof(ushort));
copyBytes = checked(copyBytes + (long)_indices.HighWaterMark * sizeof(ushort));
newBuffers++;
}
return new GlobalMeshUploadPlan(
checked((long)vertexCount * VertexPositionNormalTexture.Size
+ (long)indexCount * sizeof(ushort)),
allocationBytes,
copyBytes,
newBuffers);
}
public GlobalMeshBuffer(GL gl)
: this(gl, ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal GlobalMeshBuffer(GL gl, IGpuResourceRetirementQueue retirement)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
ArgumentNullException.ThrowIfNull(retirement);
_retirementLedger = new GpuRetirementLedger(retirement);
_vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB
_indices = new GpuRetiredRangeAllocator(InitialIndexCapacity, retirement); // ~6 MB
InitBuffers();
}
private unsafe void InitBuffers()
{
uint vao = 0;
uint vbo = 0;
uint ibo = 0;
long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size;
long indexBytes = (long)_indices.Capacity * sizeof(ushort);
bool vaoTracked = false;
bool vertexTracked = false;
bool indexTracked = false;
try
{
_gl.GenVertexArrays(1, out vao);
_gl.GenBuffers(1, out vbo);
_gl.GenBuffers(1, out ibo);
if (vao == 0 || vbo == 0 || ibo == 0)
throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects.");
_gl.BindVertexArray(vao);
_gl.BindBuffer(GLEnum.ArrayBuffer, vbo);
_gl.BufferData(GLEnum.ArrayBuffer, ToNativeSize(vertexBytes), null, GLEnum.StaticDraw);
ConfigureVertexAttributes();
_gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo);
_gl.BufferData(GLEnum.ElementArrayBuffer, ToNativeSize(indexBytes), null, GLEnum.StaticDraw);
GLHelpers.ThrowOnResourceError(
_gl,
$"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)");
GlobalMeshVaoAccounting.TrackAllocation();
vaoTracked = true;
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer);
vertexTracked = true;
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(indexBytes, GpuResourceType.Buffer);
indexTracked = true;
VAO = vao;
VBO = vbo;
IBO = ibo;
}
catch
{
if (ibo != 0) _gl.DeleteBuffer(ibo);
if (vbo != 0) _gl.DeleteBuffer(vbo);
if (vao != 0) _gl.DeleteVertexArray(vao);
if (indexTracked)
{
GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
if (vertexTracked)
{
GpuMemoryTracker.TrackDeallocation(vertexBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
if (vaoTracked)
GlobalMeshVaoAccounting.TrackDeallocation();
throw;
}
finally
{
_gl.BindVertexArray(0);
}
}
private unsafe void ConfigureVertexAttributes()
{
int stride = VertexPositionNormalTexture.Size;
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
}
internal unsafe GlobalMeshAllocation UploadMesh(
VertexPositionNormalTexture[] vertices,
IReadOnlyList<ushort[]> indexBatches)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
if (_migration is not null)
throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress.");
ArgumentNullException.ThrowIfNull(vertices);
ArgumentNullException.ThrowIfNull(indexBatches);
if (vertices.Length == 0)
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
int totalIndices = 0;
for (int i = 0; i < indexBatches.Count; i++)
{
ushort[] batch = indexBatches[i]
?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
totalIndices = checked(totalIndices + batch.Length);
}
if (totalIndices == 0)
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
MeshBufferRange vertexRange = AllocateVertices(vertices.Length);
MeshBufferRange indexRange;
try
{
indexRange = AllocateIndices(totalIndices);
}
catch
{
_vertices.ReleaseUnsubmitted(vertexRange);
throw;
}
var firstIndices = new int[indexBatches.Count];
try
{
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
fixed (VertexPositionNormalTexture* ptr = vertices)
{
long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size);
long vertexUploadBytes = checked((long)vertices.Length * VertexPositionNormalTexture.Size);
_gl.BufferSubData(
GLEnum.ArrayBuffer,
ToNativeOffset(vertexOffsetBytes),
ToNativeSize(vertexUploadBytes),
ptr);
}
// ElementArrayBuffer binding is VAO state. Use the neutral copy
// target for staging so uploads cannot mutate whichever VAO the
// preceding render pass happened to leave bound.
_gl.BindBuffer(GLEnum.CopyWriteBuffer, IBO);
int indexOffset = indexRange.Offset;
for (int i = 0; i < indexBatches.Count; i++)
{
ushort[] batch = indexBatches[i];
firstIndices[i] = indexOffset;
if (batch.Length > 0)
{
fixed (ushort* ptr = batch)
{
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
long indexUploadBytes = checked((long)batch.Length * sizeof(ushort));
_gl.BufferSubData(
GLEnum.CopyWriteBuffer,
ToNativeOffset(indexOffsetBytes),
ToNativeSize(indexUploadBytes),
ptr);
}
indexOffset = checked(indexOffset + batch.Length);
}
}
GLHelpers.ThrowOnResourceError(
_gl,
$"uploading global mesh ({vertices.Length} vertices, {totalIndices} indices)");
}
catch
{
_indices.ReleaseUnsubmitted(indexRange);
_vertices.ReleaseUnsubmitted(vertexRange);
throw;
}
UploadCount++;
UploadedBytes = checked(UploadedBytes
+ checked((long)vertices.Length * VertexPositionNormalTexture.Size)
+ checked((long)totalIndices * sizeof(ushort)));
return new GlobalMeshAllocation(vertexRange, indexRange, firstIndices);
}
internal void Release(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_indices.ReleaseAfterGpuUse(allocation.Indices);
_vertices.ReleaseAfterGpuUse(allocation.Vertices);
}
// Narrow seams for ObjectMeshManager's per-resource retirement ledger.
// The ordinary Release method remains the convenience API; a retryable
// owner uses these seams so one accepted range is never submitted twice.
internal void ReleaseIndexRange(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_indices.ReleaseAfterGpuUse(allocation.Indices);
}
internal void ReleaseVertexRange(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_vertices.ReleaseAfterGpuUse(allocation.Vertices);
}
/// <summary>
/// Rolls back a mesh transaction which never published render data and
/// therefore can never have been referenced by a submitted draw.
/// </summary>
internal void Abort(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_indices.ReleaseUnsubmitted(allocation.Indices);
_vertices.ReleaseUnsubmitted(allocation.Vertices);
}
// Failed uploads were never submitted, so these matching seams return
// each range immediately while preserving independent rollback progress.
internal void AbortIndexRange(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_indices.ReleaseUnsubmitted(allocation.Indices);
}
internal void AbortVertexRange(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_vertices.ReleaseUnsubmitted(allocation.Vertices);
}
private MeshBufferRange AllocateVertices(int count)
{
if (_vertices.TryAllocate(count, out MeshBufferRange allocation))
return allocation;
throw new InvalidOperationException(
"Vertex capacity was not migrated before the staged mesh upload was admitted.");
}
private MeshBufferRange AllocateIndices(int count)
{
if (_indices.TryAllocate(count, out MeshBufferRange allocation))
return allocation;
throw new InvalidOperationException(
"Index capacity was not migrated before the staged mesh upload was admitted.");
}
internal static int CalculateGrowthCapacity(
int capacity,
int trailingFreeLength,
int requiredContiguousLength,
int growthQuantum,
int maximumCapacity = int.MaxValue)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
ArgumentOutOfRangeException.ThrowIfNegative(trailingFreeLength);
ArgumentOutOfRangeException.ThrowIfGreaterThan(trailingFreeLength, capacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requiredContiguousLength);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCapacity, capacity);
long missing = Math.Max(0L, (long)requiredContiguousLength - trailingFreeLength);
if (missing == 0)
return capacity;
long minimum = checked((long)capacity + missing);
if (minimum > maximumCapacity)
throw new NotSupportedException(
$"A contiguous range of {requiredContiguousLength:N0} elements exceeds the supported arena capacity {maximumCapacity:N0}.");
// A 3:2 geometric destination amortizes the immutable-prefix copy.
// Rounding only the missing tail caused every few uploads to allocate
// another buffer and recopy the full prefix (quadratic route cost).
long geometric = checked((long)capacity + Math.Max((long)growthQuantum, capacity / 2L));
long target = Math.Min(maximumCapacity, Math.Max(minimum, geometric));
return RoundUpToLimit(target, growthQuantum, maximumCapacity);
}
internal static bool TryCalculateTrimCapacity(
int capacity,
int highWaterMark,
int initialCapacity,
int growthQuantum,
out int trimmedCapacity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
ArgumentOutOfRangeException.ThrowIfNegative(highWaterMark);
ArgumentOutOfRangeException.ThrowIfGreaterThan(highWaterMark, capacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCapacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
trimmedCapacity = capacity;
if (capacity <= initialCapacity)
return false;
// Capacity-based hysteresis, not a settle timer: retain 100% headroom
// above the live tail and shrink only when the result is no more than
// one third of the current arena. A destination revisit therefore has
// to more than double its live prefix before growth can resume.
long withHeadroom = checked(
highWaterMark + Math.Max((long)growthQuantum, highWaterMark));
int target = Math.Max(
initialCapacity,
RoundUpToLimit(withHeadroom, growthQuantum, int.MaxValue));
if (target > capacity / 3)
return false;
trimmedCapacity = target;
return true;
}
internal GlobalMeshCapacityResult EnsureUploadCapacity(
int vertexCount,
int indexCount,
out GlobalMeshMaintenanceStep step)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
ArgumentOutOfRangeException.ThrowIfNegative(vertexCount);
ArgumentOutOfRangeException.ThrowIfNegative(indexCount);
if (vertexCount > MaximumVertexCapacity)
throw new NotSupportedException(
$"Mesh requires {vertexCount:N0} vertices; the supported per-arena maximum is {MaximumVertexCapacity:N0}.");
if (indexCount > MaximumIndexCapacity)
throw new NotSupportedException(
$"Mesh requires {indexCount:N0} indices; the supported per-arena maximum is {MaximumIndexCapacity:N0}.");
step = default;
if (_migration is not null)
return GlobalMeshCapacityResult.MigrationInProgress;
if (vertexCount <= _vertices.LargestFreeRange
&& indexCount <= _indices.LargestFreeRange)
{
return GlobalMeshCapacityResult.Ready;
}
BufferKind kind;
int targetCapacity;
long copyBytes;
if (vertexCount > _vertices.LargestFreeRange)
{
long minimum = checked(
(long)_vertices.Capacity
+ Math.Max(0L, (long)vertexCount - _vertices.TrailingFreeLength));
if (minimum > MaximumVertexCapacity)
return GlobalMeshCapacityResult.NeedsReclamation;
kind = BufferKind.Vertices;
targetCapacity = CalculateGrowthCapacity(
_vertices.Capacity,
_vertices.TrailingFreeLength,
vertexCount,
VertexGrowthQuantum,
MaximumVertexCapacity);
copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size);
}
else
{
long minimum = checked(
(long)_indices.Capacity
+ Math.Max(0L, (long)indexCount - _indices.TrailingFreeLength));
if (minimum > MaximumIndexCapacity)
return GlobalMeshCapacityResult.NeedsReclamation;
kind = BufferKind.Indices;
targetCapacity = CalculateGrowthCapacity(
_indices.Capacity,
_indices.TrailingFreeLength,
indexCount,
IndexGrowthQuantum,
MaximumIndexCapacity);
copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort));
}
long newBytes = CapacityBytesFor(kind, targetCapacity);
if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes)
return GlobalMeshCapacityResult.NeedsReclamation;
BeginMigration(kind, targetCapacity, copyBytes);
step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false);
return GlobalMeshCapacityResult.MigrationStarted;
}
/// <summary>
/// Copies at most <paramref name="maximumCopyBytes"/> of the immutable
/// live prefix into the staged backing store. The active VAO continues to
/// reference the old store until the final chunk succeeds, then one atomic
/// VAO rebind publishes the destination.
/// </summary>
internal GlobalMeshMaintenanceStep AdvanceMigration(long maximumCopyBytes)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes);
BufferMigration? migration = _migration;
if (migration is null)
return default;
long chunk = CalculateCopyChunk(
migration.CopyBytes,
migration.CopiedBytes,
maximumCopyBytes);
try
{
if (chunk != 0)
{
_gl.BindBuffer(GLEnum.CopyReadBuffer, migration.OldBuffer);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, migration.NewBuffer);
_gl.CopyBufferSubData(
GLEnum.CopyReadBuffer,
GLEnum.CopyWriteBuffer,
ToNativeOffset(migration.CopiedBytes),
ToNativeOffset(migration.CopiedBytes),
ToNativeSize(chunk));
GLHelpers.ThrowOnResourceError(
_gl,
$"migrating {migration.Kind} arena bytes "
+ $"{migration.CopiedBytes:N0}..{migration.CopiedBytes + chunk:N0}");
migration.CopiedBytes = checked(migration.CopiedBytes + chunk);
}
bool complete = migration.CopiedBytes == migration.CopyBytes;
if (complete)
CommitMigration(migration);
return new GlobalMeshMaintenanceStep(0, chunk, 0, complete);
}
catch (Exception migrationError)
{
try
{
AbortMigration(migration);
}
catch (Exception abortError)
{
throw new AggregateException(
"Global mesh migration failed and its staged buffer could not yet be released.",
migrationError,
abortError);
}
throw;
}
}
/// <summary>
/// Starts a staged shrink of one cold arena. Copy size no longer prevents
/// reclamation: <see cref="AdvanceMigration"/> services any prefix over as
/// many bounded frames as necessary.
/// </summary>
internal bool TryTrimUnusedTail(out GlobalMeshMaintenanceStep step)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
step = default;
if (_migration is not null)
return false;
bool trimVertices = TryCalculateTrimCapacity(
_vertices.Capacity, _vertices.HighWaterMark,
InitialVertexCapacity, VertexGrowthQuantum,
out int vertexCapacity);
bool trimIndices = TryCalculateTrimCapacity(
_indices.Capacity, _indices.HighWaterMark,
InitialIndexCapacity, IndexGrowthQuantum,
out int indexCapacity);
long vertexSaving = trimVertices
? (long)(_vertices.Capacity - vertexCapacity) * VertexPositionNormalTexture.Size
: 0;
long indexSaving = trimIndices
? (long)(_indices.Capacity - indexCapacity) * sizeof(ushort)
: 0;
if (vertexSaving == 0 && indexSaving == 0)
return false;
BufferKind kind;
int capacity;
long copyBytes;
if (vertexSaving >= indexSaving)
{
kind = BufferKind.Vertices;
capacity = vertexCapacity;
copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size);
}
else
{
kind = BufferKind.Indices;
capacity = indexCapacity;
copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort));
}
long newBytes = CapacityBytesFor(kind, capacity);
if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes)
return false;
BeginMigration(kind, capacity, copyBytes);
step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false);
return true;
}
internal static long CalculateCopyChunk(long totalBytes, long copiedBytes, long maximumCopyBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(totalBytes);
ArgumentOutOfRangeException.ThrowIfNegative(copiedBytes);
ArgumentOutOfRangeException.ThrowIfGreaterThan(copiedBytes, totalBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes);
return Math.Min(totalBytes - copiedBytes, maximumCopyBytes);
}
private unsafe void BeginMigration(BufferKind kind, int newCapacity, long copyBytes)
{
if (_migration is not null || _migrationAbort is not null)
throw new InvalidOperationException("Only one global mesh backing buffer may migrate at a time.");
int oldCapacity = kind == BufferKind.Vertices ? _vertices.Capacity : _indices.Capacity;
uint oldBuffer = kind == BufferKind.Vertices ? VBO : IBO;
long oldBytes = CapacityBytesFor(kind, oldCapacity);
long newBytes = CapacityBytesFor(kind, newCapacity);
uint newBuffer = 0;
try
{
_gl.GenBuffers(1, out newBuffer);
if (newBuffer == 0)
throw new InvalidOperationException($"OpenGL did not create a staged {kind} arena buffer.");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newBuffer);
_gl.BufferData(GLEnum.CopyWriteBuffer, ToNativeSize(newBytes), null, GLEnum.StaticDraw);
GLHelpers.ThrowOnResourceError(
_gl,
$"allocating staged {kind} arena buffer ({newBytes:N0} bytes)");
}
catch
{
if (newBuffer != 0)
_gl.DeleteBuffer(newBuffer);
throw;
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer);
_migration = new BufferMigration(
kind,
oldBuffer,
newBuffer,
oldCapacity,
newCapacity,
oldBytes,
newBytes,
copyBytes);
}
private void CommitMigration(BufferMigration migration)
{
try
{
_gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices)
{
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.NewBuffer);
ConfigureVertexAttributes();
}
else
{
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.NewBuffer);
}
GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer");
}
catch
{
_gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices)
{
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.OldBuffer);
ConfigureVertexAttributes();
}
else
{
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.OldBuffer);
}
_gl.BindVertexArray(0);
throw;
}
finally
{
_gl.BindVertexArray(0);
}
if (migration.Kind == BufferKind.Vertices)
{
VBO = migration.NewBuffer;
if (migration.NewCapacity > migration.OldCapacity)
_vertices.Grow(migration.NewCapacity);
else
_vertices.Shrink(migration.NewCapacity);
}
else
{
IBO = migration.NewBuffer;
if (migration.NewCapacity > migration.OldCapacity)
_indices.Grow(migration.NewCapacity);
else
_indices.Shrink(migration.NewCapacity);
}
_migration = null;
_retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes);
RetryableGpuResourceRelease oldBufferRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
migration.OldBuffer,
migration.OldCapacityBytes,
$"retiring replaced global {migration.Kind} arena buffer {migration.OldBuffer}");
_retirementLedger.Retire(new RetryableGpuResourceRelease(
oldBufferRelease.Run,
() => _retiredCapacityBytes = checked(
_retiredCapacityBytes - migration.OldCapacityBytes)));
}
private void AbortMigration(BufferMigration migration)
{
if (!ReferenceEquals(_migration, migration))
return;
_migrationAbort ??= new GlobalMeshMigrationAbortTicket(
migration.NewBuffer,
migration.NewCapacityBytes,
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
migration.NewBuffer,
migration.NewCapacityBytes,
$"aborting staged global {migration.Kind} arena buffer {migration.NewBuffer}"));
RetryPendingMigrationAbort();
}
private void RetryPendingMigrationAbort()
{
GlobalMeshMigrationAbortTicket? ticket = _migrationAbort;
if (ticket is null)
return;
try
{
ticket.Advance();
}
finally
{
if (ticket.IsComplete)
{
BufferMigration migration = _migration
?? throw new InvalidOperationException(
"A staged-buffer abort ticket outlived its migration record.");
if (migration.NewBuffer != ticket.Buffer
|| migration.NewCapacityBytes != ticket.CapacityBytes)
{
throw new InvalidOperationException(
"A staged-buffer abort ticket no longer matches its migration record.");
}
_migrationAbort = null;
_migration = null;
}
}
}
private static long CapacityBytesFor(BufferKind kind, int capacity) => kind switch
{
BufferKind.Vertices => checked((long)capacity * VertexPositionNormalTexture.Size),
BufferKind.Indices => checked((long)capacity * sizeof(ushort)),
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
private static int RoundUpToLimit(long value, int quantum, int maximum)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
long remainder = value % quantum;
long rounded = remainder == 0 ? value : checked(value + quantum - remainder);
if (rounded > maximum)
rounded = maximum;
return checked((int)rounded);
}
private static nint ToNativeOffset(long value)
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (IntPtr.Size == 4 && value > int.MaxValue)
throw new NotSupportedException("The requested GPU byte offset exceeds this process's native pointer range.");
return checked((nint)value);
}
private static nuint ToNativeSize(long value)
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (UIntPtr.Size == 4 && value > uint.MaxValue)
throw new NotSupportedException("The requested GPU byte count exceeds this process's native pointer range.");
return checked((nuint)value);
}
public void Dispose()
{
if (_disposed)
return;
_retirementLedger.RetryPendingPublications();
RetryPendingMigrationAbort();
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_migration is { } migration)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
migration.NewBuffer,
migration.NewCapacityBytes,
$"deleting staged global {migration.Kind} arena buffer {migration.NewBuffer}");
releases.Add(("staged-migration-buffer", release.Run));
}
if (VAO != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
VAO,
$"deleting global mesh vertex array {VAO}",
GlobalMeshVaoAccounting.TrackDeallocation);
releases.Add(("global-vao", release.Run));
}
if (VBO != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
VBO,
(long)_vertices.Capacity * VertexPositionNormalTexture.Size,
$"deleting global mesh vertex buffer {VBO}");
releases.Add(("global-vbo", release.Run));
}
if (IBO != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
IBO,
(long)_indices.Capacity * sizeof(ushort),
$"deleting global mesh index buffer {IBO}");
releases.Add(("global-ibo", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
throw attempt.ToException(
"One or more global mesh-buffer resources could not be released.");
_migration = null;
_migrationAbort = null;
VAO = VBO = IBO = 0;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
throw attempt.ToException(
"Global mesh-buffer resources released with exceptional committed outcomes.");
}
}