From fe8abacfc6bad12806b675d9c58ffee8d45222da Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 14:31:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Campaign=20V=20slice=20V6i-3=20?= =?UTF-8?q?commit=201=20=E2=80=94=20the=20mesh=20pipeline's=20upload=20bod?= =?UTF-8?q?ies=20cross=20the=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh pipeline could be CONSTRUCTED without naming a backend. It said plainly what it did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers." This moves them, and gives the interface its second implementation. GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer (V4b); what still needed a context was the vertex array and the attribute pointers, which have no RHI verb because Vulkan bakes vertex input into the pipeline. So a backend with none builds the stores and nothing else, publishes 0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers, named the way a pass encoder binds them. HasStores is the backend-neutral form of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on the context and nothing else does: InitBuffers skips the vertex array, and CommitMigration skips the rebind — on the encoder arm the field swap IS the atomic publication, because the next pass reads whatever the field then holds. The store deletion likewise splits: GL keeps its immediate DeleteRetired, because the arena's own flight gate has already proven no submitted frame can reference the store, while the other arm has no second deferral to skip and Dispose is its retirement-queued release. ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three call sites were one modern-path constructor argument and two bodies whose every GL statement sits inside `if (!_useModernRendering)`. The constructor now hands the arena the nullable context; the two bodies resolve one lazily inside the legacy branch. That branch is unreachable in every shipping configuration — missing bindless or draw-parameters throws at startup under the N.5 ship amendment — so the accessor survives as the guard on dead code rather than as a blocker, and it is deleted with that code. VulkanMeshPipelineDevice is the second implementation, and it is four properties and two no-ops. Two things about it are worth stating rather than leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are GL-shaped because the seam was cut from a GL device, but what they gate is the MODERN path — one shared arena, table texture indexing, multi-draw indirect — which Vulkan supplies unconditionally and the capability gate rejects a device for lacking, so answering false would disable the only path that exists. HasPendingWork answers false because the GL device's queue exists to defer work onto the thread holding the context, and Vulkan resource work is recorded into the frame's command buffer or routed through the retirement queue. WbMeshAdapter selects between them once, in the one place the mesh pipeline still names a backend. The GL arm is unchanged, including the queue-drain guarantee its construction rollback asserts. So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is deleted — it existed for exactly the gap this closes, and the landblock spawn ledger now registers against the real adapter. Streaming's publication into GPU state stops being a no-op there: the Vulkan run below builds real render data, including the [up-null] zero-vertex caching path. Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109 baseline plus the three added here. Strict GL offline pixel gate against 579e0b7f: 4.44e-05 (25 differing pixels of 563,200), inside the documented 9-31 px control band and 22x under the 0.001 threshold. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader (VK_LOADER_DEBUG=layer reports `Insert instance layer "VK_LAYER_KHRONOS_validation"`): zero validation errors, zero warnings, a captured frame, and no [shutdown] diagnostic on either stream. What this does NOT claim: nothing draws the world on Vulkan yet. The three world renderers' submission arms, the two pass executors, and the pass-structure merge are the next commit's. No divergence-register row: no retail-facing behaviour changes. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 6 +- .../Composition/WorldRenderComposition.cs | 21 +- .../Gpu/Vk/VulkanCompositionFramePhases.cs | 34 ---- .../Gpu/Vk/VulkanMeshPipelineDevice.cs | 70 +++++++ .../Rendering/Wb/GlobalMeshBuffer.cs | 189 ++++++++++++------ .../Rendering/Wb/IMeshPipelineDevice.cs | 25 ++- .../Rendering/Wb/ObjectMeshManager.cs | 88 ++++---- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 64 +++--- .../WorldRenderCompositionTests.cs | 2 +- .../Wb/MeshPipelineDeviceSeamTests.cs | 97 ++++++++- 10 files changed, 410 insertions(+), 186 deletions(-) create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanMeshPipelineDevice.cs diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 1e3b460c..13167c41 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -225,8 +225,10 @@ internal sealed class LivePresentationCompositionPhase var componentLifecycle = new DeferredLiveEntityRuntimeComponentLifecycle(); var wbSpawnAdapter = new LandblockSpawnAdapter( - (IWbMeshAdapter?)foundation.MeshAdapter - ?? AcDream.App.Rendering.Gpu.Vk.NullWbMeshAdapter.Instance); + foundation.MeshAdapter + ?? throw new InvalidOperationException( + "The landblock spawn ledger requires the mesh pipeline, which " + + "Campaign V slice V6i-3 made backend-neutral.")); Setup? LoadPreparedSetup(uint sourceId) { if (!content.Dats.TryResolvePreferred( diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index cd603f1a..4745fcf4 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -153,7 +153,7 @@ internal interface IWorldRenderCompositionFactory TerrainAtlas? atlas); Shader CreateMeshShader(GL gl, string shadersDirectory); WbMeshAdapter CreateMeshAdapter( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, @@ -376,7 +376,7 @@ internal sealed class RetailWorldRenderCompositionFactory includeCommonPreamble: true); public WbMeshAdapter CreateMeshAdapter( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, @@ -664,12 +664,16 @@ internal sealed class WorldRenderCompositionPhase WorldRenderCompositionPoint.MeshShaderPublished); if (meshShader is not null) _dependencies.Log("[N.5] mesh_modern shader loaded"); - WbMeshAdapter? meshAdapter = AcquireAndPublishIf( - gl is not null, + // Campaign V slice V6i-3: the mesh pipeline exists on BOTH arms. Its + // upload bodies reached IGpuBuffer, so a backend with no GL context + // builds the same arena, the same atlases and the same render data — + // which is what makes streaming's publication into GPU state real + // there rather than a no-op. + WbMeshAdapter meshAdapter = AcquireAndPublish( scope, "WB mesh adapter", () => _factory.CreateMeshAdapter( - gl!, + gl, _dependencies.GpuDevice, content.Dats, content.PreparedAssets, @@ -707,11 +711,8 @@ internal sealed class WorldRenderCompositionPhase scope.Complete(); _dependencies.Log( - meshAdapter is not null - ? "[N.4+N.5] WB foundation + modern path active — " + - "routing all content through ObjectMeshManager." - : "[V6h] Vulkan composition host — RHI foundation active " + - "(retained UI, text, debug lines); no world renderers."); + "[N.4+N.5] WB foundation + modern path active — " + + "routing all content through ObjectMeshManager."); return new WorldRenderResult( terrainBuild, new WorldRenderFoundation( diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs index 2c785047..92e9e907 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs @@ -131,40 +131,6 @@ internal sealed class NullRenderFrameGpuMeasurement : IRenderFrameGpuMeasurement } } -/// -/// Campaign V slice V6h: the mesh backend on a backend that has none. -/// -/// WbMeshAdapter owns an OpenGLGraphicsDevice, so it is not -/// constructible on Vulkan until slice V4t. The landblock spawn ledger and the -/// world state that drives it are backend-neutral and must keep running — they -/// are how streaming residence is tracked — so they register against this -/// instead. Reference counting is a no-op because there is nothing to count, -/// and answers true because a mesh that is never -/// going to be drawn is never pending. -/// -internal sealed class NullWbMeshAdapter : AcDream.App.Rendering.Wb.IWbMeshAdapter -{ - public static NullWbMeshAdapter Instance { get; } = new(); - - private NullWbMeshAdapter() - { - } - - public void IncrementRefCount(ulong id) - { - } - - public void DecrementRefCount(ulong id) - { - } - - public void PinPreparedRenderData(ulong id) - { - } - - public bool IsRenderDataReady(ulong id) => true; -} - /// /// Campaign V slice V6h: the portal viewport on a backend with no portal tunnel. /// diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanMeshPipelineDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanMeshPipelineDevice.cs new file mode 100644 index 00000000..ac53ac5f --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanMeshPipelineDevice.cs @@ -0,0 +1,70 @@ +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6i-3: the mesh pipeline's device on a backend with no GL +/// context. +/// +/// V6i-2 measured the whole dependency and expressed it as +/// — a GL context, the retirement queue, the +/// shared instance VBO, and two capability flags — but there was nothing to +/// select between, because the pipeline's upload bodies still spoke GL. This +/// slice moved those bodies: the mesh arena is IGpuBuffer work on both +/// arms, and the only raw-GL upload left is the per-mesh vertex-array +/// construction the N.5 ship amendment made unreachable. So the second +/// implementation is this, and it is four properties and two no-ops. +/// +/// Why the two capability flags answer true. Their names are +/// GL-shaped because the seam was cut from a GL device, but what they gate is +/// the MODERN path — one shared arena, bindless/table texture indexing, and +/// multi-draw indirect. Vulkan supplies all three unconditionally, and the +/// capability gate (§4.11) rejects a device that cannot before composition +/// runs, so answering false here would disable the only path that exists. +/// +/// Why the instance VBO is 0. It is the legacy per-instance +/// attribute buffer the pre-modern draw path bound, which the modern path never +/// reads. Publishing 0 is the same value GlobalMeshBuffer publishes for +/// its own raw names here, and for the same reason. +/// +internal sealed class VulkanMeshPipelineDevice : IMeshPipelineDevice +{ + public VulkanMeshPipelineDevice(IGpuResourceRetirementQueue resourceRetirement) + { + ResourceRetirement = resourceRetirement + ?? throw new ArgumentNullException(nameof(resourceRetirement)); + } + + /// + public GL? Gl => null; + + /// + public IGpuResourceRetirementQueue ResourceRetirement { get; } + + /// + public uint InstanceVBO => 0; + + /// + public bool HasBindless => true; + + /// + public bool HasOpenGL43 => true; + + /// + /// Always false. The GL device's queue exists to defer work that must run on + /// the thread holding the context; Vulkan resource work is recorded into the + /// frame's command buffer or routed through the retirement queue, so there + /// is no second deferral to drain. + /// + public bool HasPendingWork => false; + + /// + public void ProcessQueue() + { + } + + public void Dispose() + { + } +} diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 4827e12a..8138e068 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -86,7 +86,15 @@ internal enum GlobalMeshCapacityResult /// 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. +/// on the GL arm. +/// +/// Campaign V slice V6i-3 made the GL context optional. A backend that has +/// none builds no vertex array and publishes no raw names — , +/// and are 0 there — and its consumers bind +/// and through the pass +/// encoder instead, which is the same 32-byte position/normal/texcoord layout +/// expressed as pipeline vertex input. Everything above the handle — the +/// allocator, the migration, the ledger — is one body on both arms. /// public sealed class GlobalMeshBuffer : IDisposable { @@ -106,9 +114,9 @@ public sealed class GlobalMeshBuffer : IDisposable (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; + // the RHI has no verb for, and null on a backend with no such object. It is + // retired with the raw-GL dispatcher. + private readonly GL? _gl; private readonly IGpuDevice _device; private readonly GpuRetirementLedger _retirementLedger; private readonly GpuRetiredRangeAllocator _vertices; @@ -162,15 +170,33 @@ public sealed class GlobalMeshBuffer : IDisposable public uint VAO { 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 . + /// The vertex store's raw GL name, or 0 on a backend with no GL context. + /// Transitional: the GL draw paths still bind the arena themselves, so the + /// arena keeps publishing the backend name of the buffer it now owns as an + /// . /// - public uint VBO => _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName; + public uint VBO => + _gl is null || _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; + public uint IBO => + _gl is null || _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName; + + /// + /// The vertex store as the contract's own handle. This is what a pass + /// encoder binds, and it is live on both arms — is the + /// GL-only expression of the same thing. + /// + internal IGpuBuffer? VertexStore => _vertexBuffer; + + /// The index store as the contract's own handle. See . + internal IGpuBuffer? IndexStore => _indexBuffer; + + /// + /// True once both backing stores exist. The backend-neutral form of the + /// VAO != 0 readiness test the raw-GL draw paths make. + /// + internal bool HasStores => _vertexBuffer is not null && _indexBuffer is not null; internal long UploadCount { get; private set; } internal long UploadedBytes { get; private set; } internal long CapacityBytes => @@ -242,9 +268,9 @@ public sealed class GlobalMeshBuffer : IDisposable newBuffers); } - internal GlobalMeshBuffer(GL gl, IGpuDevice device, IGpuResourceRetirementQueue retirement) + internal GlobalMeshBuffer(GL? gl, IGpuDevice device, IGpuResourceRetirementQueue retirement) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _gl = gl; _device = device ?? throw new ArgumentNullException(nameof(device)); ArgumentNullException.ThrowIfNull(retirement); _retirementLedger = new GpuRetirementLedger(retirement); @@ -282,23 +308,34 @@ public sealed class GlobalMeshBuffer : IDisposable try { - _gl.GenVertexArrays(1, out vao); - if (vao == 0) - throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects."); + // The vertex array is the one object here with no RHI equivalent — + // Vulkan bakes vertex input into the pipeline — so a backend with no + // GL context builds the two stores and nothing else. + if (_gl is { } gl) + { + gl.GenVertexArrays(1, out vao); + if (vao == 0) + throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects."); + } + vbo = _device.CreateBuffer(DescribeStore(BufferKind.Vertices, vertexBytes, _storeGeneration)); ibo = _device.CreateBuffer(DescribeStore(BufferKind.Indices, indexBytes, _storeGeneration)); - _gl.BindVertexArray(vao); - _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName); - ConfigureVertexAttributes(); + if (_gl is { } glBind) + { + glBind.BindVertexArray(vao); + glBind.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName); + ConfigureVertexAttributes(glBind); - _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName); - GLHelpers.ThrowOnResourceError( - _gl, - $"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)"); + glBind.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName); + GLHelpers.ThrowOnResourceError( + glBind, + $"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)"); + + GlobalMeshVaoAccounting.TrackAllocation(); + vaoTracked = true; + } - GlobalMeshVaoAccounting.TrackAllocation(); - vaoTracked = true; GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer); vertexTracked = true; @@ -318,9 +355,13 @@ public sealed class GlobalMeshBuffer : IDisposable // cast failure that masks the original construction exception. if (ibo is GlGpuBuffer stagedIndexStore) stagedIndexStore.DeleteRetired("rolling back the global index arena buffer"); + else + ibo?.Dispose(); if (vbo is GlGpuBuffer stagedVertexStore) stagedVertexStore.DeleteRetired("rolling back the global vertex arena buffer"); - if (vao != 0) _gl.DeleteVertexArray(vao); + else + vbo?.Dispose(); + if (vao != 0) _gl!.DeleteVertexArray(vao); if (indexTracked) { GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer); @@ -337,19 +378,19 @@ public sealed class GlobalMeshBuffer : IDisposable } finally { - _gl.BindVertexArray(0); + _gl?.BindVertexArray(0); } } - private unsafe void ConfigureVertexAttributes() + private static unsafe void ConfigureVertexAttributes(GL gl) { int stride = VertexPositionNormalTexture.Size; - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + gl.EnableVertexAttribArray(2); + gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); } internal GlobalMeshAllocation UploadMesh( @@ -778,38 +819,46 @@ public sealed class GlobalMeshBuffer : IDisposable private void CommitMigration(BufferMigration migration) { - try + // The atomic publication step is a VAO rebind on GL and nothing at all + // on a backend whose vertex source is a per-draw encoder bind: the field + // swap below IS the publication there, and the next pass reads the new + // store. The rollback arm exists for the same reason it did — a failed + // rebind must leave the vertex array pointing at the live store. + if (_gl is { } gl) { - _gl.BindVertexArray(VAO); - if (migration.Kind == BufferKind.Vertices) + try { - _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); - ConfigureVertexAttributes(); + gl.BindVertexArray(VAO); + if (migration.Kind == BufferKind.Vertices) + { + gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); + ConfigureVertexAttributes(gl); + } + else + { + gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); + } + GLHelpers.ThrowOnResourceError(gl, $"publishing staged {migration.Kind} arena buffer"); } - else + catch { - _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName); + gl.BindVertexArray(VAO); + if (migration.Kind == BufferKind.Vertices) + { + gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); + ConfigureVertexAttributes(gl); + } + else + { + gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); + } + gl.BindVertexArray(0); + throw; } - GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer"); - } - catch - { - _gl.BindVertexArray(VAO); - if (migration.Kind == BufferKind.Vertices) + finally { - _gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); - ConfigureVertexAttributes(); + gl.BindVertexArray(0); } - else - { - _gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName); - } - _gl.BindVertexArray(0); - throw; - } - finally - { - _gl.BindVertexArray(0); } if (migration.Kind == BufferKind.Vertices) @@ -872,10 +921,24 @@ public sealed class GlobalMeshBuffer : IDisposable { ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); - GL gl = _gl; + GL? gl = _gl; return new RetryableGpuResourceRelease( - () => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"), - () => RequireGlBuffer(buffer).DeleteRetired(context), + () => + { + if (gl is not null) + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + }, + // On GL the delete runs here rather than through IGpuBuffer.Dispose + // because the arena's own flight gate has already proven no submitted + // frame can reference the store. A backend with no GL context has no + // second deferral to skip: Dispose IS its retirement-queued release. + () => + { + if (gl is not null) + RequireGlBuffer(buffer).DeleteRetired(context); + else + buffer.Dispose(); + }, () => { if (capacityBytes != 0) @@ -957,11 +1020,11 @@ public sealed class GlobalMeshBuffer : IDisposable releases.Add(("staged-migration-buffer", release.Run)); } - if (VAO != 0) + if (VAO != 0 && _gl is { } vaoGl) { RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableVertexArrayDeletion( - _gl, + vaoGl, VAO, $"deleting global mesh vertex array {VAO}", GlobalMeshVaoAccounting.TrackDeallocation); diff --git a/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs index 12a43144..0b60bd74 100644 --- a/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs +++ b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs @@ -9,7 +9,7 @@ namespace AcDream.App.Rendering.Wb; /// /// Plan §5.5.10 recorded the blocker plainly: "WbMeshAdapter owns an /// OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is -/// why NullWbMeshAdapter exists at all. §5.5.12 item 6 then MEASURED how +/// why a null mesh adapter had to exist at all. §5.5.12 item 6 then MEASURED how /// wide that dependency really is, and the answer is this: a GL context, the /// retirement queue, the shared instance VBO, and two capability flags. Seven /// members out of a 760-line class. @@ -21,20 +21,23 @@ namespace AcDream.App.Rendering.Wb; /// backend, which is the prerequisite for the slice that gives them a second /// implementation. /// -/// What it does not yet buy, stated plainly. is -/// still a GL type, because the mesh pipeline's upload bodies are still raw GL — -/// GlobalMeshBuffer, the VAO/IBO construction, and the layer transfers all -/// speak it directly. Those bodies are the pass-structure work items 3–5 of -/// §5.5.12's remainder list own. This slice removes the TYPE-level blocker and -/// names the rest; it does not claim the mesh pipeline runs on Vulkan today, and -/// being nullable is what will make the remaining sites fail -/// loudly rather than silently when that arm is written. +/// What slice V6i-3 then moved. V6i-2 left the upload bodies raw — +/// GlobalMeshBuffer's vertex array, the arena's publication step, and the +/// per-mesh VAO/VBO/IBO construction — so the pipeline could be CONSTRUCTED off +/// GL but not RUN. The arena now builds its stores through +/// IGpuDevice.CreateBuffer and publishes them as +/// GlobalMeshBuffer.VertexStore/IndexStore, which a pass encoder +/// binds; the vertex array is built only where one exists. What still reads +/// is the LEGACY per-mesh upload the N.5 ship amendment made +/// unreachable, and AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice +/// is the second implementation this interface was cut for. /// internal interface IMeshPipelineDevice : IDisposable { /// - /// The GL context, or null on a backend that has none. Every reader is a - /// raw-GL upload body awaiting its own port. + /// The GL context, or null on a backend that has none. Since slice V6i-3 the + /// only readers are the legacy per-mesh upload bodies the mandatory modern + /// path never reaches, plus the mesh arena's vertex array. /// GL? Gl { get; } diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index c649370e..4c47d8e9 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -1,4 +1,4 @@ -using Chorizite.Core.Lib; +using Chorizite.Core.Lib; using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; using DatReaderWriter.DBObjs; @@ -125,16 +125,25 @@ namespace AcDream.App.Rendering.Wb private readonly IMeshPipelineDevice _graphicsDevice; /// - /// The GL context this class's still-raw upload bodies write through. - /// Null only on a backend with none, where every one of those bodies is - /// a programming error rather than a runtime condition — the slice that - /// ports them owns deleting this accessor. + /// The GL context the LEGACY (pre-modern-path) upload bodies write + /// through. + /// + /// Campaign V slice V6i-3 narrowed what still needs it. The modern + /// path's arena upload is 's, and that is + /// now work on both + /// arms; what remains raw is the per-mesh VAO/VBO/IBO construction the + /// N.5 ship amendment made unreachable — missing bindless or + /// draw-parameters throws at startup, so _useModernRendering is + /// true in every shipping configuration. The accessor therefore survives + /// as the guard on genuinely dead code rather than as a blocker, and it + /// is deleted with that code. /// private GL RequireGl() => _graphicsDevice.Gl ?? throw new InvalidOperationException( - "The mesh pipeline's upload bodies are still raw GL and this device has no " - + "context. Campaign V's world-draw slice owns porting them."); + "The mesh pipeline's legacy per-mesh vertex-array upload is raw GL and this " + + "device has no context. The modern path is mandatory (N.5 ship amendment), " + + "so reaching this is a composition error rather than a backend gap."); private readonly IPreparedAssetSource _preparedAssets; private readonly ILogger _logger; @@ -522,7 +531,7 @@ namespace AcDream.App.Rendering.Wb if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer( - RequireGl(), + _graphicsDevice.Gl, gpuDevice, _graphicsDevice.ResourceRetirement); } @@ -1984,7 +1993,11 @@ namespace AcDream.App.Rendering.Wb { if (meshData.Vertices.Length == 0) return null; - var gl = RequireGl(); + // Resolved lazily since Campaign V slice V6i-3: every reader below + // is inside a !_useModernRendering branch, and the modern path is + // mandatory, so a backend with no GL context uploads meshes here + // without ever asking for one. + GL? gl = _graphicsDevice.Gl; uint vao = 0, vbo = 0; var modernIndexBatches = meshData.TextureBatches.Values .SelectMany(batches => batches) @@ -2008,40 +2021,41 @@ namespace AcDream.App.Rendering.Wb } else { - gl.GenVertexArrays(1, out vao); - gl.BindVertexArray(vao); + GL legacyGl = RequireGl(); + legacyGl.GenVertexArrays(1, out vao); + legacyGl.BindVertexArray(vao); - gl.GenBuffers(1, out vbo); - gl.BindBuffer(GLEnum.ArrayBuffer, vbo); + legacyGl.GenBuffers(1, out vbo); + legacyGl.BindBuffer(GLEnum.ArrayBuffer, vbo); fixed (VertexPositionNormalTexture* ptr = meshData.Vertices) { - gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); + legacyGl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); } GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); int stride = VertexPositionNormalTexture.Size; // Position (location 0) - gl.EnableVertexAttribArray(0); - gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + legacyGl.EnableVertexAttribArray(0); + legacyGl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); // Normal (location 1) - gl.EnableVertexAttribArray(1); - gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + legacyGl.EnableVertexAttribArray(1); + legacyGl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); // TexCoord (location 2) - gl.EnableVertexAttribArray(2); - gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + legacyGl.EnableVertexAttribArray(2); + legacyGl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); // Instance data (shared VBO) - gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); + legacyGl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); for (uint i = 0; i < 4; i++) { var loc = 3 + i; - gl.EnableVertexAttribArray(loc); - gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); - gl.VertexAttribDivisor(loc, 1); + legacyGl.EnableVertexAttribArray(loc); + legacyGl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); + legacyGl.VertexAttribDivisor(loc, 1); } - gl.EnableVertexAttribArray(8); - gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); - gl.VertexAttribDivisor(8, 1); + legacyGl.EnableVertexAttribArray(8); + legacyGl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); + legacyGl.VertexAttribDivisor(8, 1); } // Allocate the shared vertex/index range before acquiring texture @@ -2120,12 +2134,13 @@ namespace AcDream.App.Rendering.Wb } else { - gl.GenBuffers(1, out ibo); - gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); + GL legacyGl = RequireGl(); + legacyGl.GenBuffers(1, out ibo); + legacyGl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); var indexArray = batch.Indices.ToArray(); fixed (ushort* iptr = indexArray) { - gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); + legacyGl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); } GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer); legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort))); @@ -2200,7 +2215,7 @@ namespace AcDream.App.Rendering.Wb if (!_useModernRendering) { - gl.BindVertexArray(0); + RequireGl().BindVertexArray(0); } return renderData; } @@ -2234,7 +2249,7 @@ namespace AcDream.App.Rendering.Wb private RetryableResourceReleaseLedger CreateUploadRollback( ObjectMeshData meshData, - GL gl, + GL? gl, uint vao, uint vbo, GlobalMeshAllocation? globalAllocation, @@ -2269,12 +2284,13 @@ namespace AcDream.App.Rendering.Wb if (!_useModernRendering) { + GL legacyGl = gl ?? RequireGl(); for (int i = 0; i < legacyIndexBuffers.Count; i++) { int bufferIndex = i; releases.Add(( $"legacy-index-buffer-{bufferIndex}-delete", - () => gl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name))); + () => legacyGl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name))); releases.Add(( $"legacy-index-buffer-{bufferIndex}-accounting", () => GpuMemoryTracker.TrackDeallocation( @@ -2284,7 +2300,7 @@ namespace AcDream.App.Rendering.Wb if (vbo != 0) { - releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(vbo))); + releases.Add(("legacy-vertex-buffer-delete", () => legacyGl.DeleteBuffer(vbo))); releases.Add(( "legacy-vertex-buffer-accounting", () => GpuMemoryTracker.TrackDeallocation( @@ -2292,7 +2308,7 @@ namespace AcDream.App.Rendering.Wb GpuResourceType.Buffer))); } if (vao != 0) - releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(vao))); + releases.Add(("legacy-vertex-array-delete", () => legacyGl.DeleteVertexArray(vao))); } return new RetryableResourceReleaseLedger(releases); @@ -2431,7 +2447,6 @@ namespace AcDream.App.Rendering.Wb return null; var releases = new List<(string Name, Action Release)>(); - GL gl = RequireGl(); if (_useModernRendering) { if (data.GlobalAllocation is { } allocation) @@ -2446,6 +2461,7 @@ namespace AcDream.App.Rendering.Wb } else { + GL gl = RequireGl(); if (data.VAO != 0) releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(data.VAO))); if (data.VBO != 0) diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 075b2ddd..04ca35f3 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using AcDream.Content; using AcDream.App.Rendering.Residency; @@ -124,7 +124,7 @@ public sealed class WbMeshAdapter /// Logger for the adapter; ObjectMeshManager uses /// NullLogger internally. internal WbMeshAdapter( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, ILogger logger) @@ -141,7 +141,7 @@ public sealed class WbMeshAdapter } internal static WbMeshAdapter CreateWithLiveDatPreparedAssets( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, ILogger logger, @@ -157,7 +157,7 @@ public sealed class WbMeshAdapter ResidencyBudgetOptions.Default); internal WbMeshAdapter( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, @@ -177,7 +177,7 @@ public sealed class WbMeshAdapter } private WbMeshAdapter( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice, IDatReaderWriter dats, IPreparedAssetSource? preparedAssets, @@ -186,7 +186,6 @@ public sealed class WbMeshAdapter bool ownsPreparedAssets, ResidencyBudgetOptions budgets) { - ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gpuDevice); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); @@ -194,29 +193,46 @@ public sealed class WbMeshAdapter _resourceRetirement = resourceRetirement; var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); - OpenGLGraphicsDevice? graphicsDevice = null; + IMeshPipelineDevice? graphicsDevice = null; IPreparedAssetSource? resolvedPreparedAssets = preparedAssets; ObjectMeshManager? meshManager = null; try { - graphicsDevice = new OpenGLGraphicsDevice( - gl, - logger, - new DebugRenderSettings(), - resourceRetirement); - OpenGLGraphicsDevice ownedGraphicsDevice = graphicsDevice; - var graphicsDeviceRelease = new RetryableGpuResourceRelease( - ownedGraphicsDevice.Dispose, - () => - { - ownedGraphicsDevice.ProcessGLQueue(); - if (ownedGraphicsDevice.HasPendingGLWork) + // Campaign V slice V6i-3: WHICH mesh-pipeline device is decided + // here, once, and it is the only place in the mesh pipeline that + // names a backend. The GL arm is unchanged — same construction, + // same queue-drain guarantee on rollback. A backend with no context + // gets the RHI arm, whose queue is empty by construction because + // Vulkan resource work is recorded or retirement-queued rather than + // deferred onto a context-owning thread. + if (gl is { } context) + { + var openGl = new OpenGLGraphicsDevice( + context, + logger, + new DebugRenderSettings(), + resourceRetirement); + graphicsDevice = openGl; + var graphicsDeviceRelease = new RetryableGpuResourceRelease( + openGl.Dispose, + () => { - throw new InvalidOperationException( - "WB graphics-device construction cleanup still has queued GL work."); - } - }); - resources.Add("WB graphics device", graphicsDeviceRelease.Run); + openGl.ProcessGLQueue(); + if (openGl.HasPendingGLWork) + { + throw new InvalidOperationException( + "WB graphics-device construction cleanup still has queued GL work."); + } + }); + resources.Add("WB graphics device", graphicsDeviceRelease.Run); + } + else + { + var rhiDevice = new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice( + resourceRetirement); + graphicsDevice = rhiDevice; + resources.Add("WB graphics device", rhiDevice.Dispose); + } if (resolvedPreparedAssets is null) { resolvedPreparedAssets = new DatPreparedAssetSource( diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 61329e58..0383966d 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -315,7 +315,7 @@ public sealed class WorldRenderCompositionTests Resource("mesh shader"); public WbMeshAdapter CreateMeshAdapter( - GL gl, + GL? gl, IGpuDevice device, IDatReaderWriter dats, IPreparedAssetSource preparedAssets, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs index 6d2fda9b..8894fe81 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs @@ -31,7 +31,9 @@ namespace AcDream.App.Tests.Rendering.Wb; public sealed class MeshPipelineDeviceSeamTests { /// A device with the mesh pipeline's whole surface and no GL behind it. - private sealed class ContextFreeMeshPipelineDevice(IGpuResourceRetirementQueue retirement) + private sealed class ContextFreeMeshPipelineDevice( + IGpuResourceRetirementQueue retirement, + bool modernPath = false) : IMeshPipelineDevice { public GL? Gl => null; @@ -40,9 +42,9 @@ public sealed class MeshPipelineDeviceSeamTests public uint InstanceVBO => 0; - public bool HasBindless => false; + public bool HasBindless => modernPath; - public bool HasOpenGL43 => false; + public bool HasOpenGL43 => modernPath; public bool HasPendingWork => false; @@ -55,9 +57,11 @@ public sealed class MeshPipelineDeviceSeamTests } } - private static ObjectMeshManager Build(RecordingGpuDevice device) => + private static ObjectMeshManager Build( + RecordingGpuDevice device, + bool modernPath = false) => new( - new ContextFreeMeshPipelineDevice(device.Retirement), + new ContextFreeMeshPipelineDevice(device.Retirement, modernPath), device, new NullPreparedAssetSource(), NullLogger.Instance); @@ -184,4 +188,87 @@ public sealed class MeshPipelineDeviceSeamTests // residence accounting keep running on a backend with no world draws. Assert.Equal((0, 0, 0), manager.GetPendingTextureUpdateStats()); } + + /// + /// Campaign V slice V6i-3. V6i-2 could only prove construction, because the + /// arena's own body still spoke GL — a device reporting the modern-path + /// capabilities and no context would have dereferenced a null one. It now + /// builds, and what it publishes is the contract's handle rather than a raw + /// name: no vertex array, two live stores. + /// + [Fact] + public void TheModernArenaBuildsWithoutAGlContext() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device, modernPath: true); + + GlobalMeshBuffer arena = Assert.IsType(manager.GlobalBuffer); + Assert.Equal(0u, arena.VAO); + Assert.Equal(0u, arena.VBO); + Assert.Equal(0u, arena.IBO); + Assert.True(arena.HasStores); + Assert.NotNull(arena.VertexStore); + Assert.NotNull(arena.IndexStore); + } + + /// + /// And it UPLOADS. The vertex and index bytes land in the stores a pass + /// encoder binds, at the offsets the allocator handed out — which is the + /// whole of what a draw needs from this class and the thing V6i-2 could not + /// claim. + /// + [Fact] + public void AMeshUploadsIntoTheArenaWithoutAGlContext() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device, modernPath: true); + GlobalMeshBuffer arena = manager.GlobalBuffer!; + + var vertices = new VertexPositionNormalTexture[3]; + vertices[0].Position = new System.Numerics.Vector3(1f, 2f, 3f); + vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f); + ushort[] indices = [0, 1, 2]; + + GlobalMeshAllocation allocation = arena.UploadMesh(vertices, [indices]); + + Assert.Equal(3, allocation.Vertices.Length); + Assert.Equal(3, allocation.Indices.Length); + Assert.Equal(1, arena.UploadCount); + + Span readback = stackalloc byte[3 * VertexPositionNormalTexture.Size]; + arena.VertexStore!.Read( + (long)allocation.Vertices.Offset * VertexPositionNormalTexture.Size, + readback); + var uploaded = System.Runtime.InteropServices.MemoryMarshal + .Cast(readback); + Assert.Equal(new System.Numerics.Vector3(1f, 2f, 3f), uploaded[0].Position); + Assert.Equal(new System.Numerics.Vector3(7f, 8f, 9f), uploaded[2].Position); + + Span indexBytes = stackalloc byte[3 * sizeof(ushort)]; + arena.IndexStore!.Read((long)allocation.Indices.Offset * sizeof(ushort), indexBytes); + Assert.Equal( + indices, + System.Runtime.InteropServices.MemoryMarshal.Cast(indexBytes).ToArray()); + } + + /// + /// The production Vulkan implementation of the seam, checked against the + /// same surface. Its two capability flags answer true because what they + /// gate is the modern path, which Vulkan supplies unconditionally — see the + /// type's own documentation for why the GL-shaped names survive. + /// + [Fact] + public void TheVulkanMeshPipelineDeviceReportsTheModernPath() + { + using var device = new RecordingGpuDevice(); + using var vulkanDevice = + new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice(device.Retirement); + + Assert.Null(vulkanDevice.Gl); + Assert.True(vulkanDevice.HasBindless); + Assert.True(vulkanDevice.HasOpenGL43); + Assert.False(vulkanDevice.HasPendingWork); + Assert.Equal(0u, vulkanDevice.InstanceVBO); + Assert.Same(device.Retirement, vulkanDevice.ResourceRetirement); + } }