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