From e946b46f754442678f083b7eac2cd19eb1f2f888 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 20:02:50 +0200 Subject: [PATCH] feat(render): Campaign V slice V4b - move the mesh arena onto IGpuBuffer The shared vertex/index arena is the largest single GPU allocation acdream makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific plan for (campaign doc section 4.3). This slice swaps the resource handle type underneath it and changes nothing else: the reclaimable-range allocator, the growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger gating, the abort ticket, the LRU that drives eviction, and the 896 MiB dual-generation physical ceiling are all untouched. That is deliberate - those are the semantics section 4.3 says the Vulkan arena must mirror exactly, so preserving them is the point of the slice rather than an incidental constraint. What moved: - GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus both transfer usages (the arena is simultaneously a draw source and both ends of its own migration, which is exactly why GpuBufferUsage is a flags enum). - UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload. The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an upload could not mutate whichever VAO a preceding render pass left bound; Upload stages through a neutral binding point of the backend's choosing, so that property now comes for free instead of by hand. - AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live prefix still never round-trips through system memory. - BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in the migration record and the abort ticket instead of raw uint names, so the ticket's identity check is a resource identity rather than a number that goes stale the moment the buffer is deleted. What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and its attribute layout, and VBO/IBO became computed properties that publish the backing GL name of the buffer the arena now owns as an IGpuBuffer. One private RequireGlBuffer helper is the single place that reaches through the interface, and it disappears with those consumers. ObjectMeshManager therefore needed no upload-path change at all - it reads those same three properties. Two decisions worth recording. First, arena deletes do not route through IGpuBuffer.Dispose. The arena already gates every delete behind its own GpuRetirementLedger and decrements its physical-capacity accounting in the same retirement stage; Dispose would defer the physical free through the device queue a second time, so the accounting would run ahead of real GPU residency and could admit a migration that breaches the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have already proved flight safety, and GlobalMeshBuffer composes it into a release whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly - precondition, mutation-with-validation, byte accounting, resource-count accounting - so a driver failure re-issues only the delete and never double-counts. Second, two corrections in the GL backend, both required to keep this port behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the arena has always requested; the host-writable rings and texture table keep DynamicDraw and are unaffected. And a failed allocation now releases the GL name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB growth destination, and the previous code leaked the name on that path. Plumbing: the device reaches the arena through WbMeshAdapter and ObjectMeshManager. Their constructors became internal because IGpuDevice is an internal type by the pinned contract, matching what V4a did for BitmapFont, DebugLineRenderer and TextRenderer; both classes stay public and every caller already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could not supply a device and had no callers. Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed / 3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5 skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200 (fraction 4.44e-05), against a same-commit control captured immediately afterwards of 24 - the change is indistinguishable from capture noise and sits 40x under the 0.001 threshold. An earlier gate run was discarded rather than interpreted: its client log showed real ScrollUp/ScrollDown input reaching the offline window, which zoomed the camera, and a camera-motion difference is not a rendering result. No divergence-register row: this slice changes no retail-facing behaviour. Co-Authored-By: Claude Fable 5 --- .../Composition/WorldRenderComposition.cs | 4 + .../Rendering/Gpu/Gl/GlGpuBuffer.cs | 72 +++- src/AcDream.App/Rendering/RenderBootstrap.cs | 1 + .../Rendering/Wb/GlobalMeshBuffer.cs | 308 ++++++++++-------- .../Rendering/Wb/ObjectMeshManager.cs | 9 +- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 21 +- .../WorldRenderCompositionTests.cs | 1 + .../GpuResourceRetirementTransactionTests.cs | 22 +- .../Rendering/Wb/WbMeshAdapterTests.cs | 6 +- 9 files changed, 302 insertions(+), 142 deletions(-) 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]