diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 513360c5..e47fb6ec 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -32,6 +32,10 @@ the rest of the Silk family so the shared Silk.NET.Core does not fork. --> + + diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDebugNames.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDebugNames.cs new file mode 100644 index 00000000..0a3b048c --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDebugNames.cs @@ -0,0 +1,136 @@ +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.EXT; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6, plan §4.1: publishes acdream's own object names through +/// VK_EXT_debug_utils when the extension is present. +/// +/// Naming is why GpuBufferDescription.Name is documented as "not +/// cosmetic". A RenderDoc capture or a validation-layer message that says +/// mesh-arena-vertex instead of VkBuffer 0x7f… is the difference +/// between reading a frame and guessing at one — and the campaign has already +/// spent days on defects that were only ever visible from outside the API. +/// +/// The extension is optional and never required: when it is absent every +/// method here is a cheap no-op, so callers name everything unconditionally and +/// no call site needs a capability check. +/// +internal sealed unsafe class VulkanDebugNames : IDisposable +{ + private readonly Device _device; + private readonly ExtDebugUtils? _api; + private bool _disposed; + + private VulkanDebugNames(Device device, ExtDebugUtils? api) + { + _device = device; + _api = api; + } + + /// A naming sink that does nothing — used when the extension is absent and by tests. + internal static VulkanDebugNames Disabled { get; } = new(default, null); + + internal bool IsEnabled => _api is not null; + + /// + /// Loads the extension's entry points when + /// contains it. Failure to load is treated exactly like absence: naming is a + /// diagnostic, never a dependency. + /// + internal static VulkanDebugNames Create( + Silk.NET.Vulkan.Vk vk, + Instance instance, + Device device, + IReadOnlyCollection enabledInstanceExtensions) + { + ArgumentNullException.ThrowIfNull(vk); + ArgumentNullException.ThrowIfNull(enabledInstanceExtensions); + if (!enabledInstanceExtensions.Contains(VulkanExtensionSelection.DebugUtilsExtension)) + return Disabled; + if (!vk.TryGetInstanceExtension(instance, out ExtDebugUtils api)) + return Disabled; + return new VulkanDebugNames(device, api); + } + + internal void NameBuffer(Buffer buffer, string name) => + Name(ObjectType.Buffer, buffer.Handle, name); + + internal void NameImage(Image image, string name) => + Name(ObjectType.Image, image.Handle, name); + + internal void NameImageView(ImageView view, string name) => + Name(ObjectType.ImageView, view.Handle, name); + + internal void NameSampler(Sampler sampler, string name) => + Name(ObjectType.Sampler, sampler.Handle, name); + + internal void NamePipeline(Pipeline pipeline, string name) => + Name(ObjectType.Pipeline, pipeline.Handle, name); + + internal void NameDeviceMemory(DeviceMemory memory, string name) => + Name(ObjectType.DeviceMemory, memory.Handle, name); + + /// Opens a labelled region in the command stream — one per RHI pass. + internal void BeginLabel(CommandBuffer commands, string label) + { + if (_api is null || _disposed) + return; + + nint text = SilkMarshal.StringToPtr(label); + try + { + var info = new DebugUtilsLabelEXT + { + SType = StructureType.DebugUtilsLabelExt, + PLabelName = (byte*)text, + }; + _api.CmdBeginDebugUtilsLabel(commands, &info); + } + finally + { + SilkMarshal.Free(text); + } + } + + internal void EndLabel(CommandBuffer commands) + { + if (_api is null || _disposed) + return; + _api.CmdEndDebugUtilsLabel(commands); + } + + private void Name(ObjectType type, ulong handle, string name) + { + if (_api is null || _disposed || handle == 0 || string.IsNullOrEmpty(name)) + return; + + nint text = SilkMarshal.StringToPtr(name); + try + { + var info = new DebugUtilsObjectNameInfoEXT + { + SType = StructureType.DebugUtilsObjectNameInfoExt, + ObjectType = type, + ObjectHandle = handle, + PObjectName = (byte*)text, + }; + _api.SetDebugUtilsObjectName(_device, &info); + } + finally + { + SilkMarshal.Free(text); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _api?.Dispose(); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs new file mode 100644 index 00000000..207eb51b --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs @@ -0,0 +1,242 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// A live suballocation: the device memory it sits in, where, and — for +/// host-visible memory — a pointer to its first byte inside the block's +/// persistent mapping. +/// +internal readonly unsafe struct VulkanAllocation( + DeviceMemory memory, + ulong offsetBytes, + ulong sizeBytes, + uint memoryTypeIndex, + VulkanMemoryRange range, + void* mapped) +{ + internal DeviceMemory Memory { get; } = memory; + internal ulong OffsetBytes { get; } = offsetBytes; + internal ulong SizeBytes { get; } = sizeBytes; + internal uint MemoryTypeIndex { get; } = memoryTypeIndex; + internal VulkanMemoryRange Range { get; } = range; + + /// First mapped byte of this allocation, or null on device-local memory. + internal void* Mapped { get; } = mapped; + + internal bool IsMapped => Mapped is not null; + + internal Span AsSpan() => IsMapped + ? new Span(Mapped, checked((int)SizeBytes)) + : throw new InvalidOperationException( + "This allocation lives in device-local memory and has no CPU mapping."); +} + +/// +/// Campaign V slice V6a, plan §4.2: acdream's Vulkan memory allocator. +/// +/// No VMA, deliberately. Silk does not ship it; third-party .NET +/// bindings are a native-binary and maintenance liability across win-x64, +/// linux-x64 and CI lavapipe; and acdream's allocation profile is tame — two +/// mesh arena buffers, one staging ring, a few per-flight buffers, a handful of +/// render targets and a texture pool. What this buys instead is exact +/// accounting: every byte is attributable to a memory type and a block, which +/// VMA would obscure. +/// +/// The interesting decisions are not here. Placement inside a block is +/// , the block/dedicated policy is +/// , and the heap choice is +/// — all pure and unit-tested. This +/// class turns their answers into vkAllocateMemory and one persistent +/// vkMapMemory per host-visible block. +/// +/// One mapping per block, never per allocation. Vulkan permits a +/// memory object to be mapped once; mapping per buffer would therefore need one +/// VkDeviceMemory per buffer, which is the allocation-count explosion the +/// whole design exists to avoid. Blocks are mapped when they are created and +/// stay mapped for their lifetime, and a suballocation's pointer is the block +/// base plus its offset. +/// +internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly MemoryPropertyFlags[] _memoryTypeProperties; + private readonly ulong _blockSizeBytes; + private readonly ulong _dedicatedThresholdBytes; + + private readonly Dictionary _pools = []; + private readonly Dictionary<(uint TypeIndex, int BlockIndex), BlockMemory> _blockMemory = []; + + private bool _disposed; + + private readonly record struct BlockMemory(DeviceMemory Memory, nint Mapped, ulong CapacityBytes); + + internal VulkanDeviceMemoryAllocator( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + ulong blockSizeBytes = VulkanMemoryTypePool.DefaultBlockSizeBytes, + ulong dedicatedThresholdBytes = VulkanMemoryTypePool.DefaultDedicatedThresholdBytes) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _blockSizeBytes = blockSizeBytes; + _dedicatedThresholdBytes = dedicatedThresholdBytes; + + vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out PhysicalDeviceMemoryProperties properties); + _memoryTypeProperties = new MemoryPropertyFlags[properties.MemoryTypeCount]; + for (uint i = 0; i < properties.MemoryTypeCount && i < 32; i++) + _memoryTypeProperties[i] = properties.MemoryTypes[(int)i].PropertyFlags; + } + + /// Live vkAllocateMemory objects. Kept two orders of magnitude below the device limit by design. + internal int DeviceMemoryObjectCount => _blockMemory.Count; + + /// Total bytes handed to callers, excluding block headroom. + internal ulong AllocatedBytes { get; private set; } + + /// Total bytes committed to vkAllocateMemory, including headroom in shared blocks. + internal ulong CommittedBytes { get; private set; } + + internal IReadOnlyList MemoryTypeProperties => _memoryTypeProperties; + + /// + /// Allocates memory satisfying for the given + /// residency class, creating a block when no existing one fits. + /// + internal VulkanAllocation Allocate( + in MemoryRequirements requirements, + GpuMemoryResidency residency, + string ownerName) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + uint typeIndex = VulkanMemoryTypeSelection.Choose( + _memoryTypeProperties, + requirements.MemoryTypeBits, + residency) + ?? throw new NotSupportedException( + $"No Vulkan memory type satisfies {residency} for '{ownerName}'. " + + $"Allowed type bits 0x{requirements.MemoryTypeBits:X8}; the device exposes " + + $"{_memoryTypeProperties.Length} memory types."); + + if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool)) + { + pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes); + _pools.Add(typeIndex, pool); + } + + ulong size = requirements.Size; + ulong alignment = Math.Max(requirements.Alignment, 1); + if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range)) + { + bool dedicated = pool.IsDedicatedSize(size); + ulong capacity = Math.Max(pool.BlockCapacityFor(size), size); + int blockIndex = pool.AddBlock(capacity, dedicated); + CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName); + + range = dedicated + ? pool.AllocateWholeBlock(blockIndex, size) + : pool.TryAllocate(size, alignment, out VulkanMemoryRange placed) + ? placed + : throw new InvalidOperationException( + $"A freshly created {capacity}-byte block could not satisfy a {size}-byte " + + $"allocation at alignment {alignment} for '{ownerName}'."); + } + + BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)]; + AllocatedBytes += range.SizeBytes; + void* mapped = block.Mapped == 0 + ? null + : (void*)(block.Mapped + (nint)range.OffsetBytes); + return new VulkanAllocation( + block.Memory, + range.OffsetBytes, + range.SizeBytes, + typeIndex, + range, + mapped); + } + + /// Returns an allocation's bytes to its pool, freeing the block when a dedicated one empties. + internal void Free(in VulkanAllocation allocation) + { + if (_disposed || allocation.SizeBytes == 0) + return; + if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool)) + return; + + AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes); + if (!pool.Free(allocation.Range)) + return; + + var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex); + if (!_blockMemory.Remove(key, out BlockMemory block)) + return; + + if (block.Mapped != 0) + _vk.UnmapMemory(_device, block.Memory); + _vk.FreeMemory(_device, block.Memory, null); + CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes); + } + + private void CreateBlockMemory(uint typeIndex, int blockIndex, ulong capacityBytes, string ownerName) + { + var allocate = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = capacityBytes, + MemoryTypeIndex = typeIndex, + }; + Result result = _vk.AllocateMemory(_device, &allocate, null, out DeviceMemory memory); + if (result != Result.Success) + { + throw new VulkanCallException( + $"vkAllocateMemory ({capacityBytes} bytes on memory type {typeIndex} for '{ownerName}')", + result); + } + + nint mapped = 0; + if (_memoryTypeProperties[(int)typeIndex].HasFlag(MemoryPropertyFlags.HostVisibleBit)) + { + void* pointer = null; + Result mapResult = _vk.MapMemory(_device, memory, 0, capacityBytes, 0, &pointer); + if (mapResult != Result.Success) + { + _vk.FreeMemory(_device, memory, null); + throw new VulkanCallException($"vkMapMemory (block for '{ownerName}')", mapResult); + } + + mapped = (nint)pointer; + } + + _blockMemory[(typeIndex, blockIndex)] = new BlockMemory(memory, mapped, capacityBytes); + CommittedBytes += capacityBytes; + } + + /// Human-readable accounting for the diagnostics report and for teardown assertions. + internal string Describe() => + $"{DeviceMemoryObjectCount} device-memory object(s), " + + $"{CommittedBytes / (1024 * 1024)} MiB committed, " + + $"{AllocatedBytes / (1024 * 1024)} MiB allocated"; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (BlockMemory block in _blockMemory.Values) + { + if (block.Mapped != 0) + _vk.UnmapMemory(_device, block.Memory); + _vk.FreeMemory(_device, block.Memory, null); + } + + _blockMemory.Clear(); + _pools.Clear(); + AllocatedBytes = 0; + CommittedBytes = 0; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs new file mode 100644 index 00000000..5ef2a7b6 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs @@ -0,0 +1,221 @@ +using AcDream.App.Rendering; +using Silk.NET.Vulkan; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// The four timeline-semaphore operations +/// needs, behind an interface so the controller's ledger and flight arithmetic +/// run under a unit test with no device — the same role IGpuFenceApi +/// plays for the GL flight controller. +/// +internal interface IVulkanTimelineApi +{ + /// Highest value the timeline has signalled. + ulong CurrentValue { get; } + + /// Blocks until the timeline reaches . + void Wait(ulong value); +} + +/// Live implementation over one VkSemaphore of type timeline. +internal sealed unsafe class VulkanTimelineApi( + Silk.NET.Vulkan.Vk vk, + Device device, + Semaphore timeline) : IVulkanTimelineApi +{ + private readonly Silk.NET.Vulkan.Vk _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + + public ulong CurrentValue + { + get + { + VulkanInterop.Check( + _vk.GetSemaphoreCounterValue(device, timeline, out ulong value), + "vkGetSemaphoreCounterValue"); + return value; + } + } + + public void Wait(ulong value) + { + Semaphore semaphore = timeline; + ulong target = value; + var wait = new SemaphoreWaitInfo + { + SType = StructureType.SemaphoreWaitInfo, + SemaphoreCount = 1, + PSemaphores = &semaphore, + PValues = &target, + }; + VulkanInterop.Check( + _vk.WaitSemaphores(device, &wait, ulong.MaxValue), + $"vkWaitSemaphores (frame flight, value {value})"); + } +} + +/// +/// Campaign V slice V6a, plan §4.8: frames in flight and retirement, on ONE +/// timeline semaphore whose value is the frame serial. +/// +/// This is the Vulkan sibling of GpuFrameFlightController, and the +/// port is close to mechanical because the plan chose the timeline for exactly +/// that reason. GL's array of fences becomes one monotonic counter; "has slot +/// s retired?" becomes "is the counter at least serial - 2?"; and +/// the SortedDictionary<long, List<Action>> retirement ledger +/// keeps its keys, because those keys were already frame serials. +/// +/// Retirement is keyed to the frame that has not been submitted +/// yet. A resource released now may still be referenced by commands already +/// recorded into the open frame, so its release is filed under the serial of the +/// frame currently being recorded (or the next one to open, when no frame is +/// open) and runs once the timeline passes that value. Releasing at the +/// last-completed serial would free memory a recorded-but-unsubmitted command +/// buffer still reads. +/// +internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue, IDisposable +{ + /// Plan §4.8: two frames in flight. + internal const int DefaultFramesInFlight = 2; + + private readonly IVulkanTimelineApi _timeline; + private readonly SortedDictionary> _retirements = []; + + private long _openSerial; + private long _submittedSerial; + private bool _disposed; + + internal VulkanFrameFlightController( + IVulkanTimelineApi timeline, + int framesInFlight = DefaultFramesInFlight) + { + _timeline = timeline ?? throw new ArgumentNullException(nameof(timeline)); + ArgumentOutOfRangeException.ThrowIfLessThan(framesInFlight, 1); + SlotCount = framesInFlight; + } + + /// Frames in flight, and therefore ring/command-pool slots. + internal int SlotCount { get; } + + /// Serial of the frame currently being recorded, or 0 when none is open. + internal long OpenSerial => _openSerial; + + /// Highest serial handed to . + internal long SubmittedSerial => _submittedSerial; + + /// Flight slot index of the currently open frame. + internal int CurrentSlot => SlotIndexOf(_openSerial); + + internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count); + + /// Maps a frame serial onto its flight slot. Serials are 1-based. + internal int SlotIndexOf(long serial) => + serial <= 0 ? 0 : (int)((serial - 1) % SlotCount); + + /// + /// Opens the next frame: waits until its flight slot's previous occupant has + /// completed on the GPU, runs everything that retirement made due, and + /// returns the new serial. + /// + internal long BeginFrame() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_openSerial != 0) + { + throw new InvalidOperationException( + $"Frame {_openSerial} is still open; call EndFrame before beginning another."); + } + + long serial = _submittedSerial + 1; + long mustComplete = serial - SlotCount; + if (mustComplete > 0) + _timeline.Wait((ulong)mustComplete); + + _openSerial = serial; + RunRetirements(); + return serial; + } + + /// Records that the open frame has been submitted with its serial as the timeline signal value. + internal void EndFrame() + { + if (_openSerial == 0) + return; + _submittedSerial = _openSerial; + _openSerial = 0; + } + + /// + /// Files to run once every frame that could still + /// reference the resource has completed. See the class remarks for why the + /// key is the open (or next) serial rather than the last completed one. + /// + public void Retire(Action release) + { + ArgumentNullException.ThrowIfNull(release); + if (_disposed) + { + // Teardown already drained the ledger; running immediately is the + // only way this release ever happens, and by then the device is idle. + release(); + return; + } + + long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1; + if (!_retirements.TryGetValue(key, out List? actions)) + { + actions = []; + _retirements.Add(key, actions); + } + + actions.Add(release); + } + + /// Runs every retirement whose frame the GPU has completed. + internal void RunRetirements() + { + if (_retirements.Count == 0) + return; + + var completed = (long)_timeline.CurrentValue; + while (_retirements.Count > 0) + { + KeyValuePair> first = _retirements.First(); + if (first.Key > completed) + break; + + _retirements.Remove(first.Key); + foreach (Action release in first.Value) + release(); + } + } + + /// Blocks until every submitted frame has completed, then drains the whole ledger. + internal void WaitForSubmittedWork() + { + if (_submittedSerial > 0) + _timeline.Wait((ulong)_submittedSerial); + DrainAll(); + } + + /// Runs every pending retirement regardless of serial. Only legal when the device is idle. + internal void DrainAll() + { + while (_retirements.Count > 0) + { + KeyValuePair> first = _retirements.First(); + _retirements.Remove(first.Key); + foreach (Action release in first.Value) + release(); + } + } + + public void Dispose() + { + if (_disposed) + return; + DrainAll(); + _disposed = true; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs new file mode 100644 index 00000000..c2e9cad2 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs @@ -0,0 +1,211 @@ +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a: on Vulkan. +/// +/// Host-writable buffers are written, not uploaded. On a +/// buffer is +/// a memcpy into memory the GPU already reads — the driver validation, +/// the copy and the rename tracking that glBufferSubData pays for simply +/// do not happen. On a device-local buffer it stages through +/// instead, which is the only case that costs a +/// transfer. +/// +/// Usage flags are named at creation, so they are named generously. +/// Vulkan requires every consumption to be declared up front and the RHI +/// contract puts and on every buffer, +/// so transfer source and destination are always included. They cost nothing on +/// any implementation acdream targets and the alternative — inferring them from +/// how a buffer happens to be used later — is how a driver error appears months +/// after the code that caused it. +/// +/// Disposal routes through the device's retirement queue rather than +/// destroying immediately, so a submitted frame that still reads this buffer +/// keeps valid memory. +/// +internal sealed unsafe class VulkanGpuBuffer : IGpuBuffer +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly VulkanDeviceMemoryAllocator _allocator; + private readonly VulkanUploadQueue _uploads; + private readonly IGpuResourceRetirementQueue _retirement; + private readonly VulkanAllocation _allocation; + private bool _disposed; + + internal VulkanGpuBuffer( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanDeviceMemoryAllocator allocator, + VulkanUploadQueue uploads, + IGpuResourceRetirementQueue retirement, + VulkanDebugNames debugNames, + in GpuBufferDescription description) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _allocator = allocator ?? throw new ArgumentNullException(nameof(allocator)); + _uploads = uploads ?? throw new ArgumentNullException(nameof(uploads)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + ArgumentException.ThrowIfNullOrWhiteSpace(description.Name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SizeBytes); + + Name = description.Name; + SizeBytes = description.SizeBytes; + Usage = description.Usage; + Residency = description.Residency; + + var create = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = (ulong)description.SizeBytes, + Usage = UsageFlagsOf(description.Usage), + SharingMode = SharingMode.Exclusive, + }; + VulkanInterop.Check( + _vk.CreateBuffer(_device, &create, null, out Buffer handle), + $"vkCreateBuffer ('{description.Name}')"); + Handle = handle; + + try + { + _vk.GetBufferMemoryRequirements(_device, handle, out MemoryRequirements requirements); + _allocation = _allocator.Allocate(requirements, description.Residency, description.Name); + VulkanInterop.Check( + _vk.BindBufferMemory(_device, handle, _allocation.Memory, _allocation.OffsetBytes), + $"vkBindBufferMemory ('{description.Name}')"); + } + catch + { + _vk.DestroyBuffer(_device, handle, null); + throw; + } + + debugNames.NameBuffer(handle, description.Name); + } + + public string Name { get; } + public long SizeBytes { get; } + public GpuBufferUsage Usage { get; } + public GpuMemoryResidency Residency { get; } + + internal Buffer Handle { get; } + + /// True when this buffer's memory is persistently mapped and directly writable. + internal bool IsMapped => _allocation.IsMapped; + + /// The buffer's whole mapped range. Only valid on host-visible residency. + internal Span MappedSpan => _allocation.AsSpan()[..checked((int)SizeBytes)]; + + public void Upload(long offsetBytes, ReadOnlySpan data) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(offsetBytes); + if (data.IsEmpty) + return; + if (offsetBytes + data.Length > SizeBytes) + { + throw new ArgumentOutOfRangeException( + nameof(data), + $"Writing {data.Length} bytes at offset {offsetBytes} exceeds " + + $"'{Name}' ({SizeBytes} bytes)."); + } + + if (_allocation.IsMapped) + { + data.CopyTo(MappedSpan.Slice((int)offsetBytes, data.Length)); + return; + } + + _uploads.StageBufferWrite(Handle, (ulong)offsetBytes, data, Name); + } + + public void CopyTo( + IGpuBuffer destination, + long sourceOffsetBytes, + long destinationOffsetBytes, + long byteCount) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(destination); + if (destination is not VulkanGpuBuffer target) + { + throw new ArgumentException( + "The Vulkan backend can only copy into a Vulkan buffer.", + nameof(destination)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(byteCount); + if (byteCount == 0) + return; + if (sourceOffsetBytes + byteCount > SizeBytes) + throw new ArgumentOutOfRangeException(nameof(byteCount), $"The copy reads past the end of '{Name}'."); + if (destinationOffsetBytes + byteCount > target.SizeBytes) + throw new ArgumentOutOfRangeException(nameof(byteCount), $"The copy writes past the end of '{target.Name}'."); + + _uploads.EnqueueBufferCopy( + Handle, + target.Handle, + (ulong)sourceOffsetBytes, + (ulong)destinationOffsetBytes, + (ulong)byteCount); + } + + public void Read(long offsetBytes, Span destination) + { + ThrowIfDisposed(); + if (Residency != GpuMemoryResidency.HostReadable) + { + throw new InvalidOperationException( + $"'{Name}' has {Residency} residency; only HostReadable buffers can be read back. " + + "This path is diagnostics-only by design."); + } + + if (destination.IsEmpty) + return; + if (offsetBytes + destination.Length > SizeBytes) + throw new ArgumentOutOfRangeException(nameof(destination), $"The read runs past the end of '{Name}'."); + + MappedSpan.Slice((int)offsetBytes, destination.Length).CopyTo(destination); + } + + /// + /// Maps onto Vulkan usage bits. Transfer source + /// and destination are unconditional — see the class remarks. + /// + internal static BufferUsageFlags UsageFlagsOf(GpuBufferUsage usage) + { + BufferUsageFlags flags = BufferUsageFlags.TransferSrcBit | BufferUsageFlags.TransferDstBit; + if (usage.HasFlag(GpuBufferUsage.Vertex)) + flags |= BufferUsageFlags.VertexBufferBit; + if (usage.HasFlag(GpuBufferUsage.Index)) + flags |= BufferUsageFlags.IndexBufferBit; + if (usage.HasFlag(GpuBufferUsage.Storage)) + flags |= BufferUsageFlags.StorageBufferBit; + if (usage.HasFlag(GpuBufferUsage.Uniform)) + flags |= BufferUsageFlags.UniformBufferBit; + if (usage.HasFlag(GpuBufferUsage.Indirect)) + flags |= BufferUsageFlags.IndirectBufferBit; + return flags; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + Buffer handle = Handle; + VulkanAllocation allocation = _allocation; + _retirement.Retire(() => + { + _vk.DestroyBuffer(_device, handle, null); + _allocator.Free(allocation); + }); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs new file mode 100644 index 00000000..ea6842fb --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -0,0 +1,67 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// The half of that owns sampled resources, +/// pipelines and passes. +/// +/// Split into its own file because the three V6 commits divide along +/// exactly this line: V6a lands memory, buffers, rings and the frame timeline — +/// everything in VulkanGpuDevice.cs — while textures, the descriptor +/// table and render targets arrive at V6b and pipelines, passes and readback at +/// V6c. Until each lands, the corresponding contract member throws with the +/// slice named, rather than returning something that would fail later and +/// further away. +/// +internal sealed unsafe partial class VulkanGpuDevice +{ + private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory) + { + _ = shaderSpirvDirectory; + _ = pipelineCacheDirectory; + } + + private void BeginFrameResources(int slotIndex) => _ = slotIndex; + + private void EndFrameResources(int slotIndex, CommandBuffer commands) + { + _ = slotIndex; + _ = commands; + } + + private void DisposeResources() + { + } + + public IGpuTimerPool Timers => throw NotYet("GPU timer scopes", "V6c"); + + public GpuTextureSlot DefaultTextureSlot => throw NotYet("the default 1x1 white table slot", "V6b"); + + public IGpuTexture CreateTexture(in GpuTextureDescription description) => + throw NotYet($"texture creation ('{description.Name}')", "V6b"); + + public IGpuSampler CreateSampler(in GpuSamplerDescription description) => + throw NotYet("sampler creation", "V6b"); + + public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) => + throw NotYet($"render targets ('{description.Name}')", "V6b"); + + public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) => + throw NotYet("the global texture table", "V6b"); + + public void ReleaseTextureSlot(GpuTextureSlot slot) => + throw NotYet("the global texture table", "V6b"); + + public IGpuPipeline CreatePipeline(GpuPipelineDescription description) => + throw NotYet($"pipelines ('{description?.Name}')", "V6c"); + + internal IGpuPassEncoder BeginPass(VulkanGpuFrame frame, GpuPassDescription description) => + throw NotYet($"render passes ('{description.Name}')", "V6c"); + + public byte[] CaptureBackbuffer(int width, int height) => + throw NotYet("backbuffer capture", "V6c"); + + private static NotSupportedException NotYet(string what, string slice) => + new($"The Vulkan backend does not implement {what} yet; it lands at Campaign V slice {slice}."); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs new file mode 100644 index 00000000..1ddaee37 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs @@ -0,0 +1,572 @@ +using Silk.NET.Vulkan; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Raised when the swapchain must be rebuilt before another frame can be +/// recorded. Thrown rather than returned by +/// because the pinned contract has no "try" shape; hosts that own the swapchain +/// call and never see it. +/// +internal sealed class VulkanSwapchainOutOfDateException(string message) : InvalidOperationException(message); + +/// +/// What needs from whoever owns the swapchain. +/// The device deliberately does not own presentation: format/extent/present-mode +/// selection and the OUT_OF_DATE/SUBOPTIMAL policy are slice V5's, already pure +/// and unit-tested, and duplicating that judgement inside the RHI would fork it. +/// +internal interface IVulkanBackbuffer +{ + Format ImageFormat { get; } + + uint Width { get; } + + uint Height { get; } + + /// Acquires the next image. False means the caller must recreate and skip the frame. + bool TryAcquire(Semaphore acquired, out uint imageIndex); + + Image ImageAt(uint imageIndex); + + ImageView ViewAt(uint imageIndex); + + /// The semaphore a submit rendering into must signal. + Semaphore RenderCompleteAt(uint imageIndex); + + /// Presents. False means the caller should recreate before the next frame. + bool Present(uint imageIndex); +} + +/// +/// Campaign V slice V6: on Vulkan 1.3. +/// +/// The second implementation of the contract pinned at V0. Where the GL +/// backend is deliberately behaviour-preserving — it keeps +/// BufferSubData, it emulates the texture table with a buffer of bindless +/// handles — this one is where the campaign's efficiency actually lands: +/// per-frame data is written straight into persistently mapped memory the GPU +/// reads, the texture table is a real update-after-bind descriptor array with no +/// per-frame writes at all, and every pipeline is built once at startup so no +/// frame ever compiles a shader. +/// +/// Target: null means the swapchain image, literally. Plan §5.4: +/// the GL backend currently inherits whatever framebuffer is bound for a +/// null-target pass, which is a transitional behaviour that exists because +/// PrivateEntityViewportRenderer and PortalTunnelPresentation bind +/// their own FBO and then call into a ported renderer. Vulkan has no ambient +/// framebuffer to inherit and this backend never pretends otherwise: a null +/// target is the acquired swapchain image, or the multisampled scratch that +/// resolves into it. The two backends are never live in the same process, and +/// V4h owes the removal of the GL divergence. +/// +/// Frame skeleton (plan §4.8): wait the timeline back to the flight +/// window, run retirements, reset the slot's command pool, acquire, record +/// [uploads → offscreen passes → main pass], barrier to PRESENT_SRC, submit +/// signalling both the per-image render-complete semaphore and the timeline at +/// this frame's serial, present. +/// +internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice +{ + /// Per-flight-slot ring capacity, matching the GL backend's 16 MiB. + internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024; + + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly PhysicalDevice _physicalDevice; + private readonly Device _device; + private readonly Queue _graphicsQueue; + private readonly Queue _presentQueue; + private readonly uint _graphicsFamily; + private readonly IVulkanBackbuffer? _backbuffer; + + private readonly VulkanDeviceMemoryAllocator _allocator; + private readonly VulkanUploadQueue _uploads; + private readonly VulkanFrameFlightController _flights; + private readonly VulkanDebugNames _debugNames; + private readonly Semaphore _timeline; + + private readonly CommandPool[] _commandPools; + private readonly CommandBuffer[] _commandBuffers; + private readonly Semaphore[] _imageAcquired; + + private readonly VulkanRingBufferState[] _ringStates; + private readonly VulkanGpuBuffer[] _ringBuffers; + + private readonly List _queuedActions = []; + + private VulkanGpuFrame? _openFrame; + private uint? _acquiredImageIndex; + private bool _disposed; + + internal VulkanGpuDevice( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + Queue graphicsQueue, + Queue presentQueue, + uint graphicsFamily, + VulkanDeviceFeatureSupport features, + VulkanDeviceLimitSupport limits, + VulkanFormatSupport formats, + string deviceName, + string driverInfo, + string apiVersion, + VulkanDebugNames debugNames, + IVulkanBackbuffer? backbuffer = null, + string? shaderSpirvDirectory = null, + string? pipelineCacheDirectory = null, + int ringCapacityBytesPerSlot = DefaultRingCapacityBytesPerSlot, + int framesInFlight = VulkanFrameFlightController.DefaultFramesInFlight) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(limits); + ArgumentNullException.ThrowIfNull(formats); + _physicalDevice = physicalDevice; + _device = device; + _graphicsQueue = graphicsQueue; + _presentQueue = presentQueue; + _graphicsFamily = graphicsFamily; + _backbuffer = backbuffer; + _debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames)); + DepthStencilFormat = formats.DepthStencilFormat; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ringCapacityBytesPerSlot); + + Capabilities = new GpuCapabilityRecord + { + Backend = GpuBackendKind.Vulkan, + DeviceName = deviceName, + DriverInfo = driverInfo, + ApiVersion = apiVersion, + MaxTextureTableSlots = Math.Min( + limits.MaxDescriptorSetUpdateAfterBindSampledImages, + limits.MaxPerStageDescriptorUpdateAfterBindSampledImages), + MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, + MaxPushConstantBytes = limits.MaxPushConstantsSize, + MinStorageBufferOffsetAlignment = Math.Max(limits.MinStorageBufferOffsetAlignment, 1), + MinUniformBufferOffsetAlignment = Math.Max(limits.MinUniformBufferOffsetAlignment, 1), + MaxClipDistances = limits.MaxClipDistances, + MaxSampleCount = limits.MaxColorSampleCount, + SupportsMultiDrawIndirect = features.MultiDrawIndirect, + SupportsDrawParameters = features.ShaderDrawParameters, + SupportsTextureCompressionBc = + features.TextureCompressionBc && formats.Bc1Sampled && formats.Bc2Sampled && formats.Bc3Sampled, + SupportsTimestampQueries = limits.TimestampComputeAndGraphics, + // The one capability that is true here and false on GL, and the + // mechanism behind the campaign's CPU-cost target. + SupportsPersistentlyMappedRings = true, + }; + + var timelineType = new SemaphoreTypeCreateInfo + { + SType = StructureType.SemaphoreTypeCreateInfo, + SemaphoreType = SemaphoreType.Timeline, + InitialValue = 0, + }; + var timelineCreate = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + PNext = &timelineType, + }; + VulkanInterop.Check( + _vk.CreateSemaphore(_device, &timelineCreate, null, out _timeline), + "vkCreateSemaphore (RHI frame timeline)"); + + _flights = new VulkanFrameFlightController( + new VulkanTimelineApi(_vk, _device, _timeline), + framesInFlight); + _allocator = new VulkanDeviceMemoryAllocator(_vk, physicalDevice, _device); + _uploads = new VulkanUploadQueue(_vk, _device, _allocator, _flights, _debugNames); + + int slots = _flights.SlotCount; + _commandPools = new CommandPool[slots]; + _commandBuffers = new CommandBuffer[slots]; + _imageAcquired = new Semaphore[slots]; + _ringStates = new VulkanRingBufferState[slots]; + _ringBuffers = new VulkanGpuBuffer[slots]; + for (int slot = 0; slot < slots; slot++) + { + var poolCreate = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = graphicsFamily, + }; + VulkanInterop.Check( + _vk.CreateCommandPool(_device, &poolCreate, null, out CommandPool pool), + "vkCreateCommandPool (RHI flight slot)"); + _commandPools[slot] = pool; + + var allocate = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = pool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + VulkanInterop.Check( + _vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands), + "vkAllocateCommandBuffers (RHI flight slot)"); + _commandBuffers[slot] = commands; + + var semaphoreCreate = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; + VulkanInterop.Check( + _vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore acquired), + "vkCreateSemaphore (RHI image acquired)"); + _imageAcquired[slot] = acquired; + + _ringStates[slot] = new VulkanRingBufferState((ulong)ringCapacityBytesPerSlot); + _ringBuffers[slot] = new VulkanGpuBuffer( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + new GpuBufferDescription( + $"vk-ring-slot-{slot}", + ringCapacityBytesPerSlot, + GpuBufferUsage.Storage + | GpuBufferUsage.Uniform + | GpuBufferUsage.Indirect + | GpuBufferUsage.Vertex + | GpuBufferUsage.Index, + GpuMemoryResidency.HostWritable)); + + if (!_ringBuffers[slot].IsMapped) + { + throw new NotSupportedException( + "The Vulkan per-frame ring must live in host-visible memory; this device " + + "offered no host-visible type for a HostWritable buffer."); + } + } + + InitialiseResources(shaderSpirvDirectory, pipelineCacheDirectory); + } + + public GpuBackendKind Backend => GpuBackendKind.Vulkan; + + public GpuCapabilityRecord Capabilities { get; } + + public IGpuResourceRetirementQueue Retirement => _flights; + + /// Depth+stencil format chosen by the V5 gate: D32_SFLOAT_S8_UINT, falling back to D24_UNORM_S8_UINT. + internal Format DepthStencilFormat { get; } + + internal Silk.NET.Vulkan.Vk Api => _vk; + + internal Device Handle => _device; + + internal VulkanDeviceMemoryAllocator Allocator => _allocator; + + internal VulkanUploadQueue Uploads => _uploads; + + internal VulkanDebugNames DebugNames => _debugNames; + + internal VulkanFrameFlightController Flights => _flights; + + /// Command buffer of the open frame. Only valid between BeginFrame and End. + internal CommandBuffer CurrentCommands => _commandBuffers[_flights.CurrentSlot]; + + public IGpuBuffer CreateBuffer(in GpuBufferDescription description) + { + ThrowIfDisposed(); + return new VulkanGpuBuffer(_vk, _device, _allocator, _uploads, _flights, _debugNames, description); + } + + 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(); + } + + /// + /// Opens the next frame. Throws when the swapchain must be recreated first; + /// hosts that own the swapchain use instead. + /// + public IGpuFrame BeginFrame() => + TryBeginFrame(out IGpuFrame? frame) && frame is not null + ? frame + : throw new VulkanSwapchainOutOfDateException( + "The swapchain is out of date and must be recreated before another frame is recorded."); + + /// + /// Opens the next frame, returning false when the swapchain image could not + /// be acquired and the caller must recreate and skip. The flight slot is + /// waited on BEFORE the acquire, so the slot's acquire semaphore is provably + /// idle — signalling a semaphore that a pending submit still waits on is the + /// classic way to deadlock a Vulkan renderer. + /// + internal bool TryBeginFrame(out IGpuFrame? frame) + { + ThrowIfDisposed(); + frame = null; + if (_openFrame is not null) + throw new InvalidOperationException("A frame is already open; end it before beginning another."); + + long serial = _flights.BeginFrame(); + int slot = _flights.CurrentSlot; + + _uploads.ReleaseCompleted(CompletedSerial()); + _ringStates[slot].Reset(); + + _acquiredImageIndex = null; + if (_backbuffer is not null) + { + if (!_backbuffer.TryAcquire(_imageAcquired[slot], out uint imageIndex)) + { + // Release the serial by submitting nothing: the timeline must + // still reach this value or the next BeginFrame waits forever. + SignalTimelineWithoutWork(serial); + _flights.EndFrame(); + return false; + } + + _acquiredImageIndex = imageIndex; + } + + VulkanInterop.Check( + _vk.ResetCommandPool(_device, _commandPools[slot], 0), + "vkResetCommandPool"); + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanInterop.Check( + _vk.BeginCommandBuffer(_commandBuffers[slot], &begin), + "vkBeginCommandBuffer (frame)"); + + BeginFrameResources(slot); + + var opened = new VulkanGpuFrame(this, slot, serial); + _openFrame = opened; + frame = opened; + return true; + } + + private long CompletedSerial() + { + VulkanInterop.Check( + _vk.GetSemaphoreCounterValue(_device, _timeline, out ulong value), + "vkGetSemaphoreCounterValue (upload release)"); + return (long)value; + } + + internal GpuRingAllocation AllocateRing(int slotIndex, int byteCount, GpuRingUsage usage) + { + ThrowIfDisposed(); + ulong alignment = usage switch + { + GpuRingUsage.Storage => Capabilities.MinStorageBufferOffsetAlignment, + GpuRingUsage.Uniform => Capabilities.MinUniformBufferOffsetAlignment, + GpuRingUsage.Indirect => 4, + // 16 covers every vertex stride and index type acdream uses and is + // what the staging path already aligns to, so one number rather + // than a per-format table. + _ => 16, + }; + + ulong offset = _ringStates[slotIndex].Allocate(byteCount, alignment); + VulkanGpuBuffer buffer = _ringBuffers[slotIndex]; + Span data = byteCount == 0 + ? Span.Empty + : buffer.MappedSpan.Slice((int)offset, byteCount); + return new GpuRingAllocation(buffer, (uint)offset, data); + } + + /// + /// Closes the frame: finishes any open pass work, records outstanding + /// uploads, submits with the timeline signalled at this frame's serial, and + /// presents when a backbuffer image was acquired. + /// + internal void EndFrame(VulkanGpuFrame frame) + { + if (!ReferenceEquals(_openFrame, frame)) + return; + + int slot = frame.SlotIndex; + CommandBuffer commands = _commandBuffers[slot]; + + EndFrameResources(slot, commands); + + // Anything queued after the last pass still belongs in this submission: + // it is correct, ordered, and saves it waiting a whole frame. + _uploads.Record(commands); + + if (_acquiredImageIndex is { } imageIndex && _backbuffer is not null) + { + TransitionBackbufferForPresent(commands, _backbuffer.ImageAt(imageIndex)); + } + + VulkanInterop.Check(_vk.EndCommandBuffer(commands), "vkEndCommandBuffer (frame)"); + + var commandSubmit = new CommandBufferSubmitInfo + { + SType = StructureType.CommandBufferSubmitInfo, + CommandBuffer = commands, + }; + var waitSemaphore = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _imageAcquired[slot], + StageMask = PipelineStageFlags2.ColorAttachmentOutputBit, + }; + SemaphoreSubmitInfo* signals = stackalloc SemaphoreSubmitInfo[2]; + int signalCount = 0; + if (_acquiredImageIndex is { } presented && _backbuffer is not null) + { + signals[signalCount++] = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _backbuffer.RenderCompleteAt(presented), + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + } + + signals[signalCount++] = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _timeline, + Value = (ulong)frame.Serial, + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + + var submit = new SubmitInfo2 + { + SType = StructureType.SubmitInfo2, + WaitSemaphoreInfoCount = _acquiredImageIndex is null ? 0u : 1u, + PWaitSemaphoreInfos = _acquiredImageIndex is null ? null : &waitSemaphore, + CommandBufferInfoCount = 1, + PCommandBufferInfos = &commandSubmit, + SignalSemaphoreInfoCount = (uint)signalCount, + PSignalSemaphoreInfos = signals, + }; + VulkanInterop.Check( + _vk.QueueSubmit2(_graphicsQueue, 1, &submit, default), + "vkQueueSubmit2 (frame)"); + + _flights.EndFrame(); + _openFrame = null; + + if (_acquiredImageIndex is { } toPresent && _backbuffer is not null) + { + PresentSucceeded = _backbuffer.Present(toPresent); + _acquiredImageIndex = null; + } + } + + /// False after a present that reported the swapchain should be rebuilt. + internal bool PresentSucceeded { get; private set; } = true; + + private void TransitionBackbufferForPresent(CommandBuffer commands, Image image) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.ColorAttachmentOutputBit, + SrcAccessMask = AccessFlags2.ColorAttachmentWriteBit, + DstStageMask = PipelineStageFlags2.BottomOfPipeBit, + DstAccessMask = AccessFlags2.None, + OldLayout = ImageLayout.ColorAttachmentOptimal, + NewLayout = ImageLayout.PresentSrcKhr, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + /// + /// Submits nothing but the timeline signal. Used when a frame is abandoned + /// because its swapchain image could not be acquired: the serial must still + /// complete or every later frame waits on a value nothing signals. + /// + private void SignalTimelineWithoutWork(long serial) + { + var signal = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _timeline, + Value = (ulong)serial, + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + var submit = new SubmitInfo2 + { + SType = StructureType.SubmitInfo2, + SignalSemaphoreInfoCount = 1, + PSignalSemaphoreInfos = &signal, + }; + VulkanInterop.Check( + _vk.QueueSubmit2(_graphicsQueue, 1, &submit, default), + "vkQueueSubmit2 (abandoned frame timeline signal)"); + } + + public void WaitIdle() + { + if (_disposed) + return; + VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle"); + _flights.WaitForSubmittedWork(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _vk.DeviceWaitIdle(_device); + DisposeResources(); + _flights.DrainAll(); + + foreach (VulkanGpuBuffer ring in _ringBuffers) + ring.Dispose(); + _flights.DrainAll(); + + foreach (Semaphore semaphore in _imageAcquired) + { + if (semaphore.Handle != 0) + _vk.DestroySemaphore(_device, semaphore, null); + } + + foreach (CommandPool pool in _commandPools) + { + if (pool.Handle != 0) + _vk.DestroyCommandPool(_device, pool, null); + } + + _uploads.Dispose(); + _flights.DrainAll(); + + if (_timeline.Handle != 0) + _vk.DestroySemaphore(_device, _timeline, null); + + _flights.Dispose(); + _allocator.Dispose(); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs new file mode 100644 index 00000000..3ec2eec2 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs @@ -0,0 +1,66 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a: one frame's recording context on Vulkan. +/// +/// Thin on purpose. The frame owns no Vulkan objects: the command buffer, +/// the ring and the timeline all belong to , which +/// keeps them alive across frames and hands this object the slot index they are +/// addressed by. What is here is the contract's shape — one open pass at a time, +/// idempotent . +/// +/// being idempotent with Dispose is +/// load-bearing rather than tidy: a renderer that throws mid-frame must still +/// close its flight slot, or the ring never rewinds and the next +/// BeginFrame waits on a timeline value nothing will ever signal. +/// +internal sealed class VulkanGpuFrame : IGpuFrame +{ + private readonly VulkanGpuDevice _device; + private IGpuPassEncoder? _openPass; + private bool _ended; + + internal VulkanGpuFrame(VulkanGpuDevice device, int slotIndex, long serial) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + SlotIndex = slotIndex; + Serial = serial; + } + + public int SlotIndex { get; } + + public long Serial { get; } + + public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => + _device.AllocateRing(SlotIndex, byteCount, usage); + + public IGpuPassEncoder BeginPass(GpuPassDescription description) + { + ArgumentNullException.ThrowIfNull(description); + if (_openPass is not null) + { + throw new InvalidOperationException( + "A pass is already open on this frame; dispose it before beginning another."); + } + + IGpuPassEncoder encoder = _device.BeginPass(this, description); + _openPass = encoder; + return encoder; + } + + internal void ClosePass(IGpuPassEncoder encoder) + { + if (ReferenceEquals(_openPass, encoder)) + _openPass = null; + } + + public void End() + { + if (_ended) + return; + _ended = true; + _device.EndFrame(this); + } + + public void Dispose() => End(); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanMemoryModel.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanMemoryModel.cs new file mode 100644 index 00000000..53b53e97 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanMemoryModel.cs @@ -0,0 +1,411 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// One suballocation: which block it came from and where inside it. A +/// range owns its whole block, which is how +/// allocations at or above the dedicated threshold are expressed without a +/// second code path. +/// +internal readonly record struct VulkanMemoryRange( + int BlockIndex, + ulong OffsetBytes, + ulong SizeBytes, + bool IsDedicated); + +/// +/// Campaign V slice V6a, plan §4.2: the free list inside one device-memory +/// block. +/// +/// A first-fit list of free ranges, coalescing on release. It is +/// deliberately pure — no VkDeviceMemory, no driver call — because the +/// interesting failure modes of an allocator (fragmentation, a mis-coalesced +/// neighbour, an alignment that eats the tail of a block) are arithmetic, and +/// arithmetic can be tested with no GPU in the room. The driver-facing half +/// lives in and does nothing this +/// class has not already decided. +/// +/// First fit rather than best fit is a considered choice, not laziness: +/// acdream's allocation profile is a handful of long-lived giants (two mesh +/// arena buffers, the staging ring, the per-flight ring buffers) plus a texture +/// pool whose members are all similar sizes. Best fit buys its reduced external +/// fragmentation by leaving many tiny unusable holes, which is the worse trade +/// for exactly that profile. +/// +internal sealed class VulkanMemoryBlockFreeList +{ + private readonly List _free = []; + + private struct Range(ulong offset, ulong size) + { + public ulong Offset = offset; + public ulong Size = size; + public readonly ulong End => Offset + Size; + } + + internal VulkanMemoryBlockFreeList(ulong capacityBytes) + { + if (capacityBytes == 0) + throw new ArgumentOutOfRangeException(nameof(capacityBytes), "A memory block cannot be empty."); + CapacityBytes = capacityBytes; + _free.Add(new Range(0, capacityBytes)); + } + + internal ulong CapacityBytes { get; } + + /// Bytes currently handed out, including alignment padding left in front of a range. + internal ulong UsedBytes { get; private set; } + + internal ulong FreeBytes => CapacityBytes - UsedBytes; + + /// Largest single allocation this block could still satisfy at alignment 1. + internal ulong LargestFreeBytes + { + get + { + ulong largest = 0; + foreach (Range range in _free) + largest = Math.Max(largest, range.Size); + return largest; + } + } + + internal int FreeRangeCount => _free.Count; + + /// + /// First-fit placement. The aligned offset can sit above a free range's + /// start, in which case the padding in front stays part of the allocation + /// (it is released with it) rather than becoming an unreachable hole. + /// + internal bool TryAllocate(ulong sizeBytes, ulong alignmentBytes, out ulong offsetBytes) + { + offsetBytes = 0; + if (sizeBytes == 0) + return false; + + for (int i = 0; i < _free.Count; i++) + { + Range range = _free[i]; + ulong aligned = AlignUp(range.Offset, alignmentBytes); + ulong padding = aligned - range.Offset; + if (padding > range.Size || range.Size - padding < sizeBytes) + continue; + + // The allocation owns [range.Offset, aligned + size) so releasing it + // returns the alignment padding too. + ulong consumed = padding + sizeBytes; + if (consumed == range.Size) + { + _free.RemoveAt(i); + } + else + { + range.Offset += consumed; + range.Size -= consumed; + _free[i] = range; + } + + UsedBytes += consumed; + offsetBytes = aligned; + AllocatedPaddingByOffset[aligned] = padding; + return true; + } + + return false; + } + + /// + /// Alignment padding carried in front of each live allocation, so + /// can return the same bytes + /// took. Keyed by the aligned offset the caller was handed, which is the + /// only value the caller ever sees. + /// + private Dictionary AllocatedPaddingByOffset { get; } = []; + + /// Returns at to the free list, coalescing neighbours. + internal void Free(ulong offsetBytes, ulong sizeBytes) + { + if (sizeBytes == 0) + return; + + ulong padding = 0; + if (AllocatedPaddingByOffset.Remove(offsetBytes, out ulong recorded)) + padding = recorded; + + ulong start = offsetBytes - padding; + ulong end = offsetBytes + sizeBytes; + ulong length = end - start; + if (length > UsedBytes) + { + throw new InvalidOperationException( + $"Releasing [{start}, {end}) would free more than the {UsedBytes} bytes this block " + + "has handed out — the same range was probably released twice."); + } + + UsedBytes -= length; + + int insertAt = _free.FindIndex(range => range.Offset > start); + if (insertAt < 0) + insertAt = _free.Count; + _free.Insert(insertAt, new Range(start, length)); + Coalesce(insertAt); + } + + private void Coalesce(int index) + { + if (index > 0 && _free[index - 1].End == _free[index].Offset) + { + Range merged = _free[index - 1]; + merged.Size += _free[index].Size; + _free[index - 1] = merged; + _free.RemoveAt(index); + index--; + } + + if (index + 1 < _free.Count && _free[index].End == _free[index + 1].Offset) + { + Range merged = _free[index]; + merged.Size += _free[index + 1].Size; + _free[index] = merged; + _free.RemoveAt(index + 1); + } + } + + internal static ulong AlignUp(ulong value, ulong alignmentBytes) => + alignmentBytes <= 1 ? value : (value + alignmentBytes - 1) / alignmentBytes * alignmentBytes; +} + +/// +/// Campaign V slice V6a, plan §4.2: every block belonging to one Vulkan memory +/// type, and the policy that decides when a request gets a block of its own. +/// +/// Pure by the same argument as : +/// answers "no" rather than allocating, and the +/// driver-facing owner responds by calling with real +/// device memory and asking again. That keeps every placement decision +/// reachable from a unit test. +/// +internal sealed class VulkanMemoryTypePool +{ + /// Plan §4.2's block size: 128 MiB device-local blocks per memory type. + internal const ulong DefaultBlockSizeBytes = 128UL * 1024 * 1024; + + /// Plan §4.2: allocations at or above this size take a block of their own. + internal const ulong DefaultDedicatedThresholdBytes = 32UL * 1024 * 1024; + + private readonly List _blocks = []; + private readonly HashSet _dedicatedBlocks = []; + + internal VulkanMemoryTypePool( + uint memoryTypeIndex, + ulong blockSizeBytes = DefaultBlockSizeBytes, + ulong dedicatedThresholdBytes = DefaultDedicatedThresholdBytes) + { + ArgumentOutOfRangeException.ThrowIfZero(blockSizeBytes); + ArgumentOutOfRangeException.ThrowIfZero(dedicatedThresholdBytes); + MemoryTypeIndex = memoryTypeIndex; + BlockSizeBytes = blockSizeBytes; + DedicatedThresholdBytes = dedicatedThresholdBytes; + } + + internal uint MemoryTypeIndex { get; } + + internal ulong BlockSizeBytes { get; } + + internal ulong DedicatedThresholdBytes { get; } + + internal int BlockCount => _blocks.Count; + + /// Blocks that still exist, i.e. have been added and not retired. + internal int LiveBlockCount => _blocks.Count(block => block is not null); + + internal bool IsDedicatedSize(ulong sizeBytes) => sizeBytes >= DedicatedThresholdBytes; + + /// Capacity a new block must have to satisfy . + internal ulong BlockCapacityFor(ulong sizeBytes) => + IsDedicatedSize(sizeBytes) ? sizeBytes : BlockSizeBytes; + + /// + /// Places in an existing block. Returns false + /// when the caller must create a new block of + /// bytes and retry. + /// + internal bool TryAllocate(ulong sizeBytes, ulong alignmentBytes, out VulkanMemoryRange range) + { + range = default; + if (sizeBytes == 0) + return false; + + // A dedicated-size request never shares: hunting for a hole big enough + // in a shared block would find one only in a nearly empty block, and + // taking it would strand the rest. + if (IsDedicatedSize(sizeBytes)) + return false; + + for (int i = 0; i < _blocks.Count; i++) + { + if (_blocks[i] is not { } block || _dedicatedBlocks.Contains(i)) + continue; + if (block.TryAllocate(sizeBytes, alignmentBytes, out ulong offset)) + { + range = new VulkanMemoryRange(i, offset, sizeBytes, IsDedicated: false); + return true; + } + } + + return false; + } + + /// Registers a newly created block and returns its index. + internal int AddBlock(ulong capacityBytes, bool dedicated) + { + _blocks.Add(new VulkanMemoryBlockFreeList(capacityBytes)); + int index = _blocks.Count - 1; + if (dedicated) + _dedicatedBlocks.Add(index); + return index; + } + + /// Claims the whole of a block that was created for one dedicated allocation. + internal VulkanMemoryRange AllocateWholeBlock(int blockIndex, ulong sizeBytes) + { + VulkanMemoryBlockFreeList block = BlockAt(blockIndex); + if (!block.TryAllocate(sizeBytes, 1, out ulong offset) || offset != 0) + { + throw new InvalidOperationException( + $"Block {blockIndex} was created for a dedicated {sizeBytes}-byte allocation " + + "but could not satisfy it at offset 0."); + } + + return new VulkanMemoryRange(blockIndex, 0, sizeBytes, IsDedicated: true); + } + + /// + /// Releases a range. Returns true when the block became completely empty + /// AND is dedicated, which is the caller's signal to free the underlying + /// VkDeviceMemory; shared blocks are kept for reuse. + /// + internal bool Free(in VulkanMemoryRange range) + { + VulkanMemoryBlockFreeList block = BlockAt(range.BlockIndex); + block.Free(range.OffsetBytes, range.SizeBytes); + if (block.UsedBytes != 0 || !_dedicatedBlocks.Contains(range.BlockIndex)) + return false; + + _blocks[range.BlockIndex] = null; + _dedicatedBlocks.Remove(range.BlockIndex); + return true; + } + + internal ulong UsedBytes + { + get + { + ulong total = 0; + foreach (VulkanMemoryBlockFreeList? block in _blocks) + total += block?.UsedBytes ?? 0; + return total; + } + } + + internal ulong CapacityBytes + { + get + { + ulong total = 0; + foreach (VulkanMemoryBlockFreeList? block in _blocks) + total += block?.CapacityBytes ?? 0; + return total; + } + } + + private VulkanMemoryBlockFreeList BlockAt(int index) => + index >= 0 && index < _blocks.Count && _blocks[index] is { } block + ? block + : throw new ArgumentOutOfRangeException( + nameof(index), + $"Memory block {index} does not exist in the pool for memory type {MemoryTypeIndex}."); +} + +/// +/// Campaign V slice V6a, plan §4.3: which Vulkan memory type backs each +/// . +/// +/// The row is the one that +/// earns the campaign's CPU win. It prefers a type that is BOTH device-local and +/// host-visible — resizable BAR, present on the RX 9070 XT, on RADV and on +/// modern NVIDIA — so per-frame data is written once, straight into memory the +/// GPU reads. When no such type exists the fallback is ordinary host-visible +/// coherent memory, which still beats BufferSubData but gives up the +/// device-local read. +/// +/// Pure and table-driven so the preference order is a fact a test can +/// assert against a synthesised VkPhysicalDeviceMemoryProperties, rather +/// than something only the RX 9070 XT can confirm. +/// +internal static class VulkanMemoryTypeSelection +{ + /// The property masks tried in order for a residency class. First match wins. + internal static IReadOnlyList PreferenceOrder(GpuMemoryResidency residency) => + residency switch + { + GpuMemoryResidency.DeviceLocal => + [ + MemoryPropertyFlags.DeviceLocalBit, + 0, + ], + GpuMemoryResidency.HostWritable => + [ + // ReBAR: device-local AND host-visible. The whole point of §4.3. + MemoryPropertyFlags.DeviceLocalBit + | MemoryPropertyFlags.HostVisibleBit + | MemoryPropertyFlags.HostCoherentBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + MemoryPropertyFlags.HostVisibleBit, + ], + GpuMemoryResidency.HostReadable => + [ + // Cached memory is dramatically faster to read back on the CPU; + // coherent-only is correct but slow, so it is the fallback. + MemoryPropertyFlags.HostVisibleBit + | MemoryPropertyFlags.HostCoherentBit + | MemoryPropertyFlags.HostCachedBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + MemoryPropertyFlags.HostVisibleBit, + ], + _ => throw new ArgumentOutOfRangeException(nameof(residency), residency, "Unknown residency class."), + }; + + /// Whether a residency class must end up on memory the CPU can map. + internal static bool RequiresMapping(GpuMemoryResidency residency) => + residency is GpuMemoryResidency.HostWritable or GpuMemoryResidency.HostReadable; + + /// + /// Chooses a memory type index for out of the + /// types permits. Returns null when the + /// device offers nothing usable, which the caller turns into a named + /// exception rather than a silent wrong-heap allocation. + /// + internal static uint? Choose( + IReadOnlyList memoryTypeProperties, + uint allowedTypeBits, + GpuMemoryResidency residency) + { + ArgumentNullException.ThrowIfNull(memoryTypeProperties); + + foreach (MemoryPropertyFlags required in PreferenceOrder(residency)) + { + for (int i = 0; i < memoryTypeProperties.Count && i < 32; i++) + { + if ((allowedTypeBits & (1u << i)) == 0) + continue; + if ((memoryTypeProperties[i] & required) != required) + continue; + return (uint)i; + } + } + + return null; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRingBufferState.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRingBufferState.cs new file mode 100644 index 00000000..55095648 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRingBufferState.cs @@ -0,0 +1,189 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a: one flight slot's per-frame upload ring, as pure +/// bookkeeping. +/// +/// Deliberately simpler than its GL sibling +/// (GlRingBufferState), and the difference is the campaign's whole +/// point. On GL a ring allocation writes into a managed staging array which is +/// later copied into a GL buffer, so that class has to track a dirty watermark +/// and prove the copy never overlaps an in-flight read. Here the allocation +/// hands back a span of memory the GPU reads directly, so there is no upload +/// step to track and no dirty range to flush: what is left is a cursor. +/// +/// The forward-only invariant still matters and is still enforced. The +/// slot's buffer is being read by the frame that was submitted from this slot +/// two frames ago; the timeline wait in +/// is what makes rewinding +/// the cursor safe, and may only be called after it. +/// +internal sealed class VulkanRingBufferState +{ + private ulong _cursor; + + internal VulkanRingBufferState(ulong capacityBytes) + { + ArgumentOutOfRangeException.ThrowIfZero(capacityBytes); + CapacityBytes = capacityBytes; + } + + internal ulong CapacityBytes { get; } + + /// Bytes handed out since the last , including alignment padding. + internal ulong AllocatedBytes => _cursor; + + /// High-water mark across the ring's lifetime — the number that says whether the capacity is right. + internal ulong PeakAllocatedBytes { get; private set; } + + /// + /// Reserves bytes at the required alignment. + /// Throws rather than truncating: a silently shortened allocation would + /// corrupt the frame invisibly, and the exception names the capacity to set. + /// + internal ulong Allocate(int byteCount, ulong alignmentBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(byteCount); + ulong aligned = VulkanMemoryBlockFreeList.AlignUp(_cursor, alignmentBytes); + ulong end = aligned + (ulong)byteCount; + if (end > CapacityBytes) + { + throw new InvalidOperationException( + $"Ring allocation of {byteCount} bytes at aligned offset {aligned} needs {end} bytes; " + + $"this flight slot's ring is {CapacityBytes} bytes. Increase the per-slot ring " + + "capacity (VulkanGpuDevice's ringCapacityBytesPerSlot)."); + } + + _cursor = end; + PeakAllocatedBytes = Math.Max(PeakAllocatedBytes, end); + return aligned; + } + + /// + /// Rewinds for a new frame. Only legal once the GPU has finished the frame + /// that last used this slot — the caller proves that with a timeline wait. + /// + internal void Reset() => _cursor = 0; +} + +/// +/// Campaign V slice V6a, plan §4.3: the staging ring's bookkeeping. +/// +/// One persistently mapped host-visible buffer carries every upload +/// destined for device-local memory. Allocations are handed out linearly and +/// released in frame order: a region taken while recording frame N is reclaimed +/// when frame N retires, which is exactly the guarantee the timeline semaphore +/// already provides. +/// +/// It is a genuine ring — the cursor wraps — because uploads are bursty +/// (a landblock arriving is thousands of small copies) and a linear allocator +/// would need a capacity sized for the worst burst rather than the worst +/// in-flight window. Wrapping only happens when the bytes being overwritten +/// belong to a frame the GPU has finished, which is what +/// establishes. +/// +/// When the ring cannot satisfy a request — because it is larger than the +/// whole ring, or because everything still in flight is holding the space — the +/// allocator answers no and the caller takes a temporary dedicated staging +/// buffer retired through the ledger. That is the §4.3 escape for oversized +/// uploads, applied to the one other case that has the same shape. It is not a +/// fallback that hides a problem: the upload is still correct, still ordered, +/// and still freed on retirement; it just costs one extra allocation. +/// +internal sealed class VulkanStagingRingState +{ + private readonly List _pending = []; + private ulong _head; + private ulong _tail; + private ulong _live; + + private readonly record struct PendingSegment(long Serial, ulong SizeBytes); + + internal VulkanStagingRingState(ulong capacityBytes) + { + ArgumentOutOfRangeException.ThrowIfZero(capacityBytes); + CapacityBytes = capacityBytes; + } + + internal ulong CapacityBytes { get; } + + /// Bytes reserved by frames that have not yet retired. + internal ulong LiveBytes => _live; + + internal int PendingSegmentCount => _pending.Count; + + /// + /// Reserves bytes for the frame identified by + /// . Returns false when the ring cannot serve the + /// request without overwriting bytes an unretired frame still owns. + /// + internal bool TryAllocate(int byteCount, ulong alignmentBytes, long serial, out ulong offsetBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(byteCount); + offsetBytes = 0; + if (byteCount == 0) + return true; + + var size = (ulong)byteCount; + if (size > CapacityBytes) + return false; + + ulong aligned = VulkanMemoryBlockFreeList.AlignUp(_head, alignmentBytes); + ulong consumed; + if (aligned + size > CapacityBytes) + { + // Wrapping costs the tail of the ring; charge it to this segment so + // Release returns it with the rest. + ulong wasted = CapacityBytes - _head; + aligned = 0; + consumed = wasted + size; + } + else + { + consumed = (aligned - _head) + size; + } + + if (_live + consumed > CapacityBytes) + return false; + + _head = (aligned + size) % CapacityBytes; + _live += consumed; + offsetBytes = aligned; + + if (_pending.Count > 0 && _pending[^1].Serial == serial) + { + PendingSegment last = _pending[^1]; + _pending[^1] = last with { SizeBytes = last.SizeBytes + consumed }; + } + else + { + _pending.Add(new PendingSegment(serial, consumed)); + } + + return true; + } + + /// Reclaims every segment taken by a frame at or below . + internal void Release(long completedSerial) + { + int released = 0; + while (released < _pending.Count && _pending[released].Serial <= completedSerial) + { + _tail = (_tail + _pending[released].SizeBytes) % CapacityBytes; + _live -= Math.Min(_live, _pending[released].SizeBytes); + released++; + } + + if (released > 0) + _pending.RemoveRange(0, released); + } + + /// Drops all bookkeeping. Only legal when the device is idle. + internal void Reset() + { + _pending.Clear(); + _head = 0; + _tail = 0; + _live = 0; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs new file mode 100644 index 00000000..6c384391 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs @@ -0,0 +1,392 @@ +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a, plan §4.3 and §4.8: every transfer that has been asked +/// for but not yet recorded, plus the staging memory those transfers read from. +/// +/// Why a queue rather than an immediate copy. Uploads arrive from +/// the streaming and texture-cache code at arbitrary moments, including with no +/// frame open at all. Vulkan copies must be recorded into a command buffer, and +/// they must be recorded OUTSIDE a dynamic-rendering block. So requests +/// accumulate here and are drained into the frame's command buffer at the one +/// moment both conditions hold: immediately before a pass begins. That is the +/// direct analogue of the GL backend's "flush immediately before every draw" +/// discipline, moved to the coarser granularity Vulkan actually needs. +/// +/// One batched barrier, not one per copy. The drain emits a single +/// buffer memory barrier covering every copy it recorded, moving the whole batch +/// from transfer writes to the vertex/index/indirect/shader reads that follow. +/// Plan §4.8 budgets four to six barriers per frame; this is one of them. +/// +/// Staging exhaustion is not an error. When the ring cannot serve a +/// request — the payload is larger than the whole ring, or unretired frames hold +/// the space — the upload takes a temporary dedicated staging buffer that is +/// retired through the ledger. The transfer is equally correct and equally +/// ordered; it just costs one allocation. See +/// for why that is a policy rather than a +/// workaround. +/// +internal sealed unsafe class VulkanUploadQueue : IDisposable +{ + /// Plan §4.3: a 48 MiB persistently mapped staging ring. + internal const ulong DefaultStagingCapacityBytes = 48UL * 1024 * 1024; + + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly VulkanDeviceMemoryAllocator _allocator; + private readonly VulkanFrameFlightController _flights; + private readonly VulkanStagingRingState _ringState; + private readonly VulkanDebugNames _debugNames; + + private readonly Buffer _stagingBuffer; + private readonly VulkanAllocation _stagingAllocation; + + private readonly List _bufferCopies = []; + private readonly List _imageCopies = []; + private readonly List _temporaries = []; + + private bool _disposed; + + private readonly record struct BufferCopy2(Buffer Source, Buffer Destination, ulong SourceOffset, ulong DestinationOffset, ulong SizeBytes); + + private readonly record struct ImageCopy2( + Buffer Source, + ulong SourceOffset, + Image Destination, + uint MipLevel, + uint Layer, + uint Width, + uint Height, + uint MipLevelCount, + uint LayerCount); + + private readonly record struct TemporaryStaging(Buffer Buffer, VulkanAllocation Allocation); + + internal VulkanUploadQueue( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanDeviceMemoryAllocator allocator, + VulkanFrameFlightController flights, + VulkanDebugNames debugNames, + ulong stagingCapacityBytes = DefaultStagingCapacityBytes) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _allocator = allocator ?? throw new ArgumentNullException(nameof(allocator)); + _flights = flights ?? throw new ArgumentNullException(nameof(flights)); + _debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames)); + _ringState = new VulkanStagingRingState(stagingCapacityBytes); + + (_stagingBuffer, _stagingAllocation) = CreateHostBuffer( + stagingCapacityBytes, + BufferUsageFlags.TransferSrcBit, + "vk-staging-ring"); + } + + /// Images whose layout must be moved to TRANSFER_DST before the drain and to SHADER_READ after it. + private readonly HashSet _imagesNeedingBarrier = []; + + internal int PendingBufferCopyCount => _bufferCopies.Count; + + internal int PendingImageCopyCount => _imageCopies.Count; + + internal ulong StagingLiveBytes => _ringState.LiveBytes; + + /// Stages and queues a copy into . + internal void StageBufferWrite( + Buffer destination, + ulong destinationOffsetBytes, + ReadOnlySpan data, + string ownerName) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (data.IsEmpty) + return; + + (Buffer source, ulong sourceOffset) = Stage(data, ownerName); + _bufferCopies.Add(new BufferCopy2( + source, + destination, + sourceOffset, + destinationOffsetBytes, + (ulong)data.Length)); + } + + /// Stages and queues a copy into one mip level of one array layer. + internal void StageImageWrite( + Image destination, + int mipLevel, + int layer, + int width, + int height, + int mipLevelCount, + int layerCount, + ReadOnlySpan data, + string ownerName) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (data.IsEmpty) + return; + + // Vulkan requires a buffer-to-image copy's source offset to be a + // multiple of 4 and of the texel/block size; 16 covers every format + // acdream uses (BC3's 16-byte block is the largest). + (Buffer source, ulong sourceOffset) = Stage(data, ownerName, alignmentBytes: 16); + _imageCopies.Add(new ImageCopy2( + source, + sourceOffset, + destination, + (uint)mipLevel, + (uint)layer, + (uint)width, + (uint)height, + (uint)mipLevelCount, + (uint)layerCount)); + _imagesNeedingBarrier.Add(destination); + } + + /// Queues a device-side buffer copy — the mesh arena's grow-and-copy migration. + internal void EnqueueBufferCopy( + Buffer source, + Buffer destination, + ulong sourceOffsetBytes, + ulong destinationOffsetBytes, + ulong byteCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (byteCount == 0) + return; + _bufferCopies.Add(new BufferCopy2( + source, + destination, + sourceOffsetBytes, + destinationOffsetBytes, + byteCount)); + } + + /// + /// Records every pending transfer into and + /// clears the queue. Must be called outside a dynamic-rendering block. + /// Returns false when there was nothing to do. + /// + internal bool Record(CommandBuffer commands) + { + if (_bufferCopies.Count == 0 && _imageCopies.Count == 0) + return false; + + if (_imagesNeedingBarrier.Count > 0) + TransitionImages(commands, ImageLayout.Undefined, ImageLayout.TransferDstOptimal, toTransfer: true); + + foreach (BufferCopy2 copy in _bufferCopies) + { + var region = new BufferCopy + { + SrcOffset = copy.SourceOffset, + DstOffset = copy.DestinationOffset, + Size = copy.SizeBytes, + }; + _vk.CmdCopyBuffer(commands, copy.Source, copy.Destination, 1, ®ion); + } + + foreach (ImageCopy2 copy in _imageCopies) + { + var region = new BufferImageCopy + { + BufferOffset = copy.SourceOffset, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = copy.MipLevel, + BaseArrayLayer = copy.Layer, + LayerCount = 1, + }, + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(copy.Width, copy.Height, 1), + }; + _vk.CmdCopyBufferToImage( + commands, + copy.Source, + copy.Destination, + ImageLayout.TransferDstOptimal, + 1, + ®ion); + } + + if (_imagesNeedingBarrier.Count > 0) + TransitionImages(commands, ImageLayout.TransferDstOptimal, ImageLayout.ShaderReadOnlyOptimal, toTransfer: false); + + // One buffer barrier for the whole batch: transfer writes become + // readable by every consumer stage a copied buffer can feed. + if (_bufferCopies.Count > 0) + { + var barrier = new MemoryBarrier2 + { + SType = StructureType.MemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllTransferBit, + SrcAccessMask = AccessFlags2.TransferWriteBit, + DstStageMask = PipelineStageFlags2.VertexInputBit + | PipelineStageFlags2.VertexShaderBit + | PipelineStageFlags2.FragmentShaderBit + | PipelineStageFlags2.DrawIndirectBit, + DstAccessMask = AccessFlags2.VertexAttributeReadBit + | AccessFlags2.IndexReadBit + | AccessFlags2.ShaderReadBit + | AccessFlags2.UniformReadBit + | AccessFlags2.IndirectCommandReadBit, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + MemoryBarrierCount = 1, + PMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + _bufferCopies.Clear(); + _imageCopies.Clear(); + _imagesNeedingBarrier.Clear(); + return true; + } + + private void TransitionImages( + CommandBuffer commands, + ImageLayout oldLayout, + ImageLayout newLayout, + bool toTransfer) + { + int count = _imagesNeedingBarrier.Count; + var barriers = new ImageMemoryBarrier2[count]; + int index = 0; + foreach (Image image in _imagesNeedingBarrier) + { + barriers[index++] = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = toTransfer + ? PipelineStageFlags2.AllCommandsBit + : PipelineStageFlags2.AllTransferBit, + SrcAccessMask = toTransfer ? AccessFlags2.None : AccessFlags2.TransferWriteBit, + DstStageMask = toTransfer + ? PipelineStageFlags2.AllTransferBit + : PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.VertexShaderBit, + DstAccessMask = toTransfer ? AccessFlags2.TransferWriteBit : AccessFlags2.ShaderReadBit, + // Undefined discards existing contents, which is right for the + // first upload into a fresh image and wrong for an incremental + // one; incremental array-layer fills therefore name the layout + // they are already in. + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, + BaseArrayLayer = 0, + LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers, + }, + }; + } + + fixed (ImageMemoryBarrier2* first = barriers) + { + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = (uint)count, + PImageMemoryBarriers = first, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + } + + /// Reclaims staging bytes and temporary buffers belonging to completed frames. + internal void ReleaseCompleted(long completedSerial) => _ringState.Release(completedSerial); + + private (Buffer Buffer, ulong Offset) Stage( + ReadOnlySpan data, + string ownerName, + ulong alignmentBytes = 4) + { + long serial = _flights.OpenSerial != 0 ? _flights.OpenSerial : _flights.SubmittedSerial + 1; + if (_ringState.TryAllocate(data.Length, alignmentBytes, serial, out ulong offset)) + { + data.CopyTo(_stagingAllocation.AsSpan().Slice((int)offset, data.Length)); + return (_stagingBuffer, offset); + } + + (Buffer temporary, VulkanAllocation allocation) = CreateHostBuffer( + (ulong)data.Length, + BufferUsageFlags.TransferSrcBit, + $"vk-staging-temp-{ownerName}"); + data.CopyTo(allocation.AsSpan()); + _temporaries.Add(new TemporaryStaging(temporary, allocation)); + + Buffer captured = temporary; + VulkanAllocation capturedAllocation = allocation; + _flights.Retire(() => + { + _vk.DestroyBuffer(_device, captured, null); + _allocator.Free(capturedAllocation); + _temporaries.RemoveAll(entry => entry.Buffer.Handle == captured.Handle); + }); + return (temporary, 0); + } + + private (Buffer Buffer, VulkanAllocation Allocation) CreateHostBuffer( + ulong sizeBytes, + BufferUsageFlags usage, + string name) + { + var create = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = sizeBytes, + Usage = usage, + SharingMode = SharingMode.Exclusive, + }; + VulkanInterop.Check( + _vk.CreateBuffer(_device, &create, null, out Buffer buffer), + $"vkCreateBuffer ({name})"); + + _vk.GetBufferMemoryRequirements(_device, buffer, out MemoryRequirements requirements); + VulkanAllocation allocation = _allocator.Allocate( + requirements, + GpuMemoryResidency.HostWritable, + name); + VulkanInterop.Check( + _vk.BindBufferMemory(_device, buffer, allocation.Memory, allocation.OffsetBytes), + $"vkBindBufferMemory ({name})"); + _debugNames.NameBuffer(buffer, name); + return (buffer, allocation); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (TemporaryStaging temporary in _temporaries) + { + _vk.DestroyBuffer(_device, temporary.Buffer, null); + _allocator.Free(temporary.Allocation); + } + _temporaries.Clear(); + + _vk.DestroyBuffer(_device, _stagingBuffer, null); + _allocator.Free(_stagingAllocation); + _ringState.Reset(); + _bufferCopies.Clear(); + _imageCopies.Clear(); + _imagesNeedingBarrier.Clear(); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanFrameFlightTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanFrameFlightTests.cs new file mode 100644 index 00000000..e747b9d8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanFrameFlightTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a — the frame timeline, the per-slot ring, and the +/// staging ring (plan §4.3 and §4.8). +/// +/// These three carry the invariant that everything else in the Vulkan backend +/// rests on: memory handed to the GPU is not reused, and memory freed by the CPU +/// is not destroyed, until the frame that could still be reading it has +/// completed. A timeline semaphore makes that one number, which is exactly what +/// makes it testable — is the number. +/// +public sealed class VulkanFrameFlightTests +{ + private sealed class FakeTimeline : IVulkanTimelineApi + { + public ulong Value { get; set; } + + public List Waits { get; } = []; + + public ulong CurrentValue => Value; + + public void Wait(ulong value) + { + Waits.Add(value); + // A real wait blocks until the GPU gets there; the fake models a + // GPU that is always just far enough ahead. + Value = Math.Max(Value, value); + } + } + + // ── flight controller ──────────────────────────────────────────────────── + + [Fact] + public void Flights_FirstFramesDoNotWaitBecauseNoSlotIsOccupiedYet() + { + var timeline = new FakeTimeline(); + var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); + + Assert.Equal(1, flights.BeginFrame()); + flights.EndFrame(); + Assert.Equal(2, flights.BeginFrame()); + flights.EndFrame(); + + Assert.Empty(timeline.Waits); + } + + [Fact] + public void Flights_ThirdFrameWaitsForTheFirstToComplete() + { + var timeline = new FakeTimeline(); + var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); + flights.BeginFrame(); + flights.EndFrame(); + flights.BeginFrame(); + flights.EndFrame(); + + Assert.Equal(3, flights.BeginFrame()); + + Assert.Equal([1ul], timeline.Waits); + } + + [Fact] + public void Flights_SerialsMapOntoSlotsRoundRobin() + { + var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); + + Assert.Equal(0, flights.SlotIndexOf(1)); + Assert.Equal(1, flights.SlotIndexOf(2)); + Assert.Equal(0, flights.SlotIndexOf(3)); + Assert.Equal(1, flights.SlotIndexOf(4)); + } + + [Fact] + public void Flights_RetirementIsKeyedToTheOpenFrameNotTheCompletedOne() + { + var timeline = new FakeTimeline(); + var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); + var released = new List(); + + flights.BeginFrame(); // serial 1 open + flights.Retire(() => released.Add("during-1")); + flights.EndFrame(); + + // Frame 1 has been submitted but the GPU has not finished it, so the + // release must not run: commands recorded into frame 1 may still read + // the resource. + timeline.Value = 0; + flights.BeginFrame(); // serial 2 open; retirements run here + Assert.Empty(released); + + flights.EndFrame(); + timeline.Value = 1; + flights.RunRetirements(); + + Assert.Equal(["during-1"], released); + } + + [Fact] + public void Flights_ResourcesReleasedBetweenFramesBelongToTheNextFrame() + { + var timeline = new FakeTimeline(); + var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); + var released = new List(); + + flights.BeginFrame(); + flights.EndFrame(); + flights.Retire(() => released.Add("between")); + + // Filed against serial 2, which has not even opened. + timeline.Value = 1; + flights.RunRetirements(); + Assert.Empty(released); + + timeline.Value = 2; + flights.RunRetirements(); + Assert.Equal(["between"], released); + } + + [Fact] + public void Flights_WaitForSubmittedWorkDrainsEverythingPending() + { + var timeline = new FakeTimeline(); + var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2); + var released = new List(); + + flights.BeginFrame(); + flights.Retire(() => released.Add("a")); + flights.EndFrame(); + flights.Retire(() => released.Add("b")); + + flights.WaitForSubmittedWork(); + + Assert.Equal(["a", "b"], released); + Assert.Equal(0, flights.PendingRetirementCount); + } + + [Fact] + public void Flights_OpeningTwoFramesAtOnceIsRejected() + { + var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); + flights.BeginFrame(); + + Assert.Throws(() => flights.BeginFrame()); + } + + [Fact] + public void Flights_DisposeRunsRemainingReleasesSoNothingLeaks() + { + var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2); + var released = new List(); + flights.BeginFrame(); + flights.Retire(() => released.Add("pending")); + + flights.Dispose(); + + Assert.Equal(["pending"], released); + } + + // ── per-frame ring ─────────────────────────────────────────────────────── + + [Fact] + public void Ring_AllocatesForwardWithAlignmentAndTracksThePeak() + { + var ring = new VulkanRingBufferState(1024); + + Assert.Equal(0ul, ring.Allocate(10, 1)); + Assert.Equal(256ul, ring.Allocate(16, 256)); + Assert.Equal(272ul, ring.AllocatedBytes); + Assert.Equal(272ul, ring.PeakAllocatedBytes); + + ring.Reset(); + + Assert.Equal(0ul, ring.AllocatedBytes); + // The peak survives the reset: it is the number that says whether the + // per-slot capacity is right. + Assert.Equal(272ul, ring.PeakAllocatedBytes); + } + + [Fact] + public void Ring_OverCapacityThrowsRatherThanTruncating() + { + var ring = new VulkanRingBufferState(64); + + InvalidOperationException error = Assert.Throws(() => ring.Allocate(65, 1)); + + // The message must name the capacity, because the fix is always + // "raise it" and the person reading has no other source for the number. + Assert.Contains("64 bytes", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Ring_ZeroSizedAllocationIsLegalAndMovesNothing() + { + var ring = new VulkanRingBufferState(64); + Assert.Equal(0ul, ring.Allocate(0, 16)); + Assert.Equal(0ul, ring.AllocatedBytes); + } + + // ── staging ring ───────────────────────────────────────────────────────── + + [Fact] + public void Staging_HandsOutBytesUntilTheRingIsFullOfUnretiredFrames() + { + var staging = new VulkanStagingRingState(1024); + + Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong first)); + Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong second)); + Assert.Equal(0ul, first); + Assert.Equal(512ul, second); + + // Nothing has retired, so there is genuinely nowhere to put this. + Assert.False(staging.TryAllocate(1, 4, serial: 1, out _)); + } + + [Fact] + public void Staging_ReclaimsSpaceWhenTheFrameThatTookItCompletes() + { + var staging = new VulkanStagingRingState(1024); + Assert.True(staging.TryAllocate(1024, 4, serial: 1, out _)); + Assert.False(staging.TryAllocate(16, 4, serial: 2, out _)); + + staging.Release(completedSerial: 1); + + Assert.Equal(0ul, staging.LiveBytes); + Assert.True(staging.TryAllocate(16, 4, serial: 2, out _)); + } + + [Fact] + public void Staging_ReleasesOnlyFramesTheGpuHasActuallyFinished() + { + var staging = new VulkanStagingRingState(1024); + Assert.True(staging.TryAllocate(256, 4, serial: 1, out _)); + Assert.True(staging.TryAllocate(256, 4, serial: 2, out _)); + + staging.Release(completedSerial: 1); + + Assert.Equal(256ul, staging.LiveBytes); + Assert.Equal(1, staging.PendingSegmentCount); + } + + [Fact] + public void Staging_WrapsRatherThanRefusingWhenTheTailIsFree() + { + var staging = new VulkanStagingRingState(1024); + Assert.True(staging.TryAllocate(768, 4, serial: 1, out _)); + staging.Release(completedSerial: 1); + + // 512 does not fit in the 256 bytes left at the end, so it wraps to 0 + // and the wasted tail is charged to this segment. + Assert.True(staging.TryAllocate(512, 4, serial: 2, out ulong wrapped)); + + Assert.Equal(0ul, wrapped); + Assert.Equal(768ul, staging.LiveBytes); + } + + [Fact] + public void Staging_RefusesAPayloadLargerThanTheWholeRing() + { + var staging = new VulkanStagingRingState(1024); + + // The caller's answer to this is a temporary dedicated staging buffer, + // not a bigger ring — see VulkanUploadQueue. + Assert.False(staging.TryAllocate(2048, 4, serial: 1, out _)); + } + + [Fact] + public void Staging_MergesConsecutiveRequestsFromTheSameFrame() + { + var staging = new VulkanStagingRingState(1024); + Assert.True(staging.TryAllocate(16, 4, serial: 1, out _)); + Assert.True(staging.TryAllocate(16, 4, serial: 1, out _)); + Assert.True(staging.TryAllocate(16, 4, serial: 2, out _)); + + Assert.Equal(2, staging.PendingSegmentCount); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanMemoryModelTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanMemoryModelTests.cs new file mode 100644 index 00000000..f25c6041 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanMemoryModelTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6a — the allocator's arithmetic (plan §4.2/§4.3). +/// +/// Every decision the Vulkan allocator makes is reachable from here: where a +/// suballocation lands inside a block, whether releasing one lets the next reuse +/// its bytes, when a request gets a block of its own, and which heap a residency +/// class picks. That is the whole reason the free list and the pool carry no +/// Vulkan handles — an allocator's real failure modes are arithmetic, and +/// arithmetic does not need a GPU to be wrong. +/// +public sealed class VulkanMemoryModelTests +{ + // ── free list ──────────────────────────────────────────────────────────── + + [Fact] + public void FreeList_AllocatesFromTheFrontAndTracksUse() + { + var block = new VulkanMemoryBlockFreeList(1024); + + Assert.True(block.TryAllocate(256, 1, out ulong first)); + Assert.True(block.TryAllocate(256, 1, out ulong second)); + + Assert.Equal(0ul, first); + Assert.Equal(256ul, second); + Assert.Equal(512ul, block.UsedBytes); + Assert.Equal(512ul, block.FreeBytes); + } + + [Fact] + public void FreeList_HonoursAlignmentAndChargesThePaddingToTheAllocation() + { + var block = new VulkanMemoryBlockFreeList(1024); + Assert.True(block.TryAllocate(1, 1, out _)); + + Assert.True(block.TryAllocate(16, 256, out ulong aligned)); + + Assert.Equal(256ul, aligned); + // 1 byte of payload, then 255 bytes of padding plus 16 of payload. + Assert.Equal(272ul, block.UsedBytes); + } + + [Fact] + public void FreeList_ReleasingAnAlignedAllocationReturnsItsPaddingToo() + { + var block = new VulkanMemoryBlockFreeList(1024); + Assert.True(block.TryAllocate(1, 1, out _)); + Assert.True(block.TryAllocate(16, 256, out ulong aligned)); + + block.Free(aligned, 16); + + // Only the leading 1-byte allocation is still out. + Assert.Equal(1ul, block.UsedBytes); + Assert.Equal(1023ul, block.FreeBytes); + } + + [Fact] + public void FreeList_CoalescesNeighboursSoTheBlockDoesNotFragmentAway() + { + var block = new VulkanMemoryBlockFreeList(1024); + Assert.True(block.TryAllocate(256, 1, out ulong a)); + Assert.True(block.TryAllocate(256, 1, out ulong b)); + Assert.True(block.TryAllocate(256, 1, out ulong c)); + + block.Free(a, 256); + block.Free(c, 256); + // Two ranges, not three: releasing c already merged with the block's + // untouched tail. + Assert.Equal(2, block.FreeRangeCount); + + block.Free(b, 256); + + // Everything is back and it is ONE range, not three: a block that + // coalesced badly would still report the right free byte count while + // being unable to satisfy a large allocation ever again. + Assert.Equal(1, block.FreeRangeCount); + Assert.Equal(1024ul, block.LargestFreeBytes); + Assert.Equal(0ul, block.UsedBytes); + } + + [Fact] + public void FreeList_RefusesAnAllocationLargerThanTheLargestHole() + { + var block = new VulkanMemoryBlockFreeList(1024); + Assert.True(block.TryAllocate(600, 1, out _)); + + Assert.False(block.TryAllocate(600, 1, out _)); + Assert.True(block.TryAllocate(400, 1, out _)); + } + + [Fact] + public void FreeList_DoubleReleaseIsRejectedRatherThanCorruptingTheAccounting() + { + var block = new VulkanMemoryBlockFreeList(1024); + Assert.True(block.TryAllocate(256, 1, out ulong offset)); + block.Free(offset, 256); + + Assert.Throws(() => block.Free(offset, 256)); + } + + // ── pool policy ────────────────────────────────────────────────────────── + + [Fact] + public void Pool_AsksForANewBlockBeforeItHasOne() + { + var pool = new VulkanMemoryTypePool(memoryTypeIndex: 0, blockSizeBytes: 4096, dedicatedThresholdBytes: 2048); + + Assert.False(pool.TryAllocate(64, 1, out _)); + + int index = pool.AddBlock(pool.BlockCapacityFor(64), dedicated: false); + Assert.Equal(0, index); + Assert.True(pool.TryAllocate(64, 1, out VulkanMemoryRange range)); + Assert.Equal(0, range.BlockIndex); + Assert.False(range.IsDedicated); + } + + [Fact] + public void Pool_GivesALargeRequestAWholeBlockOfItsOwn() + { + var pool = new VulkanMemoryTypePool(memoryTypeIndex: 0, blockSizeBytes: 4096, dedicatedThresholdBytes: 2048); + int shared = pool.AddBlock(4096, dedicated: false); + Assert.Equal(0, shared); + + // 3000 >= the 2048 threshold, so it must not be placed in the shared + // block even though the shared block would fit it — taking that space + // would strand the remaining kilobyte. + Assert.False(pool.TryAllocate(3000, 1, out _)); + Assert.Equal(3000ul, pool.BlockCapacityFor(3000)); + + int dedicated = pool.AddBlock(3000, dedicated: true); + VulkanMemoryRange range = pool.AllocateWholeBlock(dedicated, 3000); + + Assert.True(range.IsDedicated); + Assert.Equal(0ul, range.OffsetBytes); + Assert.Equal(2, pool.LiveBlockCount); + } + + [Fact] + public void Pool_FreeingADedicatedRangeRetiresItsBlockButKeepsSharedOnes() + { + var pool = new VulkanMemoryTypePool(memoryTypeIndex: 0, blockSizeBytes: 4096, dedicatedThresholdBytes: 2048); + int shared = pool.AddBlock(4096, dedicated: false); + Assert.True(pool.TryAllocate(64, 1, out VulkanMemoryRange small)); + int dedicatedBlock = pool.AddBlock(3000, dedicated: true); + VulkanMemoryRange large = pool.AllocateWholeBlock(dedicatedBlock, 3000); + + Assert.True(pool.Free(large)); // dedicated block is now free device memory + Assert.False(pool.Free(small)); // shared block is kept for reuse + + Assert.Equal(0, shared); + Assert.Equal(1, pool.LiveBlockCount); + } + + [Fact] + public void Pool_ReusesASharedBlockAfterItsAllocationsAreReleased() + { + var pool = new VulkanMemoryTypePool(memoryTypeIndex: 3, blockSizeBytes: 1024, dedicatedThresholdBytes: 4096); + pool.AddBlock(1024, dedicated: false); + Assert.True(pool.TryAllocate(1024, 1, out VulkanMemoryRange whole)); + Assert.False(pool.TryAllocate(16, 1, out _)); + + pool.Free(whole); + + Assert.True(pool.TryAllocate(16, 1, out VulkanMemoryRange reused)); + Assert.Equal(0, reused.BlockIndex); + Assert.Equal(1, pool.LiveBlockCount); + } + + // ── heap selection ─────────────────────────────────────────────────────── + + private static readonly MemoryPropertyFlags DeviceLocal = MemoryPropertyFlags.DeviceLocalBit; + private static readonly MemoryPropertyFlags HostCoherent = + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit; + private static readonly MemoryPropertyFlags ReBar = + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit; + private static readonly MemoryPropertyFlags HostCached = + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.HostCachedBit; + + [Fact] + public void Selection_HostWritablePrefersResizableBarOverPlainHostMemory() + { + List types = [DeviceLocal, HostCoherent, ReBar]; + + uint? chosen = VulkanMemoryTypeSelection.Choose(types, 0b111, GpuMemoryResidency.HostWritable); + + // Index 2 is the device-local AND host-visible type: writing per-frame + // data straight into memory the GPU reads is the campaign's CPU win. + Assert.Equal(2u, chosen); + } + + [Fact] + public void Selection_HostWritableFallsBackWhenNoResizableBarTypeExists() + { + List types = [DeviceLocal, HostCoherent]; + + Assert.Equal(1u, VulkanMemoryTypeSelection.Choose(types, 0b11, GpuMemoryResidency.HostWritable)); + } + + [Fact] + public void Selection_RespectsTheResourcesAllowedTypeMask() + { + List types = [ReBar, HostCoherent]; + + // Bit 0 is masked out even though it is the preferred type. + Assert.Equal(1u, VulkanMemoryTypeSelection.Choose(types, 0b10, GpuMemoryResidency.HostWritable)); + } + + [Fact] + public void Selection_DeviceLocalPrefersDeviceLocalButAcceptsAnythingRatherThanFailing() + { + List deviceLocalPresent = [HostCoherent, DeviceLocal]; + Assert.Equal(1u, VulkanMemoryTypeSelection.Choose(deviceLocalPresent, 0b11, GpuMemoryResidency.DeviceLocal)); + + List unifiedMemory = [HostCoherent]; + Assert.Equal(0u, VulkanMemoryTypeSelection.Choose(unifiedMemory, 0b1, GpuMemoryResidency.DeviceLocal)); + } + + [Fact] + public void Selection_HostReadablePrefersCachedMemoryForTheReadbackItExistsFor() + { + List types = [HostCoherent, HostCached]; + + Assert.Equal(1u, VulkanMemoryTypeSelection.Choose(types, 0b11, GpuMemoryResidency.HostReadable)); + } + + [Fact] + public void Selection_AnswersNullWhenNothingIsUsableRatherThanGuessing() + { + List types = [DeviceLocal]; + + Assert.Null(VulkanMemoryTypeSelection.Choose(types, 0b1, GpuMemoryResidency.HostWritable)); + } + + [Fact] + public void Selection_NamesWhichResidenciesMustBeMappable() + { + Assert.False(VulkanMemoryTypeSelection.RequiresMapping(GpuMemoryResidency.DeviceLocal)); + Assert.True(VulkanMemoryTypeSelection.RequiresMapping(GpuMemoryResidency.HostWritable)); + Assert.True(VulkanMemoryTypeSelection.RequiresMapping(GpuMemoryResidency.HostReadable)); + } +}