diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index afeda836..cb2604f2 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -114,6 +114,7 @@ internal interface IWorldRenderCompositionFactory Shader CreateMeshShader(GL gl, string shadersDirectory); WbMeshAdapter CreateMeshAdapter( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement, @@ -291,12 +292,14 @@ internal sealed class RetailWorldRenderCompositionFactory public WbMeshAdapter CreateMeshAdapter( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement, ResidencyBudgetOptions budgets) => new( gl, + device, dats, preparedAssets, NullLogger.Instance, @@ -540,6 +543,7 @@ internal sealed class WorldRenderCompositionPhase "WB mesh adapter", () => _factory.CreateMeshAdapter( gl, + _dependencies.GpuDevice, content.Dats, content.PreparedAssets, _dependencies.ResourceRetirement, diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs index 6295bc6a..e37a8628 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs @@ -18,6 +18,15 @@ namespace AcDream.App.Rendering.Gpu.Gl; /// data; every write after that is glBufferSubData — no persistent /// mapping, matching the campaign's "GL backend is deliberately behaviour- /// preserving" rule for this slice. +/// +/// The glBufferData usage hint follows +/// : +/// is written rarely and read by +/// many draws, so it takes StaticDraw; the host-writable rings and +/// tables are rewritten every frame and take DynamicDraw. The hint is +/// advisory to the driver, but keeping it per-residency is what let the mesh +/// arena (Campaign V slice V4b) move onto this class without changing the +/// allocation it has always requested. /// internal sealed class GlGpuBuffer : IGpuBuffer { @@ -35,13 +44,29 @@ internal sealed class GlGpuBuffer : IGpuBuffer Residency = description.Residency; _name = GlResourceCommand.CreateName(_gl, $"buffer '{Name}'", _gl.GenBuffer, _gl.DeleteBuffer); - _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name); - unsafe + try { - _gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name); + unsafe + { + _gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, UsageHintFor(Residency)); + } + GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)"); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0); + } + catch + { + // A rejected data store (GL_OUT_OF_MEMORY is a real outcome for the + // mesh arena's 384 MiB growth destination) must not strand the name + // that was already created for it. The caller sees the original + // failure and owns nothing. + GlResourceCommand.DeleteBuffer( + _gl, + _name, + $"rollback buffer '{Name}' after a failed allocation"); + _name = 0; + throw; } - GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)"); - _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0); } public string Name { get; } @@ -52,6 +77,43 @@ internal sealed class GlGpuBuffer : IGpuBuffer /// The physical GL buffer name. For bind calls issued by . internal uint GlName => _name; + private static BufferUsageARB UsageHintFor(GpuMemoryResidency residency) => + residency == GpuMemoryResidency.DeviceLocal + ? BufferUsageARB.StaticDraw + : BufferUsageARB.DynamicDraw; + + /// + /// Deletes the physical buffer on the calling thread instead of deferring it + /// through the device's retirement queue the way does. + /// + /// Campaign V slice V4b: the mesh arena (GlobalMeshBuffer) already gates + /// every arena delete behind its own GpuRetirementLedger and decrements + /// its MaximumPhysicalArenaBytes accounting in the same retirement stage. + /// Routing the physical free through the queue a second time would delay it by + /// a further flight generation, so the arena's physical-capacity accounting + /// would run ahead of real GPU residency and could admit a migration that + /// breaches the 896 MiB dual-generation ceiling. A caller of this method must + /// therefore already have proved no submitted frame can still reference the + /// buffer. + /// + /// Retryable by construction: the managed name is cleared only after the driver + /// reports success, so a failed delete is re-issued by the next attempt, and any + /// later call (including ) is a no-op. + /// + internal void DeleteRetired(string context) + { + uint name = _name; + if (name == 0) + return; + + _gl.DeleteBuffer(name); + // Per the GL error contract a command which generates an error does not + // change object state, so validation stays in the same stage as the + // mutation and the name is only surrendered once deletion committed. + GLHelpers.ThrowOnResourceError(_gl, context); + _name = 0; + } + public void Upload(long offsetBytes, ReadOnlySpan data) { ThrowIfDisposed(); diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 76ee41da..10e76490 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -200,6 +200,7 @@ public static class RenderBootstrap var wbLogger = NullLogger.Instance; var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets( gl, + gpuDevice, dats, wbLogger, frameFlights); diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index ba074462..4827e12a 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -1,7 +1,10 @@ +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; @@ -23,7 +26,7 @@ internal readonly record struct GlobalMeshMaintenanceStep( bool Completed); /// -/// Retains the staged GL name and its exact release cursor while an aborted +/// 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. /// @@ -32,19 +35,18 @@ internal sealed class GlobalMeshMigrationAbortTicket private readonly RetryableGpuResourceRelease _release; public GlobalMeshMigrationAbortTicket( - uint buffer, + IGpuBuffer buffer, long capacityBytes, RetryableGpuResourceRelease release) { - if (buffer == 0) - throw new ArgumentOutOfRangeException(nameof(buffer)); + ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); Buffer = buffer; CapacityBytes = capacityBytes; _release = release ?? throw new ArgumentNullException(nameof(release)); } - public uint Buffer { get; } + public IGpuBuffer Buffer { get; } public long CapacityBytes { get; } public bool IsComplete => _release.IsComplete; @@ -72,6 +74,19 @@ internal enum GlobalMeshCapacityResult /// Shared modern-rendering vertex/index buffers with reclaimable ranges. /// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges /// when its zero-reference LRU entry is evicted. +/// +/// Campaign V slice V4b moved the two backing stores onto +/// : allocation is , +/// mesh upload is , and the grow-and-copy +/// migration is — a device-side copy the Vulkan +/// backend implements with vkCmdCopyBuffer. The reclaimable-range +/// allocator, growth quanta, budgeted incremental migration, retirement-ledger +/// gating and the dual-generation physical ceiling are unchanged; only the +/// resource handle type moved. The vertex array object stays raw GL because a +/// VAO has no RHI equivalent (Vulkan bakes vertex input into the pipeline) and +/// WbDrawDispatcher, EnvCellRenderer and ParticleRenderer +/// still bind // directly +/// until slice V4c moves them onto the pass encoder. /// public sealed class GlobalMeshBuffer : IDisposable { @@ -90,16 +105,41 @@ public sealed class GlobalMeshBuffer : IDisposable 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. Slice V4c retires this field with the raw-GL + // dispatcher. private readonly GL _gl; + private readonly IGpuDevice _device; private readonly GpuRetirementLedger _retirementLedger; private readonly GpuRetiredRangeAllocator _vertices; private readonly GpuRetiredRangeAllocator _indices; + private IGpuBuffer? _vertexBuffer; + private IGpuBuffer? _indexBuffer; private BufferMigration? _migration; private GlobalMeshMigrationAbortTicket? _migrationAbort; private long _retiredCapacityBytes; + private int _storeGeneration; private bool _disposed; private RetryableResourceReleaseLedger? _disposeResources; + private static IGpuBuffer RequireStore(IGpuBuffer? store) => + store ?? throw new InvalidOperationException( + "The global mesh arena has no live backing store."); + + /// + /// Campaign V slice V4b transitional bridge. The arena owns its stores as + /// , but its consumers — the vertex array object here, + /// and WbDrawDispatcher/EnvCellRenderer/ParticleRenderer + /// through / — are still raw GL until slice + /// V4c. This is the only place that reaches through the interface, and it + /// disappears with those consumers. + /// + private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer) => + buffer as GlGpuBuffer + ?? throw new NotSupportedException( + "The global mesh arena requires a GL-backed buffer while its draw paths " + + "still bind raw GL names (Campaign V slice V4c retires that requirement)."); + private enum BufferKind { Vertices, @@ -108,8 +148,8 @@ public sealed class GlobalMeshBuffer : IDisposable private sealed record BufferMigration( BufferKind Kind, - uint OldBuffer, - uint NewBuffer, + IGpuBuffer OldBuffer, + IGpuBuffer NewBuffer, int OldCapacity, int NewCapacity, long OldCapacityBytes, @@ -120,8 +160,17 @@ public sealed class GlobalMeshBuffer : IDisposable } public uint VAO { get; private set; } - public uint VBO { get; private set; } - public uint IBO { get; private set; } + + /// + /// The vertex store's raw GL name. Transitional: the modern draw paths still + /// bind the arena themselves until Campaign V slice V4c hands them the pass + /// encoder, so the arena keeps publishing the backend name of the buffer it + /// now owns as an . + /// + public uint VBO => _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName; + + /// The index store's raw GL name. See . + public uint IBO => _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName; internal long UploadCount { get; private set; } internal long UploadedBytes { get; private set; } internal long CapacityBytes => @@ -193,14 +242,10 @@ public sealed class GlobalMeshBuffer : IDisposable newBuffers); } - public GlobalMeshBuffer(GL gl) - : this(gl, ImmediateGpuResourceRetirementQueue.Instance) - { - } - - internal GlobalMeshBuffer(GL gl, IGpuResourceRetirementQueue retirement) + internal GlobalMeshBuffer(GL gl, IGpuDevice device, IGpuResourceRetirementQueue retirement) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _device = device ?? throw new ArgumentNullException(nameof(device)); ArgumentNullException.ThrowIfNull(retirement); _retirementLedger = new GpuRetirementLedger(retirement); _vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB @@ -208,11 +253,27 @@ public sealed class GlobalMeshBuffer : IDisposable InitBuffers(); } + /// + /// The mesh arena is simultaneously a draw source and both ends of the + /// grow-and-copy migration, which is why is a + /// flags enum: Vulkan must name every usage at creation time. + /// + private static GpuBufferDescription DescribeStore(BufferKind kind, long sizeBytes, int generation) => + new( + kind == BufferKind.Vertices + ? $"mesh-arena-vertex-{generation}" + : $"mesh-arena-index-{generation}", + sizeBytes, + (kind == BufferKind.Vertices ? GpuBufferUsage.Vertex : GpuBufferUsage.Index) + | GpuBufferUsage.TransferSource + | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal); + private unsafe void InitBuffers() { uint vao = 0; - uint vbo = 0; - uint ibo = 0; + IGpuBuffer? vbo = null; + IGpuBuffer? ibo = null; long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size; long indexBytes = (long)_indices.Capacity * sizeof(ushort); bool vaoTracked = false; @@ -222,18 +283,16 @@ public sealed class GlobalMeshBuffer : IDisposable try { _gl.GenVertexArrays(1, out vao); - _gl.GenBuffers(1, out vbo); - _gl.GenBuffers(1, out ibo); - if (vao == 0 || vbo == 0 || ibo == 0) + 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)); _gl.BindVertexArray(vao); - _gl.BindBuffer(GLEnum.ArrayBuffer, vbo); - _gl.BufferData(GLEnum.ArrayBuffer, ToNativeSize(vertexBytes), null, GLEnum.StaticDraw); + _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName); ConfigureVertexAttributes(); - _gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); - _gl.BufferData(GLEnum.ElementArrayBuffer, ToNativeSize(indexBytes), null, GLEnum.StaticDraw); + _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName); GLHelpers.ThrowOnResourceError( _gl, $"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)"); @@ -248,13 +307,19 @@ public sealed class GlobalMeshBuffer : IDisposable indexTracked = true; VAO = vao; - VBO = vbo; - IBO = ibo; + _vertexBuffer = vbo; + _indexBuffer = ibo; } catch { - if (ibo != 0) _gl.DeleteBuffer(ibo); - if (vbo != 0) _gl.DeleteBuffer(vbo); + // 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"); + if (vbo is GlGpuBuffer stagedVertexStore) + stagedVertexStore.DeleteRetired("rolling back the global vertex arena buffer"); if (vao != 0) _gl.DeleteVertexArray(vao); if (indexTracked) { @@ -287,7 +352,7 @@ public sealed class GlobalMeshBuffer : IDisposable _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); } - internal unsafe GlobalMeshAllocation UploadMesh( + internal GlobalMeshAllocation UploadMesh( VertexPositionNormalTexture[] vertices, IReadOnlyList indexBatches) { @@ -326,22 +391,17 @@ public sealed class GlobalMeshBuffer : IDisposable 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); - } + // IGpuBuffer.Upload stages through a neutral binding point of the + // backend's choosing, so a mesh upload can no longer disturb whichever + // vertex array a preceding render pass happened to leave bound — the + // property the old hand-rolled ElementArrayBuffer/CopyWriteBuffer split + // was protecting. + long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size); + RequireStore(_vertexBuffer).Upload( + vertexOffsetBytes, + MemoryMarshal.AsBytes(new ReadOnlySpan(vertices))); - // 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); + IGpuBuffer indexStore = RequireStore(_indexBuffer); int indexOffset = indexRange.Offset; for (int i = 0; i < indexBatches.Count; i++) { @@ -349,22 +409,13 @@ public sealed class GlobalMeshBuffer : IDisposable 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); - } + long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); + indexStore.Upload( + indexOffsetBytes, + MemoryMarshal.AsBytes(new ReadOnlySpan(batch))); indexOffset = checked(indexOffset + batch.Length); } } - GLHelpers.ThrowOnResourceError( - _gl, - $"uploading global mesh ({vertices.Length} vertices, {totalIndices} indices)"); } catch { @@ -603,18 +654,13 @@ public sealed class GlobalMeshBuffer : IDisposable { 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}"); + // 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); } @@ -704,33 +750,18 @@ public sealed class GlobalMeshBuffer : IDisposable return Math.Min(totalBytes - copiedBytes, maximumCopyBytes); } - private unsafe void BeginMigration(BufferKind kind, int newCapacity, long copyBytes) + 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; - uint oldBuffer = kind == BufferKind.Vertices ? VBO : IBO; + IGpuBuffer oldBuffer = RequireStore( + kind == BufferKind.Vertices ? _vertexBuffer : _indexBuffer); 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; - } + IGpuBuffer newBuffer = _device.CreateBuffer( + DescribeStore(kind, newBytes, checked(++_storeGeneration))); GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer); @@ -752,12 +783,12 @@ public sealed class GlobalMeshBuffer : IDisposable _gl.BindVertexArray(VAO); if (migration.Kind == BufferKind.Vertices) { - _gl.BindBuffer(GLEnum.ArrayBuffer, migration.NewBuffer); + _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); ConfigureVertexAttributes(); } else { - _gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.NewBuffer); + _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); } GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer"); } @@ -766,12 +797,12 @@ public sealed class GlobalMeshBuffer : IDisposable _gl.BindVertexArray(VAO); if (migration.Kind == BufferKind.Vertices) { - _gl.BindBuffer(GLEnum.ArrayBuffer, migration.OldBuffer); + _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); ConfigureVertexAttributes(); } else { - _gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.OldBuffer); + _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); } _gl.BindVertexArray(0); throw; @@ -783,7 +814,7 @@ public sealed class GlobalMeshBuffer : IDisposable if (migration.Kind == BufferKind.Vertices) { - VBO = migration.NewBuffer; + _vertexBuffer = migration.NewBuffer; if (migration.NewCapacity > migration.OldCapacity) _vertices.Grow(migration.NewCapacity); else @@ -791,7 +822,7 @@ public sealed class GlobalMeshBuffer : IDisposable } else { - IBO = migration.NewBuffer; + _indexBuffer = migration.NewBuffer; if (migration.NewCapacity > migration.OldCapacity) _indices.Grow(migration.NewCapacity); else @@ -801,11 +832,10 @@ public sealed class GlobalMeshBuffer : IDisposable _migration = null; _retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes); RetryableGpuResourceRelease oldBufferRelease = - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, + CreateRetryableStoreDeletion( migration.OldBuffer, migration.OldCapacityBytes, - $"retiring replaced global {migration.Kind} arena buffer {migration.OldBuffer}"); + $"retiring replaced global {migration.Kind} arena buffer '{migration.OldBuffer.Name}'"); _retirementLedger.Retire(new RetryableGpuResourceRelease( oldBufferRelease.Run, () => _retiredCapacityBytes = checked( @@ -819,14 +849,41 @@ public sealed class GlobalMeshBuffer : IDisposable _migrationAbort ??= new GlobalMeshMigrationAbortTicket( migration.NewBuffer, migration.NewCapacityBytes, - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, + CreateRetryableStoreDeletion( migration.NewBuffer, migration.NewCapacityBytes, - $"aborting staged global {migration.Kind} arena buffer {migration.NewBuffer}")); + $"aborting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'")); RetryPendingMigrationAbort(); } + /// + /// The arena's own flight gate — and the abort + /// ticket — already proves no submitted frame can reference the store, so the + /// physical delete runs here rather than being deferred a second time by + /// . Stages match + /// TrackedGlResource.CreateRetryableBufferDeletion exactly: precondition, + /// mutation-with-validation, byte accounting, then resource-count accounting, + /// so a driver failure re-issues only the delete and never double-counts. + /// + private RetryableGpuResourceRelease CreateRetryableStoreDeletion( + IGpuBuffer buffer, + long capacityBytes, + string context) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); + GL gl = _gl; + return new RetryableGpuResourceRelease( + () => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"), + () => RequireGlBuffer(buffer).DeleteRetired(context), + () => + { + if (capacityBytes != 0) + GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer); + }, + () => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer)); + } + private void RetryPendingMigrationAbort() { GlobalMeshMigrationAbortTicket? ticket = _migrationAbort; @@ -844,7 +901,7 @@ public sealed class GlobalMeshBuffer : IDisposable BufferMigration migration = _migration ?? throw new InvalidOperationException( "A staged-buffer abort ticket outlived its migration record."); - if (migration.NewBuffer != ticket.Buffer + if (!ReferenceEquals(migration.NewBuffer, ticket.Buffer) || migration.NewCapacityBytes != ticket.CapacityBytes) { throw new InvalidOperationException( @@ -874,21 +931,11 @@ public sealed class GlobalMeshBuffer : IDisposable 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); - } + // 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() { @@ -903,11 +950,10 @@ public sealed class GlobalMeshBuffer : IDisposable if (_migration is { } migration) { RetryableGpuResourceRelease release = - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, + CreateRetryableStoreDeletion( migration.NewBuffer, migration.NewCapacityBytes, - $"deleting staged global {migration.Kind} arena buffer {migration.NewBuffer}"); + $"deleting staged global {migration.Kind} arena buffer '{migration.NewBuffer.Name}'"); releases.Add(("staged-migration-buffer", release.Run)); } @@ -921,24 +967,22 @@ public sealed class GlobalMeshBuffer : IDisposable GlobalMeshVaoAccounting.TrackDeallocation); releases.Add(("global-vao", release.Run)); } - if (VBO != 0) + if (_vertexBuffer is { } vertexStore) { RetryableGpuResourceRelease release = - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, - VBO, + CreateRetryableStoreDeletion( + vertexStore, (long)_vertices.Capacity * VertexPositionNormalTexture.Size, - $"deleting global mesh vertex buffer {VBO}"); + $"deleting global mesh vertex buffer '{vertexStore.Name}'"); releases.Add(("global-vbo", release.Run)); } - if (IBO != 0) + if (_indexBuffer is { } indexStore) { RetryableGpuResourceRelease release = - TrackedGlResource.CreateRetryableBufferDeletion( - _gl, - IBO, + CreateRetryableStoreDeletion( + indexStore, (long)_indices.Capacity * sizeof(ushort), - $"deleting global mesh index buffer {IBO}"); + $"deleting global mesh index buffer '{indexStore.Name}'"); releases.Add(("global-ibo", release.Run)); } _disposeResources = new RetryableResourceReleaseLedger(releases); @@ -951,7 +995,9 @@ public sealed class GlobalMeshBuffer : IDisposable _migration = null; _migrationAbort = null; - VAO = VBO = IBO = 0; + VAO = 0; + _vertexBuffer = null; + _indexBuffer = null; _disposeResources = null; _disposed = true; diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index d3d97c4f..ae3a42b4 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -406,8 +406,13 @@ namespace AcDream.App.Rendering.Wb public bool IsQueued { get; set; } } - public ObjectMeshManager( + // internal, not public: IGpuDevice is an internal type (the pinned RHI + // contract), and the shared mesh arena is created from it. Every caller + // already lives inside AcDream.App or its InternalsVisibleTo test + // assemblies. + internal ObjectMeshManager( OpenGLGraphicsDevice graphicsDevice, + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IPreparedAssetSource preparedAssets, ILogger logger, ResidencyBudgetOptions? budgets = null) @@ -415,6 +420,7 @@ namespace AcDream.App.Rendering.Wb budgets ??= ResidencyBudgetOptions.Default; _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice)); + ArgumentNullException.ThrowIfNull(gpuDevice); _preparedAssets = preparedAssets ?? throw new ArgumentNullException(nameof(preparedAssets)); _logger = logger @@ -432,6 +438,7 @@ namespace AcDream.App.Rendering.Wb { GlobalBuffer = new GlobalMeshBuffer( _graphicsDevice.GL, + gpuDevice, _graphicsDevice.ResourceRetirement); } } diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 1bc0fd88..d55e2165 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -109,16 +109,28 @@ public sealed class WbMeshAdapter /// Constructs the UI-Studio/tooling WB pipeline. Production composition /// supplies the validated pak source through the internal overload below; /// this explicit tooling seam retains live-DAT extraction. + /// + /// internal, not public, since Campaign V slice V4b: the shared mesh arena + /// allocates its backing stores from IGpuDevice, which is an internal + /// type by the pinned RHI contract. Every caller already lives inside + /// AcDream.App or its InternalsVisibleTo test assemblies. /// /// Active Silk.NET GL context. Must be bound to the current /// thread (construction runs GL queries; call from OnLoad). + /// The one process RHI device. Supplies the mesh + /// arena's vertex/index buffers. /// acdream's shared runtime DAT facade. Tooling uses it /// through an explicitly owned . /// Logger for the adapter; ObjectMeshManager uses /// NullLogger internally. - public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger logger) + internal WbMeshAdapter( + GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, + IDatReaderWriter dats, + ILogger logger) : this( gl, + gpuDevice, dats, preparedAssets: null, logger, @@ -130,11 +142,13 @@ public sealed class WbMeshAdapter internal static WbMeshAdapter CreateWithLiveDatPreparedAssets( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, ILogger logger, AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) => new( gl, + gpuDevice, dats, preparedAssets: null, logger, @@ -144,6 +158,7 @@ public sealed class WbMeshAdapter internal WbMeshAdapter( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, ILogger logger, @@ -151,6 +166,7 @@ public sealed class WbMeshAdapter ResidencyBudgetOptions? budgets = null) : this( gl, + gpuDevice, dats, preparedAssets, logger, @@ -162,6 +178,7 @@ public sealed class WbMeshAdapter private WbMeshAdapter( GL gl, + AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, IPreparedAssetSource? preparedAssets, ILogger logger, @@ -170,6 +187,7 @@ public sealed class WbMeshAdapter ResidencyBudgetOptions budgets) { ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(gpuDevice); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(budgets); @@ -212,6 +230,7 @@ public sealed class WbMeshAdapter // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). meshManager = new ObjectMeshManager( graphicsDevice, + gpuDevice, resolvedPreparedAssets, new ConsoleErrorLogger(), budgets); diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 75aa08bc..4824c96f 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -291,6 +291,7 @@ public sealed class WorldRenderCompositionTests public WbMeshAdapter CreateMeshAdapter( GL gl, + IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement, diff --git a/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs b/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs index 8f20d3d3..9e1243a8 100644 --- a/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs @@ -1,5 +1,7 @@ using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; using Silk.NET.OpenGL; namespace AcDream.App.Tests.Rendering; @@ -224,14 +226,28 @@ public sealed class GpuResourceRetirementTransactionTests Assert.False(device.HasPendingGLWork); } + /// + /// Campaign V slice V4b: the mesh arena's staged store is an + /// rather than a raw GL name, so the abort ticket + /// retains a resource identity. The invariant under test is unchanged — the + /// owner may only forget the migration once every release stage converged. + /// + private static IGpuBuffer StagedArenaBuffer(string name, long sizeBytes) => + new RecordingGpuBuffer(new GpuBufferDescription( + name, + sizeBytes, + GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + [Fact] public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges() { int deleteCalls = 0; int accountingCalls = 0; bool failAccounting = true; + IGpuBuffer staged = StagedArenaBuffer("staged-37", 4096); var ticket = new GlobalMeshMigrationAbortTicket( - buffer: 37, + buffer: staged, capacityBytes: 4096, new RetryableGpuResourceRelease( () => deleteCalls++, @@ -247,7 +263,7 @@ public sealed class GpuResourceRetirementTransactionTests Assert.Throws(ticket.Advance); Assert.False(ticket.IsComplete); - Assert.Equal((uint)37, ticket.Buffer); + Assert.Same(staged, ticket.Buffer); Assert.Equal(4096, ticket.CapacityBytes); Assert.Equal(1, deleteCalls); Assert.Equal(0, accountingCalls); @@ -267,7 +283,7 @@ public sealed class GpuResourceRetirementTransactionTests int accountingCalls = 0; bool failDeleteValidation = true; var ticket = new GlobalMeshMigrationAbortTicket( - buffer: 41, + buffer: StagedArenaBuffer("staged-41", 8192), capacityBytes: 8192, new RetryableGpuResourceRelease( () => diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs index b77c8275..9d2eb7ef 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs @@ -14,7 +14,11 @@ public sealed class WbMeshAdapterTests // We can't pass a real GL (no context in tests), so we verify only the // null-GL guard. The real pipeline is tested via integration. Assert.Throws(() => - new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger.Instance)); + new WbMeshAdapter( + gl: null!, + gpuDevice: null!, + dats: null!, + logger: NullLogger.Instance)); } [Fact]