feat(render): Campaign V slice V0 — pin the Vulkan-shaped RHI contract

Campaign V migrates the renderer from OpenGL 4.3+extensions to a single
Vulkan 1.3 backend on Windows x64 and Linux x64, then deletes the GL path.
Motivation is compatibility and efficiency, not rescue: mandatory
GL_ARB_bindless_texture is the exact floor that parked Slice L (Mesa
D3D12/llvmpipe lack it) while Vulkan descriptor indexing is core, and
per-frame data can be written straight into mapped memory rather than
copied through BufferSubData.

V0 pins the contract every later slice codes against. Nothing consumes it
yet, so this commit changes no runtime behavior.

The seam is a minimal Vulkan-shaped RHI implemented FIRST on GL. That
ordering is the point: the twelve renderers then port one at a time under a
strict pixel gate on the still-shipping backend, so a divergence is
attributed to one slice instead of surfacing at a big-bang integration.
Duplicating renderers per backend was rejected because WbDrawDispatcher is
4,449 lines holding only ~62 GL call sites — the API surface is small and
the retail-fidelity CPU logic is large, and forking the latter is how subtle
regressions enter.

Contract highlights:
  - GpuBindingModel pins set/binding numbers dual-legal for GL and Vulkan
    GLSL. Storage bindings 0-8 keep today's shader numbering; UBOs move to
    their own set, which resolves the binding=1 collision GL only tolerates
    because it keeps SSBO and UBO tables separate.
  - GpuRingAllocation is a ref struct replacing every per-frame
    BufferSubData; the compiler forbids outliving the owning frame.
  - GpuTextureSlot replaces bindless handles. Unassigned is a loud
    uint.MaxValue sentinel rather than a silent resolve to slot 0 — the
    failure mode behind the magenta 1x1 UI placeholder bug. Renderers
    needing a fallback take the device's really-registered default slot.
  - Renderers always speak GL winding/viewport conventions; the Vulkan
    backend compensates with a negative viewport height in exactly one
    mapping function.

Verified while writing the plan: acdream's cameras already build
[0,1]-NDC projections (PortalProjection.cs:12-13), which is Vulkan's
convention. No projection rework is needed and depth precision improves,
at the cost of shifted z-fight patterns — the one pre-approved divergence
class, registered per instance at V7.

Gate: Release build green; App suite 3,785 passed / 3 skipped (3,763
baseline plus 22 new contract tests). Note for later slices, recorded in
the plan: run the suite in Release. LandblockBuildOriginTests'
far-strip test asserts behavior that LandblockStreamer.cs:505 deliberately
turns into a loud Debug.Assert in Debug builds, so a Debug run shows one
pre-existing failure that is not a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 14:26:26 +02:00
parent f6275f4501
commit 621b16364b
17 changed files with 2577 additions and 0 deletions

View file

