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 <noreply@anthropic.com>
274 lines
10 KiB
C#
274 lines
10 KiB
C#
using System;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using System.Threading;
|
|
using AcDream.Content;
|
|
using Chorizite.Core.Render.Enums;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: the mesh pipeline no longer names a backend.
|
|
///
|
|
/// <para>Plan §5.5.10 recorded the blocker as a fact about types — "WbMeshAdapter
|
|
/// owns an OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is
|
|
/// why <c>NullWbMeshAdapter</c> exists. §5.5.12 item 6 measured how wide the
|
|
/// dependency really is: a GL context, the retirement queue, the instance VBO,
|
|
/// and two capability flags. This suite proves the interface at that surface is
|
|
/// load-bearing rather than cosmetic, by building the object graph against a
|
|
/// device that has NO GL context at all.</para>
|
|
///
|
|
/// <para>It deliberately proves construction and nothing more. The upload bodies
|
|
/// are still raw GL and the world renderers still bind a GL handle table; both
|
|
/// belong to the slice that draws Dereth on Vulkan. What matters here is that
|
|
/// each of those now fails at the site that needs GL, naming why, instead of
|
|
/// throwing a cast before the constructor has run a statement.</para>
|
|
/// </summary>
|
|
public sealed class MeshPipelineDeviceSeamTests
|
|
{
|
|
/// <summary>A device with the mesh pipeline's whole surface and no GL behind it.</summary>
|
|
private sealed class ContextFreeMeshPipelineDevice(
|
|
IGpuResourceRetirementQueue retirement,
|
|
bool modernPath = false)
|
|
: IMeshPipelineDevice
|
|
{
|
|
public GL? Gl => null;
|
|
|
|
public IGpuResourceRetirementQueue ResourceRetirement { get; } = retirement;
|
|
|
|
public uint InstanceVBO => 0;
|
|
|
|
public bool HasBindless => modernPath;
|
|
|
|
public bool HasOpenGL43 => modernPath;
|
|
|
|
public bool HasPendingWork => false;
|
|
|
|
public int ProcessedQueues { get; private set; }
|
|
|
|
public void ProcessQueue() => ProcessedQueues++;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private static ObjectMeshManager Build(
|
|
RecordingGpuDevice device,
|
|
bool modernPath = false) =>
|
|
new(
|
|
new ContextFreeMeshPipelineDevice(device.Retirement, modernPath),
|
|
device,
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<ObjectMeshManager>.Instance);
|
|
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The whole point. Before this slice the constructor downcast the RHI device
|
|
/// to <c>GlGpuDevice</c>, so this threw before running a statement.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheMeshPipelineConstructsAgainstADeviceWithNoGlContext()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using ObjectMeshManager manager = Build(device);
|
|
|
|
Assert.False(manager.IsDisposed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The GL handle table is the emulation the Vulkan backend replaces with set
|
|
/// 2, so asking a non-GL pipeline for it is a programming error — and it says
|
|
/// which backend it was composed against rather than reporting a cast.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheGlHandleTableIsRefusedByNameRatherThanCast()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using ObjectMeshManager manager = Build(device);
|
|
|
|
InvalidOperationException failure =
|
|
Assert.Throws<InvalidOperationException>(() => manager.WorldTextureTable);
|
|
Assert.Contains("GL-only", failure.Message, StringComparison.Ordinal);
|
|
Assert.Contains(GpuBackendKind.Recording.ToString(), failure.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one branch the texture stack keeps: a GL pair yields the GL arm, and
|
|
/// anything else yields the RHI arm. Selection happens once, at construction.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheArrayFactorySelectsTheRhiArmWithoutAGlPair()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
IWorldTextureArrayFactory arrays = IWorldTextureArrayFactory.For(
|
|
new ContextFreeMeshPipelineDevice(device.Retirement),
|
|
device,
|
|
NullLogger.Instance);
|
|
|
|
Assert.IsType<RhiWorldTextureArrayFactory>(arrays);
|
|
using IWorldTextureArray array =
|
|
arrays.CreateClampedArray(TextureFormat.RGBA8, 32, 32, 2);
|
|
Assert.IsType<RhiWorldTextureArray>(array);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The seam's whole value is that it is NARROW — seven members measured out
|
|
/// of a 760-line class. A later slice that quietly widens it back out would
|
|
/// re-couple the mesh pipeline to a backend without any other gate noticing,
|
|
/// so the member set is pinned rather than described.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheDeviceSeamStaysAtTheMeasuredSurface()
|
|
{
|
|
string[] members =
|
|
[
|
|
.. typeof(IMeshPipelineDevice)
|
|
.GetMembers()
|
|
// Property accessors are the same members under another name.
|
|
.Where(member => member is not System.Reflection.MethodInfo
|
|
{
|
|
IsSpecialName: true,
|
|
})
|
|
.Select(member => member.Name)
|
|
.Order(StringComparer.Ordinal),
|
|
];
|
|
|
|
Assert.Equal(
|
|
[
|
|
"Gl",
|
|
"HasBindless",
|
|
"HasOpenGL43",
|
|
"HasPendingWork",
|
|
"InstanceVBO",
|
|
"ProcessQueue",
|
|
"ResourceRetirement",
|
|
],
|
|
members);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Construction touched no GL object at all. The shared mesh arena is the
|
|
/// only one the constructor would build, and it is gated on the two
|
|
/// capability flags the interface carries — so a device reporting neither
|
|
/// leaves it absent rather than dereferencing a null context.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ConstructionBuildsNoGlObject()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using ObjectMeshManager manager = Build(device);
|
|
|
|
Assert.Null(manager.GlobalBuffer);
|
|
// Read-only policy queries still answer, which is what lets streaming
|
|
// residence accounting keep running on a backend with no world draws.
|
|
Assert.Equal((0, 0, 0), manager.GetPendingTextureUpdateStats());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheModernArenaBuildsWithoutAGlContext()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using ObjectMeshManager manager = Build(device, modernPath: true);
|
|
|
|
GlobalMeshBuffer arena = Assert.IsType<GlobalMeshBuffer>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<byte> readback = stackalloc byte[3 * VertexPositionNormalTexture.Size];
|
|
arena.VertexStore!.Read(
|
|
(long)allocation.Vertices.Offset * VertexPositionNormalTexture.Size,
|
|
readback);
|
|
var uploaded = System.Runtime.InteropServices.MemoryMarshal
|
|
.Cast<byte, VertexPositionNormalTexture>(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<byte> indexBytes = stackalloc byte[3 * sizeof(ushort)];
|
|
arena.IndexStore!.Read((long)allocation.Indices.Offset * sizeof(ushort), indexBytes);
|
|
Assert.Equal(
|
|
indices,
|
|
System.Runtime.InteropServices.MemoryMarshal.Cast<byte, ushort>(indexBytes).ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|