Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
544 lines
19 KiB
C#
544 lines
19 KiB
C#
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(uint Binding, 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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IReadOnlyList<RecordingGpuTexture> CreatedTextures => _createdTextures;
|
|
|
|
private readonly List<RecordingGpuTexture> _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 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;
|
|
}
|