@ -0,0 +1,166 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
/// <summary>
/// Campaign V slice V0 — the pinned RHI contract.
///
/// These are not behaviour tests; they are the tripwires that stop the contract
/// drifting out from under the shaders and the two backends. Every constant
/// asserted here also appears in a GLSL source file or in a backend's binding
/// setup, so a change that lands in only one place fails here rather than as a
/// corrupted frame.
/// </summary>
public sealed class GpuContractTests
{
[Fact]
public void PushConstantBlockMatchesThePinnedLayout()
{
Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf<GpuPushConstants>());
Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes);
Assert.Equal(0, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ViewProjection)));
Assert.Equal(64, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.DrawIdOffset)));
Assert.Equal(68, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightingMode)));
Assert.Equal(72, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.RenderPass)));
Assert.Equal(76, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightDebug)));
Assert.Equal(80, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexA)));
Assert.Equal(84, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexB)));
Assert.Equal(88, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamA)));
Assert.Equal(92, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamB)));
}
[Fact]
public void StorageBindingsMatchTheShaderSources()
{
// mesh_modern.vert declares std430 bindings 0..8 in exactly this order;
// binding 9 is the GL-only texture handle table added by slice V2.
Assert.Equal(0u, GpuBindingModel.StorageInstances);
Assert.Equal(1u, GpuBindingModel.StorageBatches);
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(3u, GpuBindingModel.StorageClipSlots);
Assert.Equal(4u, GpuBindingModel.StorageGlobalLights);
Assert.Equal(5u, GpuBindingModel.StorageInstanceLightSets);
Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor);
Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha);
Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting);
Assert.Equal(9u, GpuBindingModel.StorageTextureTable);
Assert.Equal(10u, GpuBindingModel.StorageBindingCount);
}
[Fact]
public void UniformAndTextureTableLiveInSeparateSets()
{
// The SceneLighting UBO keeps binding=1 even though the BatchBuffer SSBO
// also uses binding=1. GL tolerates that because its SSBO and UBO binding
// tables are separate; Vulkan does not, so the set index disambiguates.
Assert.Equal(GpuBindingModel.StorageBatches, GpuBindingModel.UniformSceneLighting);
Assert.NotEqual(0u, GpuBindingModel.UniformSet);
Assert.NotEqual(GpuBindingModel.UniformSet, GpuBindingModel.TextureTableSet);
}
[Fact]
public void ClipRegionStrideMatchesTheUploadedLayout()
{
// ClipFrame lays these bytes out on the CPU; ClipFrameLayoutTests pins the
// producer side, this pins the contract side. 16 B header + 8 x vec4.
Assert.Equal(8, GpuBindingModel.ClipPlanesPerSlot);
Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes);
}
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{
Assert.False(GpuTextureSlot.Unassigned.IsAssigned);
Assert.True(new GpuTextureSlot(0).IsAssigned);
Assert.Equal("slot#unassigned", GpuTextureSlot.Unassigned.ToString());
Assert.Equal("slot#7", new GpuTextureSlot(7).ToString());
}
[Fact]
public void WorldMeshVertexLayoutMatchesTheMeshShaderInputs()
{
GpuVertexLayout layout = GpuVertexLayout.WorldMesh;
Assert.Equal(32u, layout.StrideBytes);
Assert.Equal(3, layout.Attributes.Length);
Assert.Equal(new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), layout.Attributes[0]);
Assert.Equal(new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), layout.Attributes[1]);
Assert.Equal(new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), layout.Attributes[2]);
}
[Fact]
public void MultisampledBackbufferPassResolvesWhileDepthIsDiscarded()
{
GpuPassDescription multisampled = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 4);
Assert.Equal(GpuStoreOp.Resolve, multisampled.Color.Store);
Assert.Null(multisampled.Color.Target);
Assert.Equal(GpuStoreOp.DontCare, multisampled.Depth!.Value.Store);
Assert.Equal(1f, multisampled.Depth!.Value.ClearDepth);
GpuPassDescription single = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1);
Assert.Equal(GpuStoreOp.Store, single.Color.Store);
}
[Fact]
public void CapabilityRecordAcceptsADeviceThatMeetsEveryRequirement()
{
GpuCapabilityRecord record = SupportedRecord();
Assert.Empty(record.SupportFailures);
Assert.True(record.IsSupported);
}
[Fact]
public void CapabilityRecordNamesEveryMissingRequirement()
{
GpuCapabilityRecord record = SupportedRecord() with
{
SupportsMultiDrawIndirect = false,
SupportsDrawParameters = false,
SupportsTextureCompressionBc = false,
MaxTextureTableSlots = 16,
MaxStorageBufferBindings = 4,
MaxPushConstantBytes = 32,
MaxClipDistances = 0,
};
Assert.False(record.IsSupported);
Assert.Equal(7, record.SupportFailures.Count);
Assert.Contains(record.SupportFailures, failure => failure.Contains("Multi-draw-indirect", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("gl_DrawID", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("BC (DXT)", StringComparison.Ordinal));
Assert.Contains(record.SupportFailures, failure => failure.Contains("clip distances", StringComparison.Ordinal));
}
[Fact]
public void TimestampSupportIsOptional()
{
// Losing GPU timing degrades profiling; it must never refuse to start.
GpuCapabilityRecord record = SupportedRecord() with { SupportsTimestampQueries = false };
Assert.True(record.IsSupported);
}
private static GpuCapabilityRecord SupportedRecord() => new()
{
Backend = GpuBackendKind.Vulkan,
DeviceName = "test-adapter",
DriverInfo = "test-driver",
ApiVersion = "Vulkan 1.3.0",
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = 64,
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
};
}

View file

@ -0,0 +1,530 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
/// <summary>
/// One recorded RHI call. Renderer tests assert against the ordered sequence
/// instead of against a live driver, which is what keeps the App suite runnable
/// on a machine with no GPU while renderers migrate onto <see cref="IGpuDevice"/>
/// during Campaign V.
/// </summary>
internal abstract record GpuRecordedCall;
internal sealed record GpuRecordedFrameBegin(long Serial, int SlotIndex) : GpuRecordedCall;
internal sealed record GpuRecordedFrameEnd(long Serial) : GpuRecordedCall;
internal sealed record GpuRecordedRingAllocation(GpuRingUsage Usage, int ByteCount, uint OffsetBytes) : GpuRecordedCall;
internal sealed record GpuRecordedPassBegin(string Name, int SampleCount) : GpuRecordedCall;
internal sealed record GpuRecordedPassEnd(string Name) : GpuRecordedCall;
internal sealed record GpuRecordedPipelineBind(string PipelineName) : GpuRecordedCall;
internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes)
: GpuRecordedCall;
internal sealed record GpuRecordedUniformBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes)
: GpuRecordedCall;
internal sealed record GpuRecordedVertexBind(string BufferName, uint OffsetBytes) : GpuRecordedCall;
internal sealed record GpuRecordedIndexBind(string BufferName, uint OffsetBytes, GpuIndexType IndexType)
: GpuRecordedCall;
internal sealed record GpuRecordedPushConstants(GpuPushConstants Constants) : GpuRecordedCall;
internal sealed record GpuRecordedViewport(int X, int Y, int Width, int Height) : GpuRecordedCall;
internal sealed record GpuRecordedScissor(int X, int Y, int Width, int Height) : GpuRecordedCall;
internal sealed record GpuRecordedCullMode(GpuCullMode CullMode) : GpuRecordedCall;
internal sealed record GpuRecordedFrontFace(GpuFrontFace FrontFace) : GpuRecordedCall;
internal sealed record GpuRecordedDepthWrite(bool Enabled) : GpuRecordedCall;
internal sealed record GpuRecordedDrawIndexed(
uint IndexCount,
uint InstanceCount,
uint FirstIndex,
int VertexOffset,
uint FirstInstance) : GpuRecordedCall;
internal sealed record GpuRecordedDraw(
uint VertexCount,
uint InstanceCount,
uint FirstVertex,
uint FirstInstance) : GpuRecordedCall;
internal sealed record GpuRecordedMultiDrawIndirect(
string BufferName,
uint OffsetBytes,
uint DrawCount,
uint StrideBytes) : GpuRecordedCall;
internal sealed record GpuRecordedTextureRegistration(string TextureName, GpuSamplerDescription Sampler, uint Slot)
: GpuRecordedCall;
internal sealed record GpuRecordedTextureRelease(uint Slot) : GpuRecordedCall;
/// <summary>
/// In-memory <see cref="IGpuDevice"/> that owns no driver objects. Ring
/// allocations are backed by a real byte array, so a test can drive a renderer
/// and then read back exactly what it wrote — the same bytes a driver would have
/// seen. Everything else is recorded into <see cref="Calls"/> in submission order.
/// </summary>
internal sealed class RecordingGpuDevice : IGpuDevice
{
private const int DefaultRingCapacityBytes = 8 * 1024 * 1024;
private readonly List<GpuRecordedCall> _calls = [];
private readonly List<Action> _queuedActions = [];
private readonly Dictionary<GpuSamplerDescription, RecordingGpuSampler> _samplers = [];
private readonly byte[] _ring;
private readonly Stack<uint> _freeTextureSlots = new();
private uint _nextTextureSlot;
private uint _ringCursor;
private long _serial;
private RecordingGpuFrame? _openFrame;
private bool _disposed;
public RecordingGpuDevice(int ringCapacityBytes = DefaultRingCapacityBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(ringCapacityBytes, 1);
_ring = new byte[ringCapacityBytes];
RingBuffer = new RecordingGpuBuffer(new GpuBufferDescription(
"test-ring",
ringCapacityBytes,
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
GpuMemoryResidency.HostWritable));
RecordingGpuTexture placeholder = new("default-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, 1, 1, 1, 1);
DefaultTextureSlot = RegisterTexture(placeholder, CreateSampler(GpuSamplerDescription.UiNearest));
}
/// <summary>Every recorded call, in submission order.</summary>
public IReadOnlyList<GpuRecordedCall> Calls => _calls;
/// <summary>Backing store for ring allocations, so tests can read what a renderer wrote.</summary>
public ReadOnlySpan<byte> RingBytes => _ring;
/// <summary>Number of ring bytes handed out during the currently open (or most recent) frame.</summary>
public uint RingBytesAllocated => _ringCursor;
public int OpenFrameCount { get; private set; }
public int LiveTextureSlotCount => (int)_nextTextureSlot - _freeTextureSlots.Count;
public GpuBackendKind Backend => GpuBackendKind.Recording;
public GpuCapabilityRecord Capabilities { get; init; } = new()
{
Backend = GpuBackendKind.Recording,
DeviceName = "recording",
DriverInfo = "in-memory test double",
ApiVersion = "n/a",
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = 256,
MinUniformBufferOffsetAlignment = 256,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxSampleCount = 8,
SupportsMultiDrawIndirect = true,
SupportsDrawParameters = true,
SupportsTextureCompressionBc = true,
SupportsTimestampQueries = true,
SupportsPersistentlyMappedRings = true,
};
public IGpuResourceRetirementQueue Retirement => ImmediateGpuResourceRetirementQueue.Instance;
public IGpuTimerPool Timers { get; } = new RecordingGpuTimerPool();
public GpuTextureSlot DefaultTextureSlot { get; }
public void Clear() => _calls.Clear();
public IGpuBuffer CreateBuffer(in GpuBufferDescription description) =>
new RecordingGpuBuffer(description);
public IGpuTexture CreateTexture(in GpuTextureDescription description) =>
new RecordingGpuTexture(
description.Name,
description.Kind,
description.Format,
description.Width,
description.Height,
description.LayerCount,
description.MipLevelCount);
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{
if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing))
return existing;
RecordingGpuSampler created = new(description);
_samplers.Add(description, created);
return created;
}
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
{
ArgumentNullException.ThrowIfNull(description);
return new RecordingGpuPipeline(description);
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) =>
new RecordingGpuRenderTarget(description);
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
ArgumentNullException.ThrowIfNull(texture);
ArgumentNullException.ThrowIfNull(sampler);
uint slot = _freeTextureSlots.Count > 0 ? _freeTextureSlots.Pop() : _nextTextureSlot++;
_calls.Add(new GpuRecordedTextureRegistration(texture.Name, sampler.Description, slot));
return new GpuTextureSlot(slot);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
{
if (!slot.IsAssigned)
throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot));
_freeTextureSlots.Push(slot.Index);
_calls.Add(new GpuRecordedTextureRelease(slot.Index));
}
public IGpuFrame BeginFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_openFrame is not null)
throw new InvalidOperationException("The previous frame must end before another begins.");
_ringCursor = 0;
long serial = ++_serial;
int slotIndex = (int)((serial - 1) % 2);
_calls.Add(new GpuRecordedFrameBegin(serial, slotIndex));
OpenFrameCount++;
_openFrame = new RecordingGpuFrame(this, serial, slotIndex);
return _openFrame;
}
public void QueueDeviceAction(Action action)
{
ArgumentNullException.ThrowIfNull(action);
_queuedActions.Add(action);
}
public void ProcessDeviceActions()
{
Action[] pending = [.. _queuedActions];
_queuedActions.Clear();
foreach (Action action in pending)
action();
}
public byte[] CaptureBackbuffer(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
return new byte[checked(width * height * 4)];
}
public void WaitIdle() => ProcessDeviceActions();
public void Dispose() => _disposed = true;
internal void Record(GpuRecordedCall call) => _calls.Add(call);
internal GpuRingAllocation Allocate(int byteCount, GpuRingUsage usage)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteCount);
uint alignment = usage switch
{
GpuRingUsage.Storage => Capabilities.MinStorageBufferOffsetAlignment,
GpuRingUsage.Uniform => Capabilities.MinUniformBufferOffsetAlignment,
_ => 4u,
};
uint aligned = AlignUp(_ringCursor, alignment);
if (aligned + (uint)byteCount > (uint)_ring.Length)
{
throw new InvalidOperationException(
$"Ring allocation of {byteCount} bytes for {usage} exceeds the {_ring.Length}-byte test ring.");
}
_ringCursor = aligned + (uint)byteCount;
_calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned));
return new GpuRingAllocation(RingBuffer, aligned, _ring.AsSpan((int)aligned, byteCount));
}
internal IGpuBuffer RingBuffer { get; }
internal void CloseFrame(RecordingGpuFrame frame)
{
if (!ReferenceEquals(_openFrame, frame))
return;
_calls.Add(new GpuRecordedFrameEnd(frame.Serial));
OpenFrameCount--;
_openFrame = null;
}
private static uint AlignUp(uint value, uint alignment) =>
alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment;
}
internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, int slotIndex) : IGpuFrame
{
private bool _ended;
public int SlotIndex { get; } = slotIndex;
public long Serial { get; } = serial;
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => device.Allocate(byteCount, usage);
public IGpuPassEncoder BeginPass(GpuPassDescription description)
{
ArgumentNullException.ThrowIfNull(description);
device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount));
return new RecordingGpuPassEncoder(device, description);
}
public void End()
{
if (_ended)
return;
_ended = true;
device.CloseFrame(this);
}
public void Dispose() => End();
}
internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPassDescription pass) : IGpuPassEncoder
{
private bool _closed;
public GpuPassDescription Pass { get; } = pass;
public void BindPipeline(IGpuPipeline pipeline)
{
ArgumentNullException.ThrowIfNull(pipeline);
device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name));
}
public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedStorageBind(binding, buffer.Name, offsetBytes, sizeBytes));
}
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes));
}
public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes)
{
ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedVertexBind(buffer.Name, offsetBytes));
}
public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType)
{
ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType));
}
public void SetPushConstants(in GpuPushConstants constants) =>
device.Record(new GpuRecordedPushConstants(constants));
public void SetViewport(int x, int y, int width, int height) =>
device.Record(new GpuRecordedViewport(x, y, width, height));
public void SetScissor(int x, int y, int width, int height) =>
device.Record(new GpuRecordedScissor(x, y, width, height));
public void SetCullMode(GpuCullMode cullMode) => device.Record(new GpuRecordedCullMode(cullMode));
public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace));
public void SetDepthWrite(bool enabled) => device.Record(new GpuRecordedDepthWrite(enabled));
public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) =>
device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance));
public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) =>
device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance));
public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
{
ArgumentNullException.ThrowIfNull(commands);
device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes));
}
public IDisposable BeginTimerScope(string scopeName) => NullDisposable.Instance;
public void Dispose()
{
if (_closed)
return;
_closed = true;
device.Record(new GpuRecordedPassEnd(Pass.Name));
}
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
internal sealed class RecordingGpuBuffer(GpuBufferDescription description) : IGpuBuffer
{
private readonly byte[] _storage = new byte[description.SizeBytes];
public string Name { get; } = description.Name;
public long SizeBytes { get; } = description.SizeBytes;
public GpuBufferUsage Usage { get; } = description.Usage;
public GpuMemoryResidency Residency { get; } = description.Residency;
public bool IsDisposed { get; private set; }
public void Upload(long offsetBytes, ReadOnlySpan<byte> data) =>
data.CopyTo(_storage.AsSpan((int)offsetBytes, data.Length));
public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount)
{
ArgumentNullException.ThrowIfNull(destination);
if (destination is not RecordingGpuBuffer target)
throw new ArgumentException("Recording buffers can only copy to recording buffers.", nameof(destination));
_storage.AsSpan((int)sourceOffsetBytes, (int)byteCount)
.CopyTo(target._storage.AsSpan((int)destinationOffsetBytes, (int)byteCount));
}
public void Read(long offsetBytes, Span<byte> destination) =>
_storage.AsSpan((int)offsetBytes, destination.Length).CopyTo(destination);
public void Dispose() => IsDisposed = true;
}
internal sealed class RecordingGpuTexture(
string name,
GpuTextureKind kind,
GpuTextureFormat format,
int width,
int height,
int layerCount,
int mipLevelCount) : IGpuTexture
{
private readonly List<(int MipLevel, int Layer, int ByteCount)> _uploads = [];
public string Name { get; } = name;
public GpuTextureKind Kind { get; } = kind;
public GpuTextureFormat Format { get; } = format;
public int Width { get; } = width;
public int Height { get; } = height;
public int LayerCount { get; } = layerCount;
public int MipLevelCount { get; } = mipLevelCount;
public bool MipChainGenerated { get; private set; }
public bool IsDisposed { get; private set; }
public IReadOnlyList<(int MipLevel, int Layer, int ByteCount)> Uploads => _uploads;
public void Upload(int mipLevel, int layer, ReadOnlySpan<byte> data) =>
_uploads.Add((mipLevel, layer, data.Length));
public void GenerateMipChain() => MipChainGenerated = true;
public void Dispose() => IsDisposed = true;
}
internal sealed class RecordingGpuSampler(GpuSamplerDescription description) : IGpuSampler
{
public GpuSamplerDescription Description { get; } = description;
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}
internal sealed class RecordingGpuPipeline(GpuPipelineDescription description) : IGpuPipeline
{
public GpuPipelineDescription Description { get; } = description;
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}
internal sealed class RecordingGpuRenderTarget : IGpuRenderTarget
{
public RecordingGpuRenderTarget(GpuRenderTargetDescription description)
{
Description = description;
ColorTexture = new RecordingGpuTexture(
$"{description.Name}-color",
GpuTextureKind.Texture2D,
description.ColorFormat,
description.Width,
description.Height,
layerCount: 1,
mipLevelCount: 1);
}
public GpuRenderTargetDescription Description { get; }
public IGpuTexture ColorTexture { get; }
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}
internal sealed class RecordingGpuTimerPool : IGpuTimerPool
{
public bool IsSupported => false;
public bool TryResolve(string scopeName, out double milliseconds)
{
milliseconds = 0d;
return false;
}
}
/// <summary>Convenience helpers so renderer tests read as assertions, not as list surgery.</summary>
internal static class RecordingGpuDeviceAssertions
{
public static IEnumerable<T> OfKind<T>(this RecordingGpuDevice device) where T : GpuRecordedCall =>
device.Calls.OfType<T>();
public static Vector4 ClearColorOf(this GpuPassDescription pass) => pass.Color.ClearColor;
}

