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