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>
949 lines
37 KiB
C#
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.");
|
|
}
|
|
}
|