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); + } }