V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh
pipeline could be CONSTRUCTED without naming a backend. It said plainly what it
did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are
still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers."
This moves them, and gives the interface its second implementation.
GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer
(V4b); what still needed a context was the vertex array and the attribute
pointers, which have no RHI verb because Vulkan bakes vertex input into the
pipeline. So a backend with none builds the stores and nothing else, publishes
0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers,
named the way a pass encoder binds them. HasStores is the backend-neutral form
of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on
the context and nothing else does: InitBuffers skips the vertex array, and
CommitMigration skips the rebind — on the encoder arm the field swap IS the
atomic publication, because the next pass reads whatever the field then holds.
The store deletion likewise splits: GL keeps its immediate DeleteRetired,
because the arena's own flight gate has already proven no submitted frame can
reference the store, while the other arm has no second deferral to skip and
Dispose is its retirement-queued release.
ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three
call sites were one modern-path constructor argument and two bodies whose every
GL statement sits inside `if (!_useModernRendering)`. The constructor now hands
the arena the nullable context; the two bodies resolve one lazily inside the
legacy branch. That branch is unreachable in every shipping configuration —
missing bindless or draw-parameters throws at startup under the N.5 ship
amendment — so the accessor survives as the guard on dead code rather than as a
blocker, and it is deleted with that code.
VulkanMeshPipelineDevice is the second implementation, and it is four
properties and two no-ops. Two things about it are worth stating rather than
leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are
GL-shaped because the seam was cut from a GL device, but what they gate is the
MODERN path — one shared arena, table texture indexing, multi-draw indirect —
which Vulkan supplies unconditionally and the capability gate rejects a device
for lacking, so answering false would disable the only path that exists.
HasPendingWork answers false because the GL device's queue exists to defer work
onto the thread holding the context, and Vulkan resource work is recorded into
the frame's command buffer or routed through the retirement queue.
WbMeshAdapter selects between them once, in the one place the mesh pipeline
still names a backend. The GL arm is unchanged, including the queue-drain
guarantee its construction rollback asserts.
So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is
deleted — it existed for exactly the gap this closes, and the landblock spawn
ledger now registers against the real adapter. Streaming's publication into GPU
state stops being a no-op there: the Vulkan run below builds real render data,
including the [up-null] zero-vertex caching path.
Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109
baseline plus the three added here. Strict GL offline pixel gate against
579e0b7f: 4.44e-05 (25 differing pixels of 563,200), inside the documented 9-31
px control band and 22x under the 0.001 threshold. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader (VK_LOADER_DEBUG=layer
reports `Insert instance layer "VK_LAYER_KHRONOS_validation"`): zero validation
errors, zero warnings, a captured frame, and no [shutdown] diagnostic on either
stream.
What this does NOT claim: nothing draws the world on Vulkan yet. The three
world renderers' submission arms, the two pass executors, and the pass-structure
merge are the next commit's.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1071 lines
44 KiB
C#
1071 lines
44 KiB
C#
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<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 static class GlobalMeshVaoAccounting
|
|
{
|
|
public static void TrackAllocation() =>
|
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
|
|
|
|
public static void TrackDeallocation() =>
|
|
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
|
|
}
|
|
|
|
internal enum GlobalMeshCapacityResult
|
|
{
|
|
Ready,
|
|
MigrationStarted,
|
|
MigrationInProgress,
|
|
NeedsReclamation,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared modern-rendering vertex/index buffers with reclaimable ranges.
|
|
/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
|
|
/// when its zero-reference LRU entry is evicted.
|
|
///
|
|
/// 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. The vertex array object stays raw GL because a
|
|
/// VAO has no RHI equivalent (Vulkan bakes vertex input into the pipeline) and
|
|
/// <c>WbDrawDispatcher</c>, <c>EnvCellRenderer</c> and <c>ParticleRenderer</c>
|
|
/// still bind <see cref="VAO"/>/<see cref="VBO"/>/<see cref="IBO"/> directly
|
|
/// on the GL arm.
|
|
///
|
|
/// <para>Campaign V slice V6i-3 made the GL context optional. A backend that has
|
|
/// none builds no vertex array and publishes no raw names — <see cref="VAO"/>,
|
|
/// <see cref="VBO"/> and <see cref="IBO"/> are 0 there — and its consumers bind
|
|
/// <see cref="VertexStore"/> and <see cref="IndexStore"/> 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.</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));
|
|
|
|
// 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.");
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V4b transitional bridge. The arena owns its stores as
|
|
/// <see cref="IGpuBuffer"/>, but its consumers — the vertex array object here,
|
|
/// and <c>WbDrawDispatcher</c>/<c>EnvCellRenderer</c>/<c>ParticleRenderer</c>
|
|
/// through <see cref="VBO"/>/<see cref="IBO"/> — are still raw GL until slice
|
|
/// V4c. This is the only place that reaches through the interface, and it
|
|
/// disappears with those consumers.
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="IGpuBuffer"/>.
|
|
/// </summary>
|
|
public uint VBO =>
|
|
_gl is null || _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName;
|
|
|
|
/// <summary>The index store's raw GL name. See <see cref="VBO"/>.</summary>
|
|
public uint IBO =>
|
|
_gl is null || _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName;
|
|
|
|
/// <summary>
|
|
/// The vertex store as the contract's own handle. This is what a pass
|
|
/// encoder binds, and it is live on both arms — <see cref="VAO"/> is the
|
|
/// GL-only expression of the same thing.
|
|
/// </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. The backend-neutral form of the
|
|
/// <c>VAO != 0</c> readiness test the raw-GL draw paths make.
|
|
/// </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(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();
|
|
}
|
|
|
|
/// <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 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<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. The active VAO continues to
|
|
/// reference the old store until the final chunk succeeds, then one atomic
|
|
/// VAO rebind publishes the destination.
|
|
/// </summary>
|
|
internal GlobalMeshMaintenanceStep AdvanceMigration(long maximumCopyBytes)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes);
|
|
BufferMigration? migration = _migration;
|
|
if (migration is null)
|
|
return default;
|
|
|
|
long chunk = CalculateCopyChunk(
|
|
migration.CopyBytes,
|
|
migration.CopiedBytes,
|
|
maximumCopyBytes);
|
|
try
|
|
{
|
|
if (chunk != 0)
|
|
{
|
|
// 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 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();
|
|
}
|
|
|
|
/// <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 by
|
|
/// <see cref="IGpuBuffer.Dispose"/>. Stages match
|
|
/// <c>TrackedGlResource.CreateRetryableBufferDeletion</c> exactly: precondition,
|
|
/// mutation-with-validation, byte accounting, then resource-count accounting,
|
|
/// so a driver 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);
|
|
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.");
|
|
}
|
|
}
|