using System.Runtime.InteropServices; using AcDream.Content; using Chorizite.Core.Render.Enums; using Silk.NET.OpenGL; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Gl; namespace AcDream.App.Rendering.Wb; internal sealed record GlobalMeshAllocation( MeshBufferRange Vertices, MeshBufferRange Indices, IReadOnlyList 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); /// /// Retains the staged buffer 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. /// internal sealed class GlobalMeshMigrationAbortTicket { private readonly RetryableGpuResourceRelease _release; public GlobalMeshMigrationAbortTicket( IGpuBuffer buffer, long capacityBytes, RetryableGpuResourceRelease release) { ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); Buffer = buffer; CapacityBytes = capacityBytes; _release = release ?? throw new ArgumentNullException(nameof(release)); } public IGpuBuffer 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, } /// /// 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. /// /// Campaign V slice V4b moved the two backing stores onto /// : allocation is , /// mesh upload is , and the grow-and-copy /// migration is — a device-side copy the Vulkan /// backend implements with vkCmdCopyBuffer. The reclaimable-range /// allocator, growth quanta, budgeted incremental migration, retirement-ledger /// gating and the dual-generation physical ceiling are unchanged; only the /// resource handle type moved. The vertex array object stays raw GL because a /// VAO has no RHI equivalent (Vulkan bakes vertex input into the pipeline) and /// WbDrawDispatcher, EnvCellRenderer and ParticleRenderer /// still bind // directly /// on the GL arm. /// /// Campaign V slice V6i-3 made the GL context optional. A backend that has /// none builds no vertex array and publishes no raw names — , /// and are 0 there — and its consumers bind /// and through the pass /// encoder instead, which is the same 32-byte position/normal/texcoord layout /// expressed as pipeline vertex input. Everything above the handle — the /// allocator, the migration, the ledger — is one body on both arms. /// 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)); // Retained only for the vertex array object and its attribute layout, which // the RHI has no verb for, and null on a backend with no such object. It is // retired with the raw-GL dispatcher. private readonly GL? _gl; private readonly IGpuDevice _device; private readonly GpuRetirementLedger _retirementLedger; private readonly GpuRetiredRangeAllocator _vertices; private readonly GpuRetiredRangeAllocator _indices; private IGpuBuffer? _vertexBuffer; private IGpuBuffer? _indexBuffer; private BufferMigration? _migration; private GlobalMeshMigrationAbortTicket? _migrationAbort; private long _retiredCapacityBytes; private int _storeGeneration; private bool _disposed; private RetryableResourceReleaseLedger? _disposeResources; private static IGpuBuffer RequireStore(IGpuBuffer? store) => store ?? throw new InvalidOperationException( "The global mesh arena has no live backing store."); /// /// Campaign V slice V4b transitional bridge. The arena owns its stores as /// , but its consumers — the vertex array object here, /// and WbDrawDispatcher/EnvCellRenderer/ParticleRenderer /// through / — are still raw GL until slice /// V4c. This is the only place that reaches through the interface, and it /// disappears with those consumers. /// private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer) => buffer as GlGpuBuffer ?? throw new NotSupportedException( "The global mesh arena requires a GL-backed buffer while its draw paths " + "still bind raw GL names (Campaign V slice V4c retires that requirement)."); private enum BufferKind { Vertices, Indices, } private sealed record BufferMigration( BufferKind Kind, IGpuBuffer OldBuffer, IGpuBuffer NewBuffer, int OldCapacity, int NewCapacity, long OldCapacityBytes, long NewCapacityBytes, long CopyBytes) { public long CopiedBytes { get; set; } } public uint VAO { get; private set; } /// /// The vertex store's raw GL name, or 0 on a backend with no GL context. /// Transitional: the GL draw paths still bind the arena themselves, so the /// arena keeps publishing the backend name of the buffer it now owns as an /// . /// public uint VBO => _gl is null || _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName; /// The index store's raw GL name. See . public uint IBO => _gl is null || _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName; /// /// The vertex store as the contract's own handle. This is what a pass /// encoder binds, and it is live on both arms — is the /// GL-only expression of the same thing. /// internal IGpuBuffer? VertexStore => _vertexBuffer; /// The index store as the contract's own handle. See . internal IGpuBuffer? IndexStore => _indexBuffer; /// /// True once both backing stores exist. The backend-neutral form of the /// VAO != 0 readiness test the raw-GL draw paths make. /// internal bool HasStores => _vertexBuffer is not null && _indexBuffer is not null; 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 long UsedBytes => checked( (long)_vertices.Used * VertexPositionNormalTexture.Size + (long)_indices.Used * sizeof(ushort)); internal long LargestFreeBytes => checked( (long)_vertices.LargestFreeRange * VertexPositionNormalTexture.Size + (long)_indices.LargestFreeRange * sizeof(ushort)); internal long PendingRangeRetirementBytes => checked( (long)_vertices.PendingReleaseLength * VertexPositionNormalTexture.Size + (long)_indices.PendingReleaseLength * sizeof(ushort)); internal long RequestedMigrationBytes => _migration?.NewCapacityBytes ?? 0; internal long RetiredBackingBytes => _retiredCapacityBytes; internal long ResidentCapacityBytes => checked( CapacityBytes - PendingRangeRetirementBytes); 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); } internal GlobalMeshBuffer(GL? gl, IGpuDevice device, IGpuResourceRetirementQueue retirement) { _gl = gl; _device = device ?? throw new ArgumentNullException(nameof(device)); ArgumentNullException.ThrowIfNull(retirement); _retirementLedger = new GpuRetirementLedger(retirement); _vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB _indices = new GpuRetiredRangeAllocator(InitialIndexCapacity, retirement); // ~6 MB InitBuffers(); } /// /// The mesh arena is simultaneously a draw source and both ends of the /// grow-and-copy migration, which is why is a /// flags enum: Vulkan must name every usage at creation time. /// private static GpuBufferDescription DescribeStore(BufferKind kind, long sizeBytes, int generation) => new( kind == BufferKind.Vertices ? $"mesh-arena-vertex-{generation}" : $"mesh-arena-index-{generation}", sizeBytes, (kind == BufferKind.Vertices ? GpuBufferUsage.Vertex : GpuBufferUsage.Index) | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination, GpuMemoryResidency.DeviceLocal); private unsafe void InitBuffers() { uint vao = 0; IGpuBuffer? vbo = null; IGpuBuffer? ibo = null; long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size; long indexBytes = (long)_indices.Capacity * sizeof(ushort); bool vaoTracked = false; bool vertexTracked = false; bool indexTracked = false; try { // The vertex array is the one object here with no RHI equivalent — // Vulkan bakes vertex input into the pipeline — so a backend with no // GL context builds the two stores and nothing else. if (_gl is { } gl) { gl.GenVertexArrays(1, out vao); if (vao == 0) throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects."); } vbo = _device.CreateBuffer(DescribeStore(BufferKind.Vertices, vertexBytes, _storeGeneration)); ibo = _device.CreateBuffer(DescribeStore(BufferKind.Indices, indexBytes, _storeGeneration)); if (_gl is { } glBind) { glBind.BindVertexArray(vao); glBind.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName); ConfigureVertexAttributes(glBind); glBind.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName); GLHelpers.ThrowOnResourceError( glBind, $"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; _vertexBuffer = vbo; _indexBuffer = ibo; } catch { // Construction rollback: nothing was ever submitted, so the physical // stores are released on the spot rather than deferred. Pattern-matched // rather than RequireGlBuffer'd so a non-GL store could never raise a // cast failure that masks the original construction exception. if (ibo is GlGpuBuffer stagedIndexStore) stagedIndexStore.DeleteRetired("rolling back the global index arena buffer"); else ibo?.Dispose(); if (vbo is GlGpuBuffer stagedVertexStore) stagedVertexStore.DeleteRetired("rolling back the global vertex arena buffer"); else vbo?.Dispose(); 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 static unsafe void ConfigureVertexAttributes(GL gl) { 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 GlobalMeshAllocation UploadMesh( VertexPositionNormalTexture[] vertices, IReadOnlyList 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 { // IGpuBuffer.Upload stages through a neutral binding point of the // backend's choosing, so a mesh upload can no longer disturb whichever // vertex array a preceding render pass happened to leave bound — the // property the old hand-rolled ElementArrayBuffer/CopyWriteBuffer split // was protecting. long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size); RequireStore(_vertexBuffer).Upload( vertexOffsetBytes, MemoryMarshal.AsBytes(new ReadOnlySpan(vertices))); IGpuBuffer indexStore = RequireStore(_indexBuffer); int indexOffset = indexRange.Offset; for (int i = 0; i < indexBatches.Count; i++) { ushort[] batch = indexBatches[i]; firstIndices[i] = indexOffset; if (batch.Length > 0) { long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); indexStore.Upload( indexOffsetBytes, MemoryMarshal.AsBytes(new ReadOnlySpan(batch))); indexOffset = checked(indexOffset + batch.Length); } } } 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); } /// /// Rolls back a mesh transaction which never published render data and /// therefore can never have been referenced by a submitted draw. /// 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; } /// /// Copies at most 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. /// 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) { // Device-side copy: the live prefix never round-trips through // system memory. The Vulkan backend records vkCmdCopyBuffer here. migration.OldBuffer.CopyTo( migration.NewBuffer, migration.CopiedBytes, migration.CopiedBytes, chunk); 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; } } /// /// Starts a staged shrink of one cold arena. Copy size no longer prevents /// reclamation: services any prefix over as /// many bounded frames as necessary. /// 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 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; IGpuBuffer oldBuffer = RequireStore( kind == BufferKind.Vertices ? _vertexBuffer : _indexBuffer); long oldBytes = CapacityBytesFor(kind, oldCapacity); long newBytes = CapacityBytesFor(kind, newCapacity); IGpuBuffer newBuffer = _device.CreateBuffer( DescribeStore(kind, newBytes, checked(++_storeGeneration))); 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) { // The atomic publication step is a VAO rebind on GL and nothing at all // on a backend whose vertex source is a per-draw encoder bind: the field // swap below IS the publication there, and the next pass reads the new // store. The rollback arm exists for the same reason it did — a failed // rebind must leave the vertex array pointing at the live store. if (_gl is { } gl) { try { gl.BindVertexArray(VAO); if (migration.Kind == BufferKind.Vertices) { gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); ConfigureVertexAttributes(gl); } else { gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); } GLHelpers.ThrowOnResourceError(gl, $"publishing staged {migration.Kind} arena buffer"); } catch { gl.BindVertexArray(VAO); if (migration.Kind == BufferKind.Vertices) { gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); ConfigureVertexAttributes(gl); } else { gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); } gl.BindVertexArray(0); throw; } finally { gl.BindVertexArray(0); } } if (migration.Kind == BufferKind.Vertices) { _vertexBuffer = migration.NewBuffer; if (migration.NewCapacity > migration.OldCapacity) _vertices.Grow(migration.NewCapacity); else _vertices.Shrink(migration.NewCapacity); } else { _indexBuffer = 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 = CreateRetryableStoreDeletion( migration.OldBuffer, migration.OldCapacityBytes, $"retiring replaced global {migration.Kind} arena buffer '{migration.OldBuffer.Name}'"); _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, CreateRetryableStoreDeletion( migration.NewBuffer, migration.NewCapacityBytes, $"aborting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'")); RetryPendingMigrationAbort(); } /// /// The arena's own flight gate — and the abort /// ticket — already proves no submitted frame can reference the store, so the /// physical delete runs here rather than being deferred a second time by /// . Stages match /// TrackedGlResource.CreateRetryableBufferDeletion exactly: precondition, /// mutation-with-validation, byte accounting, then resource-count accounting, /// so a driver failure re-issues only the delete and never double-counts. /// private RetryableGpuResourceRelease CreateRetryableStoreDeletion( IGpuBuffer buffer, long capacityBytes, string context) { ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); GL? gl = _gl; return new RetryableGpuResourceRelease( () => { if (gl is not null) GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); }, // On GL the delete runs here rather than through IGpuBuffer.Dispose // because the arena's own flight gate has already proven no submitted // frame can reference the store. A backend with no GL context has no // second deferral to skip: Dispose IS its retirement-queued release. () => { if (gl is not null) RequireGlBuffer(buffer).DeleteRetired(context); else buffer.Dispose(); }, () => { if (capacityBytes != 0) GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer); }, () => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer)); } 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 (!ReferenceEquals(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); } // The former ToNativeOffset/ToNativeSize narrowing guards went with the raw // glBufferSubData/glCopyBufferSubData calls they wrapped (Campaign V slice // V4b). IGpuBuffer speaks in long, and every arena offset and length is // bounded by an int-typed element capacity times a 32- or 2-byte stride, so // the arena can never present a value a backend cannot express. 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 = CreateRetryableStoreDeletion( migration.NewBuffer, migration.NewCapacityBytes, $"deleting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'"); releases.Add(("staged-migration-buffer", release.Run)); } if (VAO != 0 && _gl is { } vaoGl) { RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableVertexArrayDeletion( vaoGl, VAO, $"deleting global mesh vertex array {VAO}", GlobalMeshVaoAccounting.TrackDeallocation); releases.Add(("global-vao", release.Run)); } if (_vertexBuffer is { } vertexStore) { RetryableGpuResourceRelease release = CreateRetryableStoreDeletion( vertexStore, (long)_vertices.Capacity * VertexPositionNormalTexture.Size, $"deleting global mesh vertex buffer '{vertexStore.Name}'"); releases.Add(("global-vbo", release.Run)); } if (_indexBuffer is { } indexStore) { RetryableGpuResourceRelease release = CreateRetryableStoreDeletion( indexStore, (long)_indices.Capacity * sizeof(ushort), $"deleting global mesh index buffer '{indexStore.Name}'"); 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 = 0; _vertexBuffer = null; _indexBuffer = null; _disposeResources = null; _disposed = true; if (attempt.HasFailures) throw attempt.ToException( "Global mesh-buffer resources released with exceptional committed outcomes."); } }