acdream/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
Erik 8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00

898 lines
36 KiB
C#

using System.Runtime.InteropServices;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
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 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.
/// </summary>
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 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.
///
/// Campaign V slice V4b moved the two backing stores onto
/// <see cref="IGpuBuffer"/>: allocation is <see cref="IGpuDevice.CreateBuffer"/>,
/// mesh upload is <see cref="IGpuBuffer.Upload"/>, and the grow-and-copy
/// migration is <see cref="IGpuBuffer.CopyTo"/> — a device-side copy the Vulkan
/// backend implements with <c>vkCmdCopyBuffer</c>. 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.
///
/// <para>Campaign V slice V6i-3 made the GL context (and its vertex array
/// object, which has no RHI equivalent — Vulkan bakes vertex input into the
/// pipeline) optional; Campaign V slice V11 deleted the GL arm entirely, so
/// this arena now only ever builds the two backing stores. Its consumers bind
/// <see cref="VertexStore"/> and <see cref="IndexStore"/> through the pass
/// encoder, which is the same 32-byte position/normal/texcoord layout
/// expressed as pipeline vertex input.</para>
/// </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 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.");
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; }
}
/// <summary>
/// The vertex store as the contract's own handle. This is what a pass
/// encoder binds.
/// </summary>
internal IGpuBuffer? VertexStore => _vertexBuffer;
/// <summary>The index store as the contract's own handle. See <see cref="VertexStore"/>.</summary>
internal IGpuBuffer? IndexStore => _indexBuffer;
/// <summary>True once both backing stores exist.</summary>
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(IGpuDevice device, IGpuResourceRetirementQueue retirement)
{
_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();
}
/// <summary>
/// The mesh arena is simultaneously a draw source and both ends of the
/// grow-and-copy migration, which is why <see cref="GpuBufferUsage"/> is a
/// flags enum: Vulkan must name every usage at creation time.
/// </summary>
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 void InitBuffers()
{
IGpuBuffer? vbo = null;
IGpuBuffer? ibo = null;
long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size;
long indexBytes = (long)_indices.Capacity * sizeof(ushort);
bool vertexTracked = false;
bool indexTracked = false;
try
{
vbo = _device.CreateBuffer(DescribeStore(BufferKind.Vertices, vertexBytes, _storeGeneration));
ibo = _device.CreateBuffer(DescribeStore(BufferKind.Indices, indexBytes, _storeGeneration));
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer);
vertexTracked = true;
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(indexBytes, GpuResourceType.Buffer);
indexTracked = true;
_vertexBuffer = vbo;
_indexBuffer = ibo;
}
catch
{
// Construction rollback: nothing was ever submitted, so the physical
// stores are released on the spot rather than deferred.
ibo?.Dispose();
vbo?.Dispose();
if (indexTracked)
{
GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
if (vertexTracked)
{
GpuMemoryTracker.TrackDeallocation(vertexBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
throw;
}
}
internal 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
{
// 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<VertexPositionNormalTexture>(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<ushort>(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);
}
/// <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. Draws continue to reference
/// the old store until the final chunk succeeds, then one atomic field swap
/// (<see cref="CommitMigration"/>) 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)
{
// 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;
}
}
/// <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 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 nothing at all here: the vertex source
// is a per-draw pass-encoder bind, so the field swap below IS the
// publication, and the next pass reads the new store.
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();
}
/// <summary>
/// The arena's own flight gate — <see cref="_retirementLedger"/> 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.
/// Stages: precondition (no-op), mutation (dispose), byte accounting, then
/// resource-count accounting, so a failure re-issues only the delete and
/// never double-counts.
/// </summary>
private RetryableGpuResourceRelease CreateRetryableStoreDeletion(
IGpuBuffer buffer,
long capacityBytes,
string context)
{
ArgumentNullException.ThrowIfNull(buffer);
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
return new RetryableGpuResourceRelease(
() => { },
() => 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 (_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;
_vertexBuffer = null;
_indexBuffer = null;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
throw attempt.ToException(
"Global mesh-buffer resources released with exceptional committed outcomes.");
}
}