View file

@ -0,0 +1,221 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
/// <summary>
/// Campaign V slice V0 — the test double every later renderer-port slice asserts
/// against. If the double misreports ordering or ring alignment, the migration
/// slices inherit false confidence, so it gets its own tests.
/// </summary>
public sealed class RecordingGpuDeviceTests
{
[Fact]
public void FramePassAndDrawCallsAreRecordedInSubmissionOrder()
{
using RecordingGpuDevice device = new();
IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = "mesh-opaque",
Shaders = new GpuShaderSet("mesh_modern"),
VertexLayout = GpuVertexLayout.WorldMesh,
});
IGpuBuffer indirect = device.CreateBuffer(new GpuBufferDescription(
"indirect", 4096, GpuBufferUsage.Indirect, GpuMemoryResidency.HostWritable));
device.Clear();
using (IGpuFrame frame = device.BeginFrame())
{
using (IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1)))
{
pass.BindPipeline(pipeline);
pass.SetCullMode(GpuCullMode.None);
pass.MultiDrawIndexedIndirect(indirect, offsetBytes: 0, drawCount: 12, strideBytes: 20);
}
frame.End();
}
Assert.Collection(
device.Calls,
call => Assert.Equal(new GpuRecordedFrameBegin(1, 0), call),
call => Assert.Equal(new GpuRecordedPassBegin("world", 1), call),
call => Assert.Equal(new GpuRecordedPipelineBind("mesh-opaque"), call),
call => Assert.Equal(new GpuRecordedCullMode(GpuCullMode.None), call),
call => Assert.Equal(new GpuRecordedMultiDrawIndirect("indirect", 0, 12, 20), call),
call => Assert.Equal(new GpuRecordedPassEnd("world"), call),
call => Assert.Equal(new GpuRecordedFrameEnd(1), call));
}
[Fact]
public void RingAllocationsAreAlignedForTheirUsageAndReadableAfterWriting()
{
using RecordingGpuDevice device = new();
uint storageAlignment = device.Capabilities.MinStorageBufferOffsetAlignment;
using IGpuFrame frame = device.BeginFrame();
GpuRingAllocation first = frame.AllocateRing(12, GpuRingUsage.Indirect);
Assert.Equal(0u, first.OffsetBytes);
Assert.Equal(12, first.Data.Length);
GpuRingAllocation second = frame.AllocateRing(64, GpuRingUsage.Storage);
Assert.Equal(0u, second.OffsetBytes % storageAlignment);
Assert.True(second.OffsetBytes >= 12);
Span<Matrix4x4> transforms = second.AsSpan<Matrix4x4>();
Assert.Equal(1, transforms.Length);
transforms[0] = Matrix4x4.CreateTranslation(1f, 2f, 3f);
frame.End();
// The bytes a renderer writes are the bytes a driver would read; a test can
// therefore verify upload content without a GPU.
ReadOnlySpan<byte> ring = device.RingBytes;
Matrix4x4 written = System.Runtime.InteropServices.MemoryMarshal.Read<Matrix4x4>(
ring.Slice((int)second.OffsetBytes, 64));
Assert.Equal(Matrix4x4.CreateTranslation(1f, 2f, 3f), written);
}
[Fact]
public void RingIsRewoundEachFrameSoPerFrameDataDoesNotAccumulate()
{
using RecordingGpuDevice device = new();
using (IGpuFrame first = device.BeginFrame())
{
first.AllocateRing(256, GpuRingUsage.Storage);
first.End();
}
uint afterFirst = device.RingBytesAllocated;
using (IGpuFrame second = device.BeginFrame())
{
GpuRingAllocation allocation = second.AllocateRing(256, GpuRingUsage.Storage);
Assert.Equal(0u, allocation.OffsetBytes);
second.End();
}
Assert.Equal(afterFirst, device.RingBytesAllocated);
}
[Fact]
public void OverlargeRingRequestThrowsRatherThanTruncating()
{
using RecordingGpuDevice device = new(ringCapacityBytes: 1024);
using IGpuFrame frame = device.BeginFrame();
// Silently shortening an allocation would corrupt the frame invisibly.
Assert.Throws<InvalidOperationException>(() =>
{
frame.AllocateRing(4096, GpuRingUsage.Storage);
});
}
[Fact]
public void OverlappingFramesAreRejected()
{
using RecordingGpuDevice device = new();
using IGpuFrame frame = device.BeginFrame();
Assert.Throws<InvalidOperationException>(device.BeginFrame);
}
[Fact]
public void FrameSlotsAlternateAcrossTwoFramesInFlight()
{
using RecordingGpuDevice device = new();
int[] slots = new int[4];
for (int i = 0; i < slots.Length; i++)
{
using IGpuFrame frame = device.BeginFrame();
slots[i] = frame.SlotIndex;
frame.End();
}
Assert.Equal([0, 1, 0, 1], slots);
Assert.Equal(0, device.OpenFrameCount);
}
[Fact]
public void ReleasedTextureSlotsAreRecycledRatherThanLeaked()
{
using RecordingGpuDevice device = new();
IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat);
IGpuTexture texture = device.CreateTexture(new GpuTextureDescription(
"wall", GpuTextureKind.Texture2DArray, GpuTextureFormat.Bc1Unorm, 64, 64, 4, 1));
int liveBefore = device.LiveTextureSlotCount;
GpuTextureSlot slot = device.RegisterTexture(texture, sampler);
Assert.True(slot.IsAssigned);
Assert.Equal(liveBefore + 1, device.LiveTextureSlotCount);
device.ReleaseTextureSlot(slot);
Assert.Equal(liveBefore, device.LiveTextureSlotCount);
GpuTextureSlot reused = device.RegisterTexture(texture, sampler);
Assert.Equal(slot.Index, reused.Index);
}
[Fact]
public void ReleasingAnUnassignedSlotIsRejected()
{
using RecordingGpuDevice device = new();
Assert.Throws<ArgumentException>(() => device.ReleaseTextureSlot(GpuTextureSlot.Unassigned));
}
[Fact]
public void DefaultTextureSlotIsRegisteredAndUsable()
{
using RecordingGpuDevice device = new();
// Renderers needing a fallback take this, rather than assuming slot 0
// resolves to something sensible.
Assert.True(device.DefaultTextureSlot.IsAssigned);
}
[Fact]
public void SamplersAreDeduplicatedByValue()
{
using RecordingGpuDevice device = new();
IGpuSampler first = device.CreateSampler(GpuSamplerDescription.WorldRepeat);
IGpuSampler second = device.CreateSampler(GpuSamplerDescription.WorldRepeat);
IGpuSampler other = device.CreateSampler(GpuSamplerDescription.UiNearest);
Assert.Same(first, second);
Assert.NotSame(first, other);
}
[Fact]
public void QueuedDeviceActionsRunOnlyWhenProcessed()
{
using RecordingGpuDevice device = new();
int ran = 0;
device.QueueDeviceAction(() => ran++);
Assert.Equal(0, ran);
device.ProcessDeviceActions();
Assert.Equal(1, ran);
device.ProcessDeviceActions();
Assert.Equal(1, ran);
}
[Fact]
public void DisposingAFrameClosesItExactlyOnce()
{
using RecordingGpuDevice device = new();
IGpuFrame frame = device.BeginFrame();
frame.End();
frame.Dispose();
Assert.Single(device.OfKind<GpuRecordedFrameEnd>());
Assert.Equal(0, device.OpenFrameCount);
}
}