using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering.Gpu;
///
/// 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
/// during Campaign V.
///
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(uint Binding, string BufferName, uint OffsetBytes) : GpuRecordedCall;
internal sealed record GpuRecordedStencil(GpuStencilState Stencil) : 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;
///
/// In-memory 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 in submission order.
///
internal sealed class RecordingGpuDevice : IGpuDevice
{
private const int DefaultRingCapacityBytes = 8 * 1024 * 1024;
private readonly List _calls = [];
private readonly List _queuedActions = [];
private readonly Dictionary _samplers = [];
private readonly byte[] _ring;
private readonly Stack _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));
}
/// Every recorded call, in submission order.
public IReadOnlyList Calls => _calls;
/// Backing store for ring allocations, so tests can read what a renderer wrote.
public ReadOnlySpan RingBytes => _ring;
/// Number of ring bytes handed out during the currently open (or most recent) frame.
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);
///
/// Campaign V slice V6i-2: every image this device made, in creation order.
/// A caller that creates its own textures internally — the world texture
/// arrays and the terrain atlas do — has no other way to assert what landed
/// on them.
///
public IReadOnlyList CreatedTextures => _createdTextures;
private readonly List _createdTextures = [];
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
RecordingGpuTexture texture = new(
description.Name,
description.Kind,
description.Format,
description.Width,
description.Height,
description.LayerCount,
description.MipLevelCount);
_createdTextures.Add(texture);
return texture;
}
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(uint binding, IGpuBuffer buffer, uint offsetBytes)
{
ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedVertexBind(binding, 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 SetStencil(in GpuStencilState stencil) => device.Record(new GpuRecordedStencil(stencil));
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 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 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 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;
}
}
/// Convenience helpers so renderer tests read as assertions, not as list surgery.
internal static class RecordingGpuDeviceAssertions
{
public static IEnumerable OfKind(this RecordingGpuDevice device) where T : GpuRecordedCall =>
device.Calls.OfType();
public static Vector4 ClearColorOf(this GpuPassDescription pass) => pass.Color.ClearColor;
}