TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
962 lines
38 KiB
C#
962 lines
38 KiB
C#
using AcDream.Content;
|
|
using Chorizite.Core.Render.Enums;
|
|
using Silk.NET.OpenGL;
|
|
using AcDream.App.Rendering;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
internal sealed record GlobalMeshAllocation(
|
|
MeshBufferRange Vertices,
|
|
MeshBufferRange Indices,
|
|
IReadOnlyList<int> BatchFirstIndices);
|
|
|
|
internal readonly record struct GlobalMeshUploadPlan(
|
|
long UploadBytes,
|
|
long AllocationBytes,
|
|
long CopyBytes,
|
|
int NewBufferCount);
|
|
|
|
internal readonly record struct GlobalMeshMaintenanceStep(
|
|
long AllocationBytes,
|
|
long CopyBytes,
|
|
int NewBufferCount,
|
|
bool Completed);
|
|
|
|
/// <summary>
|
|
/// Retains the staged GL name and its exact release cursor while an aborted
|
|
/// arena migration is being unwound. The owner may only forget the migration
|
|
/// after this ticket has converged.
|
|
/// </summary>
|
|
internal sealed class GlobalMeshMigrationAbortTicket
|
|
{
|
|
private readonly RetryableGpuResourceRelease _release;
|
|
|
|
public GlobalMeshMigrationAbortTicket(
|
|
uint buffer,
|
|
long capacityBytes,
|
|
RetryableGpuResourceRelease release)
|
|
{
|
|
if (buffer == 0)
|
|
throw new ArgumentOutOfRangeException(nameof(buffer));
|
|
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
|
|
Buffer = buffer;
|
|
CapacityBytes = capacityBytes;
|
|
_release = release ?? throw new ArgumentNullException(nameof(release));
|
|
}
|
|
|
|
public uint Buffer { get; }
|
|
public long CapacityBytes { get; }
|
|
public bool IsComplete => _release.IsComplete;
|
|
|
|
public void Advance() => _release.Run();
|
|
}
|
|
|
|
internal static class GlobalMeshVaoAccounting
|
|
{
|
|
public static void TrackAllocation() =>
|
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
|
|
|
|
public static void TrackDeallocation() =>
|
|
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
|
|
}
|
|
|
|
internal enum GlobalMeshCapacityResult
|
|
{
|
|
Ready,
|
|
MigrationStarted,
|
|
MigrationInProgress,
|
|
NeedsReclamation,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared modern-rendering vertex/index buffers with reclaimable ranges.
|
|
/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
|
|
/// when its zero-reference LRU entry is evicted.
|
|
/// </summary>
|
|
internal sealed class GlobalMeshBuffer : IDisposable
|
|
{
|
|
internal const int InitialVertexCapacity = 1024 * 1024;
|
|
internal const int InitialIndexCapacity = 3 * 1024 * 1024;
|
|
internal const int VertexGrowthQuantum = 256 * 1024;
|
|
internal const int IndexGrowthQuantum = 1024 * 1024;
|
|
internal const long MaximumVertexBufferBytes = 384L * 1024 * 1024;
|
|
internal const long MaximumIndexBufferBytes = 128L * 1024 * 1024;
|
|
// Worst legal dual-buffer overlap: just-under-384 MiB old vertex store +
|
|
// 384 MiB destination + the 128 MiB active index store. No route can
|
|
// accumulate a second staged/retired generation beyond this ceiling.
|
|
internal const long MaximumPhysicalArenaBytes = 896L * 1024 * 1024;
|
|
internal static readonly int MaximumVertexCapacity = checked(
|
|
(int)(MaximumVertexBufferBytes / VertexPositionNormalTexture.Size));
|
|
internal const int MaximumIndexCapacity =
|
|
(int)(MaximumIndexBufferBytes / sizeof(ushort));
|
|
|
|
private readonly GL _gl;
|
|
private readonly GpuRetirementLedger _retirementLedger;
|
|
private readonly GpuRetiredRangeAllocator _vertices;
|
|
private readonly GpuRetiredRangeAllocator _indices;
|
|
private BufferMigration? _migration;
|
|
private GlobalMeshMigrationAbortTicket? _migrationAbort;
|
|
private long _retiredCapacityBytes;
|
|
private bool _disposed;
|
|
private RetryableResourceReleaseLedger? _disposeResources;
|
|
|
|
private enum BufferKind
|
|
{
|
|
Vertices,
|
|
Indices,
|
|
}
|
|
|
|
private sealed record BufferMigration(
|
|
BufferKind Kind,
|
|
uint OldBuffer,
|
|
uint NewBuffer,
|
|
int OldCapacity,
|
|
int NewCapacity,
|
|
long OldCapacityBytes,
|
|
long NewCapacityBytes,
|
|
long CopyBytes)
|
|
{
|
|
public long CopiedBytes { get; set; }
|
|
}
|
|
|
|
public uint VAO { get; private set; }
|
|
public uint VBO { get; private set; }
|
|
public uint IBO { get; private set; }
|
|
internal long UploadCount { get; private set; }
|
|
internal long UploadedBytes { get; private set; }
|
|
internal long CapacityBytes =>
|
|
(long)_vertices.Capacity * VertexPositionNormalTexture.Size
|
|
+ (long)_indices.Capacity * sizeof(ushort);
|
|
internal long PhysicalCapacityBytes => checked(
|
|
CapacityBytes + (_migration?.NewCapacityBytes ?? 0) + _retiredCapacityBytes);
|
|
internal bool IsMigrationInProgress => _migration is not null;
|
|
internal bool HasPendingReclamation =>
|
|
_migration is not null
|
|
|| _vertices.PendingReleaseCount != 0
|
|
|| _indices.PendingReleaseCount != 0
|
|
|| _retiredCapacityBytes != 0;
|
|
internal int VertexHighWaterMark => _vertices.HighWaterMark;
|
|
internal int IndexHighWaterMark => _indices.HighWaterMark;
|
|
internal 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);
|
|
}
|
|
|
|
public GlobalMeshBuffer(GL gl)
|
|
: this(gl, ImmediateGpuResourceRetirementQueue.Instance)
|
|
{
|
|
}
|
|
|
|
internal GlobalMeshBuffer(GL gl, IGpuResourceRetirementQueue retirement)
|
|
{
|
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
|
ArgumentNullException.ThrowIfNull(retirement);
|
|
_retirementLedger = new GpuRetirementLedger(retirement);
|
|
_vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB
|
|
_indices = new GpuRetiredRangeAllocator(InitialIndexCapacity, retirement); // ~6 MB
|
|
InitBuffers();
|
|
}
|
|
|
|
private unsafe void InitBuffers()
|
|
{
|
|
uint vao = 0;
|
|
uint vbo = 0;
|
|
uint ibo = 0;
|
|
long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size;
|
|
long indexBytes = (long)_indices.Capacity * sizeof(ushort);
|
|
bool vaoTracked = false;
|
|
bool vertexTracked = false;
|
|
bool indexTracked = false;
|
|
|
|
try
|
|
{
|
|
_gl.GenVertexArrays(1, out vao);
|
|
_gl.GenBuffers(1, out vbo);
|
|
_gl.GenBuffers(1, out ibo);
|
|
if (vao == 0 || vbo == 0 || ibo == 0)
|
|
throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects.");
|
|
|
|
_gl.BindVertexArray(vao);
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, vbo);
|
|
_gl.BufferData(GLEnum.ArrayBuffer, ToNativeSize(vertexBytes), null, GLEnum.StaticDraw);
|
|
ConfigureVertexAttributes();
|
|
|
|
_gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo);
|
|
_gl.BufferData(GLEnum.ElementArrayBuffer, ToNativeSize(indexBytes), null, GLEnum.StaticDraw);
|
|
GLHelpers.ThrowOnResourceError(
|
|
_gl,
|
|
$"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)");
|
|
|
|
GlobalMeshVaoAccounting.TrackAllocation();
|
|
vaoTracked = true;
|
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
|
GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer);
|
|
vertexTracked = true;
|
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
|
GpuMemoryTracker.TrackAllocation(indexBytes, GpuResourceType.Buffer);
|
|
indexTracked = true;
|
|
|
|
VAO = vao;
|
|
VBO = vbo;
|
|
IBO = ibo;
|
|
}
|
|
catch
|
|
{
|
|
if (ibo != 0) _gl.DeleteBuffer(ibo);
|
|
if (vbo != 0) _gl.DeleteBuffer(vbo);
|
|
if (vao != 0) _gl.DeleteVertexArray(vao);
|
|
if (indexTracked)
|
|
{
|
|
GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer);
|
|
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
|
|
}
|
|
if (vertexTracked)
|
|
{
|
|
GpuMemoryTracker.TrackDeallocation(vertexBytes, GpuResourceType.Buffer);
|
|
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
|
|
}
|
|
if (vaoTracked)
|
|
GlobalMeshVaoAccounting.TrackDeallocation();
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
}
|
|
|
|
private unsafe void ConfigureVertexAttributes()
|
|
{
|
|
int stride = VertexPositionNormalTexture.Size;
|
|
_gl.EnableVertexAttribArray(0);
|
|
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
|
|
_gl.EnableVertexAttribArray(1);
|
|
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
|
|
_gl.EnableVertexAttribArray(2);
|
|
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
|
|
}
|
|
|
|
internal unsafe GlobalMeshAllocation UploadMesh(
|
|
VertexPositionNormalTexture[] vertices,
|
|
IReadOnlyList<ushort[]> indexBatches)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
if (_migration is not null)
|
|
throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress.");
|
|
ArgumentNullException.ThrowIfNull(vertices);
|
|
ArgumentNullException.ThrowIfNull(indexBatches);
|
|
if (vertices.Length == 0)
|
|
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
|
|
|
|
int totalIndices = 0;
|
|
for (int i = 0; i < indexBatches.Count; i++)
|
|
{
|
|
ushort[] batch = indexBatches[i]
|
|
?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
|
|
totalIndices = checked(totalIndices + batch.Length);
|
|
}
|
|
if (totalIndices == 0)
|
|
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
|
|
|
|
MeshBufferRange vertexRange = AllocateVertices(vertices.Length);
|
|
MeshBufferRange indexRange;
|
|
try
|
|
{
|
|
indexRange = AllocateIndices(totalIndices);
|
|
}
|
|
catch
|
|
{
|
|
_vertices.ReleaseUnsubmitted(vertexRange);
|
|
throw;
|
|
}
|
|
|
|
var firstIndices = new int[indexBatches.Count];
|
|
try
|
|
{
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
|
|
fixed (VertexPositionNormalTexture* ptr = vertices)
|
|
{
|
|
long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size);
|
|
long vertexUploadBytes = checked((long)vertices.Length * VertexPositionNormalTexture.Size);
|
|
_gl.BufferSubData(
|
|
GLEnum.ArrayBuffer,
|
|
ToNativeOffset(vertexOffsetBytes),
|
|
ToNativeSize(vertexUploadBytes),
|
|
ptr);
|
|
}
|
|
|
|
// ElementArrayBuffer binding is VAO state. Use the neutral copy
|
|
// target for staging so uploads cannot mutate whichever VAO the
|
|
// preceding render pass happened to leave bound.
|
|
_gl.BindBuffer(GLEnum.CopyWriteBuffer, IBO);
|
|
int indexOffset = indexRange.Offset;
|
|
for (int i = 0; i < indexBatches.Count; i++)
|
|
{
|
|
ushort[] batch = indexBatches[i];
|
|
firstIndices[i] = indexOffset;
|
|
if (batch.Length > 0)
|
|
{
|
|
fixed (ushort* ptr = batch)
|
|
{
|
|
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
|
|
long indexUploadBytes = checked((long)batch.Length * sizeof(ushort));
|
|
_gl.BufferSubData(
|
|
GLEnum.CopyWriteBuffer,
|
|
ToNativeOffset(indexOffsetBytes),
|
|
ToNativeSize(indexUploadBytes),
|
|
ptr);
|
|
}
|
|
indexOffset = checked(indexOffset + batch.Length);
|
|
}
|
|
}
|
|
GLHelpers.ThrowOnResourceError(
|
|
_gl,
|
|
$"uploading global mesh ({vertices.Length} vertices, {totalIndices} indices)");
|
|
}
|
|
catch
|
|
{
|
|
_indices.ReleaseUnsubmitted(indexRange);
|
|
_vertices.ReleaseUnsubmitted(vertexRange);
|
|
throw;
|
|
}
|
|
|
|
UploadCount++;
|
|
UploadedBytes = checked(UploadedBytes
|
|
+ checked((long)vertices.Length * VertexPositionNormalTexture.Size)
|
|
+ checked((long)totalIndices * sizeof(ushort)));
|
|
return new GlobalMeshAllocation(vertexRange, indexRange, firstIndices);
|
|
}
|
|
|
|
internal void Release(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_indices.ReleaseAfterGpuUse(allocation.Indices);
|
|
_vertices.ReleaseAfterGpuUse(allocation.Vertices);
|
|
}
|
|
|
|
// Narrow seams for ObjectMeshManager's per-resource retirement ledger.
|
|
// The ordinary Release method remains the convenience API; a retryable
|
|
// owner uses these seams so one accepted range is never submitted twice.
|
|
internal void ReleaseIndexRange(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_indices.ReleaseAfterGpuUse(allocation.Indices);
|
|
}
|
|
|
|
internal void ReleaseVertexRange(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_vertices.ReleaseAfterGpuUse(allocation.Vertices);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rolls back a mesh transaction which never published render data and
|
|
/// therefore can never have been referenced by a submitted draw.
|
|
/// </summary>
|
|
internal void Abort(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_indices.ReleaseUnsubmitted(allocation.Indices);
|
|
_vertices.ReleaseUnsubmitted(allocation.Vertices);
|
|
}
|
|
|
|
// Failed uploads were never submitted, so these matching seams return
|
|
// each range immediately while preserving independent rollback progress.
|
|
internal void AbortIndexRange(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_indices.ReleaseUnsubmitted(allocation.Indices);
|
|
}
|
|
|
|
internal void AbortVertexRange(GlobalMeshAllocation allocation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(allocation);
|
|
_vertices.ReleaseUnsubmitted(allocation.Vertices);
|
|
}
|
|
|
|
private MeshBufferRange AllocateVertices(int count)
|
|
{
|
|
if (_vertices.TryAllocate(count, out MeshBufferRange allocation))
|
|
return allocation;
|
|
throw new InvalidOperationException(
|
|
"Vertex capacity was not migrated before the staged mesh upload was admitted.");
|
|
}
|
|
|
|
private MeshBufferRange AllocateIndices(int count)
|
|
{
|
|
if (_indices.TryAllocate(count, out MeshBufferRange allocation))
|
|
return allocation;
|
|
|
|
throw new InvalidOperationException(
|
|
"Index capacity was not migrated before the staged mesh upload was admitted.");
|
|
}
|
|
|
|
internal static int CalculateGrowthCapacity(
|
|
int capacity,
|
|
int trailingFreeLength,
|
|
int requiredContiguousLength,
|
|
int growthQuantum,
|
|
int maximumCapacity = int.MaxValue)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(trailingFreeLength);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(trailingFreeLength, capacity);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requiredContiguousLength);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCapacity, capacity);
|
|
|
|
long missing = Math.Max(0L, (long)requiredContiguousLength - trailingFreeLength);
|
|
if (missing == 0)
|
|
return capacity;
|
|
|
|
long minimum = checked((long)capacity + missing);
|
|
if (minimum > maximumCapacity)
|
|
throw new NotSupportedException(
|
|
$"A contiguous range of {requiredContiguousLength:N0} elements exceeds the supported arena capacity {maximumCapacity:N0}.");
|
|
|
|
// A 3:2 geometric destination amortizes the immutable-prefix copy.
|
|
// Rounding only the missing tail caused every few uploads to allocate
|
|
// another buffer and recopy the full prefix (quadratic route cost).
|
|
long geometric = checked((long)capacity + Math.Max((long)growthQuantum, capacity / 2L));
|
|
long target = Math.Min(maximumCapacity, Math.Max(minimum, geometric));
|
|
return RoundUpToLimit(target, growthQuantum, maximumCapacity);
|
|
}
|
|
|
|
internal static bool TryCalculateTrimCapacity(
|
|
int capacity,
|
|
int highWaterMark,
|
|
int initialCapacity,
|
|
int growthQuantum,
|
|
out int trimmedCapacity)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(highWaterMark);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(highWaterMark, capacity);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCapacity);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
|
|
|
|
trimmedCapacity = capacity;
|
|
if (capacity <= initialCapacity)
|
|
return false;
|
|
|
|
// Capacity-based hysteresis, not a settle timer: retain 100% headroom
|
|
// above the live tail and shrink only when the result is no more than
|
|
// one third of the current arena. A destination revisit therefore has
|
|
// to more than double its live prefix before growth can resume.
|
|
long withHeadroom = checked(
|
|
highWaterMark + Math.Max((long)growthQuantum, highWaterMark));
|
|
int target = Math.Max(
|
|
initialCapacity,
|
|
RoundUpToLimit(withHeadroom, growthQuantum, int.MaxValue));
|
|
if (target > capacity / 3)
|
|
return false;
|
|
|
|
trimmedCapacity = target;
|
|
return true;
|
|
}
|
|
|
|
internal GlobalMeshCapacityResult EnsureUploadCapacity(
|
|
int vertexCount,
|
|
int indexCount,
|
|
out GlobalMeshMaintenanceStep step)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
ArgumentOutOfRangeException.ThrowIfNegative(vertexCount);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(indexCount);
|
|
if (vertexCount > MaximumVertexCapacity)
|
|
throw new NotSupportedException(
|
|
$"Mesh requires {vertexCount:N0} vertices; the supported per-arena maximum is {MaximumVertexCapacity:N0}.");
|
|
if (indexCount > MaximumIndexCapacity)
|
|
throw new NotSupportedException(
|
|
$"Mesh requires {indexCount:N0} indices; the supported per-arena maximum is {MaximumIndexCapacity:N0}.");
|
|
|
|
step = default;
|
|
if (_migration is not null)
|
|
return GlobalMeshCapacityResult.MigrationInProgress;
|
|
if (vertexCount <= _vertices.LargestFreeRange
|
|
&& indexCount <= _indices.LargestFreeRange)
|
|
{
|
|
return GlobalMeshCapacityResult.Ready;
|
|
}
|
|
|
|
BufferKind kind;
|
|
int targetCapacity;
|
|
long copyBytes;
|
|
if (vertexCount > _vertices.LargestFreeRange)
|
|
{
|
|
long minimum = checked(
|
|
(long)_vertices.Capacity
|
|
+ Math.Max(0L, (long)vertexCount - _vertices.TrailingFreeLength));
|
|
if (minimum > MaximumVertexCapacity)
|
|
return GlobalMeshCapacityResult.NeedsReclamation;
|
|
kind = BufferKind.Vertices;
|
|
targetCapacity = CalculateGrowthCapacity(
|
|
_vertices.Capacity,
|
|
_vertices.TrailingFreeLength,
|
|
vertexCount,
|
|
VertexGrowthQuantum,
|
|
MaximumVertexCapacity);
|
|
copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size);
|
|
}
|
|
else
|
|
{
|
|
long minimum = checked(
|
|
(long)_indices.Capacity
|
|
+ Math.Max(0L, (long)indexCount - _indices.TrailingFreeLength));
|
|
if (minimum > MaximumIndexCapacity)
|
|
return GlobalMeshCapacityResult.NeedsReclamation;
|
|
kind = BufferKind.Indices;
|
|
targetCapacity = CalculateGrowthCapacity(
|
|
_indices.Capacity,
|
|
_indices.TrailingFreeLength,
|
|
indexCount,
|
|
IndexGrowthQuantum,
|
|
MaximumIndexCapacity);
|
|
copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort));
|
|
}
|
|
|
|
long newBytes = CapacityBytesFor(kind, targetCapacity);
|
|
if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes)
|
|
return GlobalMeshCapacityResult.NeedsReclamation;
|
|
|
|
BeginMigration(kind, targetCapacity, copyBytes);
|
|
step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false);
|
|
return GlobalMeshCapacityResult.MigrationStarted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies at most <paramref name="maximumCopyBytes"/> of the immutable
|
|
/// live prefix into the staged backing store. The active VAO continues to
|
|
/// reference the old store until the final chunk succeeds, then one atomic
|
|
/// VAO rebind publishes the destination.
|
|
/// </summary>
|
|
internal GlobalMeshMaintenanceStep AdvanceMigration(long maximumCopyBytes)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes);
|
|
BufferMigration? migration = _migration;
|
|
if (migration is null)
|
|
return default;
|
|
|
|
long chunk = CalculateCopyChunk(
|
|
migration.CopyBytes,
|
|
migration.CopiedBytes,
|
|
maximumCopyBytes);
|
|
try
|
|
{
|
|
if (chunk != 0)
|
|
{
|
|
_gl.BindBuffer(GLEnum.CopyReadBuffer, migration.OldBuffer);
|
|
_gl.BindBuffer(GLEnum.CopyWriteBuffer, migration.NewBuffer);
|
|
_gl.CopyBufferSubData(
|
|
GLEnum.CopyReadBuffer,
|
|
GLEnum.CopyWriteBuffer,
|
|
ToNativeOffset(migration.CopiedBytes),
|
|
ToNativeOffset(migration.CopiedBytes),
|
|
ToNativeSize(chunk));
|
|
GLHelpers.ThrowOnResourceError(
|
|
_gl,
|
|
$"migrating {migration.Kind} arena bytes "
|
|
+ $"{migration.CopiedBytes:N0}..{migration.CopiedBytes + chunk:N0}");
|
|
migration.CopiedBytes = checked(migration.CopiedBytes + chunk);
|
|
}
|
|
|
|
bool complete = migration.CopiedBytes == migration.CopyBytes;
|
|
if (complete)
|
|
CommitMigration(migration);
|
|
return new GlobalMeshMaintenanceStep(0, chunk, 0, complete);
|
|
}
|
|
catch (Exception migrationError)
|
|
{
|
|
try
|
|
{
|
|
AbortMigration(migration);
|
|
}
|
|
catch (Exception abortError)
|
|
{
|
|
throw new AggregateException(
|
|
"Global mesh migration failed and its staged buffer could not yet be released.",
|
|
migrationError,
|
|
abortError);
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a staged shrink of one cold arena. Copy size no longer prevents
|
|
/// reclamation: <see cref="AdvanceMigration"/> services any prefix over as
|
|
/// many bounded frames as necessary.
|
|
/// </summary>
|
|
internal bool TryTrimUnusedTail(out GlobalMeshMaintenanceStep step)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
step = default;
|
|
if (_migration is not null)
|
|
return false;
|
|
bool trimVertices = TryCalculateTrimCapacity(
|
|
_vertices.Capacity, _vertices.HighWaterMark,
|
|
InitialVertexCapacity, VertexGrowthQuantum,
|
|
out int vertexCapacity);
|
|
bool trimIndices = TryCalculateTrimCapacity(
|
|
_indices.Capacity, _indices.HighWaterMark,
|
|
InitialIndexCapacity, IndexGrowthQuantum,
|
|
out int indexCapacity);
|
|
|
|
long vertexSaving = trimVertices
|
|
? (long)(_vertices.Capacity - vertexCapacity) * VertexPositionNormalTexture.Size
|
|
: 0;
|
|
long indexSaving = trimIndices
|
|
? (long)(_indices.Capacity - indexCapacity) * sizeof(ushort)
|
|
: 0;
|
|
if (vertexSaving == 0 && indexSaving == 0)
|
|
return false;
|
|
|
|
BufferKind kind;
|
|
int capacity;
|
|
long copyBytes;
|
|
if (vertexSaving >= indexSaving)
|
|
{
|
|
kind = BufferKind.Vertices;
|
|
capacity = vertexCapacity;
|
|
copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size);
|
|
}
|
|
else
|
|
{
|
|
kind = BufferKind.Indices;
|
|
capacity = indexCapacity;
|
|
copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort));
|
|
}
|
|
|
|
long newBytes = CapacityBytesFor(kind, capacity);
|
|
if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes)
|
|
return false;
|
|
BeginMigration(kind, capacity, copyBytes);
|
|
step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false);
|
|
return true;
|
|
}
|
|
|
|
internal static long CalculateCopyChunk(long totalBytes, long copiedBytes, long maximumCopyBytes)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(totalBytes);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(copiedBytes);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(copiedBytes, totalBytes);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes);
|
|
return Math.Min(totalBytes - copiedBytes, maximumCopyBytes);
|
|
}
|
|
|
|
private unsafe void BeginMigration(BufferKind kind, int newCapacity, long copyBytes)
|
|
{
|
|
if (_migration is not null || _migrationAbort is not null)
|
|
throw new InvalidOperationException("Only one global mesh backing buffer may migrate at a time.");
|
|
int oldCapacity = kind == BufferKind.Vertices ? _vertices.Capacity : _indices.Capacity;
|
|
uint oldBuffer = kind == BufferKind.Vertices ? VBO : IBO;
|
|
long oldBytes = CapacityBytesFor(kind, oldCapacity);
|
|
long newBytes = CapacityBytesFor(kind, newCapacity);
|
|
uint newBuffer = 0;
|
|
|
|
try
|
|
{
|
|
_gl.GenBuffers(1, out newBuffer);
|
|
if (newBuffer == 0)
|
|
throw new InvalidOperationException($"OpenGL did not create a staged {kind} arena buffer.");
|
|
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newBuffer);
|
|
_gl.BufferData(GLEnum.CopyWriteBuffer, ToNativeSize(newBytes), null, GLEnum.StaticDraw);
|
|
GLHelpers.ThrowOnResourceError(
|
|
_gl,
|
|
$"allocating staged {kind} arena buffer ({newBytes:N0} bytes)");
|
|
}
|
|
catch
|
|
{
|
|
if (newBuffer != 0)
|
|
_gl.DeleteBuffer(newBuffer);
|
|
throw;
|
|
}
|
|
|
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
|
GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer);
|
|
_migration = new BufferMigration(
|
|
kind,
|
|
oldBuffer,
|
|
newBuffer,
|
|
oldCapacity,
|
|
newCapacity,
|
|
oldBytes,
|
|
newBytes,
|
|
copyBytes);
|
|
}
|
|
|
|
private void CommitMigration(BufferMigration migration)
|
|
{
|
|
try
|
|
{
|
|
_gl.BindVertexArray(VAO);
|
|
if (migration.Kind == BufferKind.Vertices)
|
|
{
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.NewBuffer);
|
|
ConfigureVertexAttributes();
|
|
}
|
|
else
|
|
{
|
|
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.NewBuffer);
|
|
}
|
|
GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer");
|
|
}
|
|
catch
|
|
{
|
|
_gl.BindVertexArray(VAO);
|
|
if (migration.Kind == BufferKind.Vertices)
|
|
{
|
|
_gl.BindBuffer(GLEnum.ArrayBuffer, migration.OldBuffer);
|
|
ConfigureVertexAttributes();
|
|
}
|
|
else
|
|
{
|
|
_gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.OldBuffer);
|
|
}
|
|
_gl.BindVertexArray(0);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
if (migration.Kind == BufferKind.Vertices)
|
|
{
|
|
VBO = migration.NewBuffer;
|
|
if (migration.NewCapacity > migration.OldCapacity)
|
|
_vertices.Grow(migration.NewCapacity);
|
|
else
|
|
_vertices.Shrink(migration.NewCapacity);
|
|
}
|
|
else
|
|
{
|
|
IBO = migration.NewBuffer;
|
|
if (migration.NewCapacity > migration.OldCapacity)
|
|
_indices.Grow(migration.NewCapacity);
|
|
else
|
|
_indices.Shrink(migration.NewCapacity);
|
|
}
|
|
|
|
_migration = null;
|
|
_retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes);
|
|
RetryableGpuResourceRelease oldBufferRelease =
|
|
TrackedGlResource.CreateRetryableBufferDeletion(
|
|
_gl,
|
|
migration.OldBuffer,
|
|
migration.OldCapacityBytes,
|
|
$"retiring replaced global {migration.Kind} arena buffer {migration.OldBuffer}");
|
|
_retirementLedger.Retire(new RetryableGpuResourceRelease(
|
|
oldBufferRelease.Run,
|
|
() => _retiredCapacityBytes = checked(
|
|
_retiredCapacityBytes - migration.OldCapacityBytes)));
|
|
}
|
|
|
|
private void AbortMigration(BufferMigration migration)
|
|
{
|
|
if (!ReferenceEquals(_migration, migration))
|
|
return;
|
|
_migrationAbort ??= new GlobalMeshMigrationAbortTicket(
|
|
migration.NewBuffer,
|
|
migration.NewCapacityBytes,
|
|
TrackedGlResource.CreateRetryableBufferDeletion(
|
|
_gl,
|
|
migration.NewBuffer,
|
|
migration.NewCapacityBytes,
|
|
$"aborting staged global {migration.Kind} arena buffer {migration.NewBuffer}"));
|
|
RetryPendingMigrationAbort();
|
|
}
|
|
|
|
private void RetryPendingMigrationAbort()
|
|
{
|
|
GlobalMeshMigrationAbortTicket? ticket = _migrationAbort;
|
|
if (ticket is null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
ticket.Advance();
|
|
}
|
|
finally
|
|
{
|
|
if (ticket.IsComplete)
|
|
{
|
|
BufferMigration migration = _migration
|
|
?? throw new InvalidOperationException(
|
|
"A staged-buffer abort ticket outlived its migration record.");
|
|
if (migration.NewBuffer != ticket.Buffer
|
|
|| migration.NewCapacityBytes != ticket.CapacityBytes)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A staged-buffer abort ticket no longer matches its migration record.");
|
|
}
|
|
|
|
_migrationAbort = null;
|
|
_migration = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static long CapacityBytesFor(BufferKind kind, int capacity) => kind switch
|
|
{
|
|
BufferKind.Vertices => checked((long)capacity * VertexPositionNormalTexture.Size),
|
|
BufferKind.Indices => checked((long)capacity * sizeof(ushort)),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
|
};
|
|
|
|
private static int RoundUpToLimit(long value, int quantum, int maximum)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
|
|
long remainder = value % quantum;
|
|
long rounded = remainder == 0 ? value : checked(value + quantum - remainder);
|
|
if (rounded > maximum)
|
|
rounded = maximum;
|
|
return checked((int)rounded);
|
|
}
|
|
|
|
private static nint ToNativeOffset(long value)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(value);
|
|
if (IntPtr.Size == 4 && value > int.MaxValue)
|
|
throw new NotSupportedException("The requested GPU byte offset exceeds this process's native pointer range.");
|
|
return checked((nint)value);
|
|
}
|
|
|
|
private static nuint ToNativeSize(long value)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegative(value);
|
|
if (UIntPtr.Size == 4 && value > uint.MaxValue)
|
|
throw new NotSupportedException("The requested GPU byte count exceeds this process's native pointer range.");
|
|
return checked((nuint)value);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_retirementLedger.RetryPendingPublications();
|
|
RetryPendingMigrationAbort();
|
|
|
|
if (_disposeResources is null)
|
|
{
|
|
var releases = new List<(string Name, Action Release)>();
|
|
if (_migration is { } migration)
|
|
{
|
|
RetryableGpuResourceRelease release =
|
|
TrackedGlResource.CreateRetryableBufferDeletion(
|
|
_gl,
|
|
migration.NewBuffer,
|
|
migration.NewCapacityBytes,
|
|
$"deleting staged global {migration.Kind} arena buffer {migration.NewBuffer}");
|
|
releases.Add(("staged-migration-buffer", release.Run));
|
|
}
|
|
|
|
if (VAO != 0)
|
|
{
|
|
RetryableGpuResourceRelease release =
|
|
TrackedGlResource.CreateRetryableVertexArrayDeletion(
|
|
_gl,
|
|
VAO,
|
|
$"deleting global mesh vertex array {VAO}",
|
|
GlobalMeshVaoAccounting.TrackDeallocation);
|
|
releases.Add(("global-vao", release.Run));
|
|
}
|
|
if (VBO != 0)
|
|
{
|
|
RetryableGpuResourceRelease release =
|
|
TrackedGlResource.CreateRetryableBufferDeletion(
|
|
_gl,
|
|
VBO,
|
|
(long)_vertices.Capacity * VertexPositionNormalTexture.Size,
|
|
$"deleting global mesh vertex buffer {VBO}");
|
|
releases.Add(("global-vbo", release.Run));
|
|
}
|
|
if (IBO != 0)
|
|
{
|
|
RetryableGpuResourceRelease release =
|
|
TrackedGlResource.CreateRetryableBufferDeletion(
|
|
_gl,
|
|
IBO,
|
|
(long)_indices.Capacity * sizeof(ushort),
|
|
$"deleting global mesh index buffer {IBO}");
|
|
releases.Add(("global-ibo", release.Run));
|
|
}
|
|
_disposeResources = new RetryableResourceReleaseLedger(releases);
|
|
}
|
|
|
|
ResourceReleaseAttempt attempt = _disposeResources.Advance();
|
|
if (!_disposeResources.IsComplete)
|
|
throw attempt.ToException(
|
|
"One or more global mesh-buffer resources could not be released.");
|
|
|
|
_migration = null;
|
|
_migrationAbort = null;
|
|
VAO = VBO = IBO = 0;
|
|
_disposeResources = null;
|
|
_disposed = true;
|
|
|
|
if (attempt.HasFailures)
|
|
throw attempt.ToException(
|
|
"Global mesh-buffer resources released with exceptional committed outcomes.");
|
|
}
|
|
}
|