diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index ffec2ecd..513360c5 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -28,6 +28,10 @@ + + + diff --git a/src/AcDream.App/RenderBackendKind.cs b/src/AcDream.App/RenderBackendKind.cs new file mode 100644 index 00000000..4765f914 --- /dev/null +++ b/src/AcDream.App/RenderBackendKind.cs @@ -0,0 +1,25 @@ +namespace AcDream.App; + +/// +/// Campaign V slice V5: which rendering backend the graphical host starts. +/// +/// OpenGL is the default and, through slice V9, the only backend that renders +/// the game. is dark bring-up — it creates an instance, a +/// device, a swapchain and runs the capability gate, and nothing more, until the +/// Vulkan RHI backend lands at V6. +/// +/// This enum is public only because is public and +/// exposes it as a property. The backend-neutral +/// AcDream.App.Rendering.Gpu.GpuBackendKind stays internal and describes a +/// live device rather than a startup request; the two are deliberately separate +/// because a Vulkan-requested process can still fail its capability gate and +/// never own a Vulkan device at all. +/// +public enum RenderBackendKind +{ + /// OpenGL 4.3 core + bindless + MDI. The default, and the only live backend. + Gl, + + /// Vulkan 1.3 core. Dark until Campaign V slice V10 flips the default. + Vulkan, +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e16fa047..9cdee745 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -680,6 +680,21 @@ public sealed class GameWindow : // attribute, so it must come from this same snapshot rather than a // second settings load during OnLoad. RuntimeSettingsSnapshot startup = _runtimeSettings.Startup; + if (_options.RenderBackend == RenderBackendKind.Vulkan) + { + // Campaign V slice V5 — dark Vulkan bring-up. Reached only when + // ACDREAM_RENDER_BACKEND=vulkan; the GL path below executes not one + // new statement. The host owns its own window, device and swapchain, + // and its capability gate throws NotSupportedException into the same + // exit-code-4 contract Program.cs already publishes for GL. + using var vulkan = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost( + _options, + _platformServices, + startup.Display.VSync); + vulkan.Run(); + return; + } + FramePacingPolicy startupPacing = _displayFramePacing.InitializeStartup(startup.Display.VSync); var options = WindowOptions.Default with diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs new file mode 100644 index 00000000..8770cacf --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs @@ -0,0 +1,768 @@ +using Silk.NET.Vulkan; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5, plan §4.11: the active half of the Vulkan +/// capability gate. +/// +/// Advertisement is not evidence. The GL gate learned that from Linux drivers +/// that publish an extension string and then return a missing entry point, and +/// the Vulkan probe is deliberately stronger: it really builds the production +/// descriptor layouts, really allocates an offscreen colour target, really +/// records a dynamic-rendering clear through a barrier2 pair, really +/// submits it against a timeline semaphore, and really reads the pixels back. +/// +/// Offscreen by construction. Nothing here touches a surface, a +/// swapchain, or a window. That is what lets the gate run — and be reasoned +/// about — without opening one, and it is why the probe is a separate type from +/// VulkanBringUpHost rather than a phase inside it. +/// +/// Deviation from §4.11, deliberate and scoped to V5: the plan also +/// wants the probe to build "one real pipeline from the committed +/// .spv" and render a triangle that samples a table slot. The +/// .spv toolchain does not exist until slice V6c, so V5 proves everything +/// the pipeline would have needed — the descriptor-indexing layout, the +/// three-set pipeline layout, the 96-byte push-constant range, dynamic +/// rendering, synchronization2, the timeline semaphore, host query reset and +/// readback — and V6c adds the shader stage on top. Recorded in the slice V5 +/// commit message and in the divergence register. +/// +internal static unsafe class VulkanActiveDeviceProbe +{ + /// Edge of the offscreen target. Small enough to be free, large enough to be a real image. + internal const uint ProbeExtent = 64; + + /// + /// The clear colour the probe writes and then verifies. Chosen so all four + /// channels differ from each other and none is 0 or 255 — a readback that + /// silently returns zeroed or saturated memory cannot accidentally match. + /// + internal static ReadOnlySpan ExpectedClearRgba => [0x33, 0x77, 0xBB, 0xEE]; + + internal static VulkanFunctionProbeResult Run( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + Queue graphicsQueue, + uint graphicsFamily) + { + ArgumentNullException.ThrowIfNull(vk); + var failures = new List(); + + bool descriptorLayout = false; + bool pushConstantLayout = false; + bool dynamicRendering = false; + bool timelineWait = false; + bool hostQueryReset = false; + bool readback = false; + + DescriptorSetLayout storageLayout = default; + DescriptorSetLayout uniformLayout = default; + DescriptorSetLayout tableLayout = default; + PipelineLayout pipelineLayout = default; + + try + { + descriptorLayout = Attempt( + "descriptor-indexing set layouts", + () => + { + storageLayout = CreateStorageSetLayout(vk, device); + uniformLayout = CreateUniformSetLayout(vk, device); + tableLayout = CreateTextureTableSetLayout(vk, device); + }, + failures); + + if (descriptorLayout) + { + pushConstantLayout = Attempt( + "three-set pipeline layout with the 96-byte push-constant block", + () => pipelineLayout = CreatePipelineLayout( + vk, + device, + storageLayout, + uniformLayout, + tableLayout), + failures); + } + + hostQueryReset = Attempt( + "host timestamp query-pool reset", + () => ProbeHostQueryReset(vk, device), + failures); + + byte[]? pixels = null; + dynamicRendering = Attempt( + "dynamic-rendering clear, synchronization2 barriers and timeline submit", + () => pixels = RenderAndReadBack(vk, physicalDevice, device, graphicsQueue, graphicsFamily), + failures); + timelineWait = dynamicRendering; + + if (dynamicRendering && pixels is not null) + { + readback = Attempt( + "offscreen readback pixel comparison", + () => VerifyClearColour(pixels), + failures); + } + } + finally + { + if (pipelineLayout.Handle != 0) + vk.DestroyPipelineLayout(device, pipelineLayout, null); + if (tableLayout.Handle != 0) + vk.DestroyDescriptorSetLayout(device, tableLayout, null); + if (uniformLayout.Handle != 0) + vk.DestroyDescriptorSetLayout(device, uniformLayout, null); + if (storageLayout.Handle != 0) + vk.DestroyDescriptorSetLayout(device, storageLayout, null); + } + + return new VulkanFunctionProbeResult( + DeviceCreation: true, + DescriptorIndexingLayout: descriptorLayout, + PushConstantLayout: pushConstantLayout, + DynamicRenderingClear: dynamicRendering, + TimelineSemaphoreWait: timelineWait, + HostQueryReset: hostQueryReset, + OffscreenReadback: readback, + Failures: failures); + } + + private static bool Attempt(string name, Action action, List failures) + { + try + { + action(); + return true; + } + catch (Exception error) + { + failures.Add($"{name}: {error.GetType().Name}: {error.Message}"); + return false; + } + } + + /// Set 0 — the ten storage bindings GpuBindingModel pins. + private static DescriptorSetLayout CreateStorageSetLayout( + Silk.NET.Vulkan.Vk vk, + Device device) + { + int count = (int)GpuBindingModel.StorageBindingCount; + DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count]; + for (int i = 0; i < count; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = (uint)i, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + } + + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = (uint)count, + PBindings = bindings, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 0, storage)"); + return layout; + } + + /// Set 1 — the SceneLighting and terrain-tiling uniform blocks. + private static DescriptorSetLayout CreateUniformSetLayout( + Silk.NET.Vulkan.Vk vk, + Device device) + { + DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2]; + bindings[0] = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.UniformSceneLighting, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + bindings[1] = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.UniformTerrainTiling, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 2, + PBindings = bindings, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 1, uniform)"); + return layout; + } + + /// + /// Set 2 — the production texture table exactly as §4.4 specifies it: one + /// combined-image-sampler binding of , + /// partially bound, update-after-bind, update-unused-while-pending, variable + /// count. This is the layout the whole bindless replacement rests on, so the + /// probe builds the real thing rather than a token one. + /// + private static DescriptorSetLayout CreateTextureTableSetLayout( + Silk.NET.Vulkan.Vk vk, + Device device) + { + var binding = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.TextureTableBinding, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = GpuBindingModel.TextureTableCapacity, + StageFlags = ShaderStageFlags.FragmentBit, + }; + DescriptorBindingFlags flags = + DescriptorBindingFlags.PartiallyBoundBit + | DescriptorBindingFlags.UpdateAfterBindBit + | DescriptorBindingFlags.UpdateUnusedWhilePendingBit + | DescriptorBindingFlags.VariableDescriptorCountBit; + + var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo + { + SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo, + BindingCount = 1, + PBindingFlags = &flags, + }; + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + PNext = &bindingFlags, + Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit, + BindingCount = 1, + PBindings = &binding, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 2, texture table)"); + return layout; + } + + /// + /// One shared pipeline layout: three sets plus the single 96-byte push-constant + /// block. Creating it proves maxBoundDescriptorSets and + /// maxPushConstantsSize for real rather than by reading a limit. + /// + private static PipelineLayout CreatePipelineLayout( + Silk.NET.Vulkan.Vk vk, + Device device, + DescriptorSetLayout storage, + DescriptorSetLayout uniform, + DescriptorSetLayout table) + { + DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3]; + sets[0] = storage; + sets[1] = uniform; + sets[2] = table; + + var pushConstants = new PushConstantRange + { + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + Offset = 0, + Size = GpuBindingModel.PushConstantBytes, + }; + var create = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 3, + PSetLayouts = sets, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstants, + }; + VulkanInterop.Check( + vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout), + "vkCreatePipelineLayout"); + return layout; + } + + /// + /// Reset a timestamp pool from the host. This is the whole point of + /// hostQueryReset: without it the reset costs a command-buffer call + /// in every frame that measures anything. + /// + private static void ProbeHostQueryReset(Silk.NET.Vulkan.Vk vk, Device device) + { + var create = new QueryPoolCreateInfo + { + SType = StructureType.QueryPoolCreateInfo, + QueryType = QueryType.Timestamp, + QueryCount = 2, + }; + VulkanInterop.Check( + vk.CreateQueryPool(device, &create, null, out QueryPool pool), + "vkCreateQueryPool"); + try + { + vk.ResetQueryPool(device, pool, 0, 2); + } + finally + { + vk.DestroyQueryPool(device, pool, null); + } + } + + /// + /// The real submission: clear a 64x64 image through dynamic rendering, move + /// it to TRANSFER_SRC with a barrier2, copy it into host-visible memory, and + /// wait on a timeline semaphore for the frame serial. Every mechanism the + /// production frame skeleton (§4.8) depends on, in miniature. + /// + private static byte[] RenderAndReadBack( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + Queue queue, + uint graphicsFamily) + { + Image image = default; + DeviceMemory imageMemory = default; + ImageView view = default; + Silk.NET.Vulkan.Buffer readback = default; + DeviceMemory readbackMemory = default; + CommandPool commandPool = default; + Semaphore timeline = default; + + try + { + (image, imageMemory) = CreateColorTarget(vk, physicalDevice, device); + view = CreateImageView(vk, device, image); + uint byteCount = ProbeExtent * ProbeExtent * 4; + (readback, readbackMemory) = CreateReadbackBuffer(vk, physicalDevice, device, byteCount); + + var poolCreate = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = graphicsFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + VulkanInterop.Check( + vk.CreateCommandPool(device, &poolCreate, null, out commandPool), + "vkCreateCommandPool"); + + var allocate = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = commandPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + VulkanInterop.Check( + vk.AllocateCommandBuffers(device, &allocate, out CommandBuffer commands), + "vkAllocateCommandBuffers"); + + RecordClearAndCopy(vk, commands, image, view, readback); + + var semaphoreType = new SemaphoreTypeCreateInfo + { + SType = StructureType.SemaphoreTypeCreateInfo, + SemaphoreType = SemaphoreType.Timeline, + InitialValue = 0, + }; + var semaphoreCreate = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + PNext = &semaphoreType, + }; + VulkanInterop.Check( + vk.CreateSemaphore(device, &semaphoreCreate, null, out timeline), + "vkCreateSemaphore (timeline)"); + + var commandSubmit = new CommandBufferSubmitInfo + { + SType = StructureType.CommandBufferSubmitInfo, + CommandBuffer = commands, + }; + var signal = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = timeline, + Value = 1, + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + var submit = new SubmitInfo2 + { + SType = StructureType.SubmitInfo2, + CommandBufferInfoCount = 1, + PCommandBufferInfos = &commandSubmit, + SignalSemaphoreInfoCount = 1, + PSignalSemaphoreInfos = &signal, + }; + VulkanInterop.Check( + vk.QueueSubmit2(queue, 1, &submit, default), + "vkQueueSubmit2"); + + ulong waitValue = 1; + Semaphore waitSemaphore = timeline; + var wait = new SemaphoreWaitInfo + { + SType = StructureType.SemaphoreWaitInfo, + SemaphoreCount = 1, + PSemaphores = &waitSemaphore, + PValues = &waitValue, + }; + VulkanInterop.Check( + vk.WaitSemaphores(device, &wait, 5_000_000_000ul), + "vkWaitSemaphores (timeline)"); + + void* mapped = null; + VulkanInterop.Check( + vk.MapMemory(device, readbackMemory, 0, byteCount, 0, &mapped), + "vkMapMemory (readback)"); + try + { + var pixels = new byte[byteCount]; + new ReadOnlySpan(mapped, (int)byteCount).CopyTo(pixels); + return pixels; + } + finally + { + vk.UnmapMemory(device, readbackMemory); + } + } + finally + { + if (timeline.Handle != 0) + vk.DestroySemaphore(device, timeline, null); + if (commandPool.Handle != 0) + vk.DestroyCommandPool(device, commandPool, null); + if (readback.Handle != 0) + vk.DestroyBuffer(device, readback, null); + if (readbackMemory.Handle != 0) + vk.FreeMemory(device, readbackMemory, null); + if (view.Handle != 0) + vk.DestroyImageView(device, view, null); + if (image.Handle != 0) + vk.DestroyImage(device, image, null); + if (imageMemory.Handle != 0) + vk.FreeMemory(device, imageMemory, null); + } + } + + private static void RecordClearAndCopy( + Silk.NET.Vulkan.Vk vk, + CommandBuffer commands, + Image image, + ImageView view, + Silk.NET.Vulkan.Buffer readback) + { + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer"); + + var subresource = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + Barrier( + vk, + commands, + image, + subresource, + ImageLayout.Undefined, + ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.TopOfPipeBit, + AccessFlags2.None, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + + var clear = new ClearValue + { + Color = new ClearColorValue + { + Float32_0 = ExpectedClearRgba[0] / 255f, + Float32_1 = ExpectedClearRgba[1] / 255f, + Float32_2 = ExpectedClearRgba[2] / 255f, + Float32_3 = ExpectedClearRgba[3] / 255f, + }, + }; + var attachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = view, + ImageLayout = ImageLayout.ColorAttachmentOptimal, + LoadOp = AttachmentLoadOp.Clear, + StoreOp = AttachmentStoreOp.Store, + ClearValue = clear, + }; + var rendering = new RenderingInfo + { + SType = StructureType.RenderingInfo, + RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(ProbeExtent, ProbeExtent)), + LayerCount = 1, + ColorAttachmentCount = 1, + PColorAttachments = &attachment, + }; + vk.CmdBeginRendering(commands, &rendering); + vk.CmdEndRendering(commands); + + Barrier( + vk, + commands, + image, + subresource, + ImageLayout.ColorAttachmentOptimal, + ImageLayout.TransferSrcOptimal, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit, + PipelineStageFlags2.CopyBit, + AccessFlags2.TransferReadBit); + + var region = new BufferImageCopy + { + BufferOffset = 0, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }, + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(ProbeExtent, ProbeExtent, 1), + }; + vk.CmdCopyImageToBuffer( + commands, + image, + ImageLayout.TransferSrcOptimal, + readback, + 1, + ®ion); + + VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer"); + } + + private static void Barrier( + Silk.NET.Vulkan.Vk vk, + CommandBuffer commands, + Image image, + ImageSubresourceRange subresource, + ImageLayout oldLayout, + ImageLayout newLayout, + PipelineStageFlags2 sourceStage, + AccessFlags2 sourceAccess, + PipelineStageFlags2 destinationStage, + AccessFlags2 destinationAccess) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = sourceStage, + SrcAccessMask = sourceAccess, + DstStageMask = destinationStage, + DstAccessMask = destinationAccess, + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = subresource, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + vk.CmdPipelineBarrier2(commands, &dependency); + } + + private static (Image Image, DeviceMemory Memory) CreateColorTarget( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device) + { + var create = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Format = Format.R8G8B8A8Unorm, + Extent = new Extent3D(ProbeExtent, ProbeExtent, 1), + MipLevels = 1, + ArrayLayers = 1, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit, + SharingMode = SharingMode.Exclusive, + InitialLayout = ImageLayout.Undefined, + }; + VulkanInterop.Check( + vk.CreateImage(device, &create, null, out Image image), + "vkCreateImage (probe colour target)"); + + vk.GetImageMemoryRequirements(device, image, out MemoryRequirements requirements); + DeviceMemory memory = Allocate( + vk, + physicalDevice, + device, + requirements, + MemoryPropertyFlags.DeviceLocalBit); + VulkanInterop.Check( + vk.BindImageMemory(device, image, memory, 0), + "vkBindImageMemory (probe colour target)"); + return (image, memory); + } + + private static ImageView CreateImageView( + Silk.NET.Vulkan.Vk vk, + Device device, + Image image) + { + var create = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type2D, + Format = Format.R8G8B8A8Unorm, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + VulkanInterop.Check( + vk.CreateImageView(device, &create, null, out ImageView view), + "vkCreateImageView (probe colour target)"); + return view; + } + + private static (Silk.NET.Vulkan.Buffer Buffer, DeviceMemory Memory) CreateReadbackBuffer( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + uint byteCount) + { + var create = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = byteCount, + Usage = BufferUsageFlags.TransferDstBit, + SharingMode = SharingMode.Exclusive, + }; + VulkanInterop.Check( + vk.CreateBuffer(device, &create, null, out Silk.NET.Vulkan.Buffer buffer), + "vkCreateBuffer (probe readback)"); + + vk.GetBufferMemoryRequirements(device, buffer, out MemoryRequirements requirements); + DeviceMemory memory = Allocate( + vk, + physicalDevice, + device, + requirements, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + VulkanInterop.Check( + vk.BindBufferMemory(device, buffer, memory, 0), + "vkBindBufferMemory (probe readback)"); + return (buffer, memory); + } + + private static DeviceMemory Allocate( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + MemoryRequirements requirements, + MemoryPropertyFlags properties) + { + uint? typeIndex = FindMemoryType( + vk, + physicalDevice, + requirements.MemoryTypeBits, + properties); + if (typeIndex is not { } index) + { + throw new NotSupportedException( + $"No Vulkan memory type satisfies {properties} for the capability probe."); + } + + var allocate = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = index, + }; + VulkanInterop.Check( + vk.AllocateMemory(device, &allocate, null, out DeviceMemory memory), + "vkAllocateMemory (probe)"); + return memory; + } + + /// + /// First memory type that is both allowed by the resource and carries every + /// requested property. The mask is per-resource, + /// which is why this cannot be hoisted to a one-time lookup. + /// + internal static uint? FindMemoryType( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + uint typeBits, + MemoryPropertyFlags properties) + { + vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out PhysicalDeviceMemoryProperties memory); + for (uint i = 0; i < memory.MemoryTypeCount && i < 32; i++) + { + bool allowed = (typeBits & (1u << (int)i)) != 0; + if (allowed && memory.MemoryTypes[(int)i].PropertyFlags.HasFlag(properties)) + return i; + } + + return null; + } + + /// + /// Verify every pixel is the clear colour. Comparing all of them rather than + /// a sample is deliberate: a driver that clears only the first tile, or that + /// returns a row-padded copy, fails here rather than at V7. + /// + internal static void VerifyClearColour(ReadOnlySpan pixels) + { + if (pixels.Length != ProbeExtent * ProbeExtent * 4) + { + throw new InvalidOperationException( + $"the readback returned {pixels.Length} bytes; expected " + + $"{ProbeExtent * ProbeExtent * 4}."); + } + + for (int i = 0; i < pixels.Length; i += 4) + { + for (int channel = 0; channel < 4; channel++) + { + byte actual = pixels[i + channel]; + byte expected = ExpectedClearRgba[channel]; + // One least-significant bit of slack: the clear colour is + // specified as a float and quantised by the implementation. + if (Math.Abs(actual - expected) > 1) + { + throw new InvalidOperationException( + $"pixel {i / 4} channel {channel} read 0x{actual:X2}, expected " + + $"0x{expected:X2}."); + } + } + } + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs new file mode 100644 index 00000000..28cca247 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -0,0 +1,718 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Platform; +using AcDream.App.Rendering; +using Silk.NET.Core.Native; +using Silk.NET.Maths; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; +using Silk.NET.Windowing; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5 — Vulkan bring-up, dark. +/// +/// Reached only when ACDREAM_RENDER_BACKEND=vulkan. It opens its own +/// window, creates an instance, a surface, a device and a swapchain, runs the +/// capability gate, and presents a flat clear colour until the window closes. +/// Nothing of the game renders through it — the RHI backend lands at V6 +/// and the default flips at V10. +/// +/// Deliberately a self-contained host rather than a branch woven into +/// GameWindow's composition: the composition phases each own GL resources +/// and would have to grow a backend switch apiece for a slice that draws one +/// colour. GameWindow.Run delegates here in four lines and returns, so the +/// GL path executes not one new statement. +/// +/// Untested by the implementing slice. Every line below needs a +/// window and a driver. The pure decisions it depends on — device ranking, +/// present-mode mapping, extent and image-count clamping, the capability +/// accept/reject matrix, the report shape, the BGRA swizzle — are unit-tested +/// beside it. The remaining gate is manual: "Vulkan boots to a clear +/// colour." +/// +internal sealed unsafe class VulkanBringUpHost : IDisposable +{ + /// Two frames in flight, matching plan §4.8 and the GL flight controller. + internal const int FlightCount = 2; + + /// + /// The bring-up clear colour, linear-encoded straight into a UNORM + /// swapchain. A deliberate deep blue: black would be indistinguishable from + /// an unpainted window, and magenta is reserved as the "unresolved texture + /// slot" sentinel everywhere else in this codebase. + /// + internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f]; + + private const string ScreenshotName = "vulkan-bringup"; + private const ulong AcquireTimeoutNanoseconds = 1_000_000_000ul; + + private readonly RuntimeOptions _options; + private readonly GraphicalHostPlatformServices _platform; + private readonly FramePacingPolicy _pacing; + private readonly Action _log; + + private IWindow? _window; + private Silk.NET.Vulkan.Vk? _vk; + private Instance _instance; + private KhrSurface? _surfaceApi; + private SurfaceKHR _surface; + private PhysicalDevice _physicalDevice; + private Device _device; + private KhrSwapchain? _swapchainApi; + private Queue _graphicsQueue; + private Queue _presentQueue; + private VulkanQueueFamilyChoice? _families; + private VulkanSwapchain? _swapchain; + + private CommandPool[] _commandPools = []; + private CommandBuffer[] _commandBuffers = []; + private Semaphore[] _imageAcquired = []; + private Semaphore _timeline; + private ulong _frameSerial; + private bool _recreateAtFrameBoundary; + private bool _disposed; + + internal VulkanBringUpHost( + RuntimeOptions options, + GraphicalHostPlatformServices platform, + bool requestedVSync, + Action? log = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _log = log ?? Console.WriteLine; + // Resolved from the same pure policy the GL path uses, so "VSync on means + // FIFO" is one decision expressed once. The monitor refresh is left + // unknown because slice V5 consumes only UseVSync: there is no software + // pacer here to feed a limit to, and inventing one would be a number + // nothing reads. V6 wires FramePacingController and supplies it. + _pacing = FramePacingPolicy.Resolve( + requestedVSync, + _options.UncappedRendering, + monitorRefreshHz: null); + } + + /// The record the gate evaluated, available after starts. + internal VulkanCapabilityRecord? Capabilities { get; private set; } + + /// + /// Open the window, pass the capability gate, and present the clear colour + /// until the window closes. Throws when + /// the gate rejects the device, which Program.cs turns into exit code + /// 4 exactly as it does for the GL gate. + /// + internal void Run() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + CreateWindow(); + CreateInstanceAndSurface(); + SelectDeviceAndGate(); + CreateFrameResources(); + Present(); + } + + private void CreateWindow() + { + var options = WindowOptions.DefaultVulkan with + { + Size = new Vector2D(1280, 720), + Title = "acdream — Vulkan bring-up (Campaign V slice V5)", + VSync = _pacing.UseVSync, + }; + _window = Window.Create(options); + _window.Initialize(); + if (_window.VkSurface is null) + { + throw new NotSupportedException( + "The windowing backend did not expose a Vulkan surface. " + + "acdream requires GLFW 3.4 built with Vulkan support."); + } + } + + private void CreateInstanceAndSurface() + { + IWindow window = _window!; + _vk = Silk.NET.Vulkan.Vk.GetApi(); + + byte** requiredNames = window.VkSurface!.GetRequiredExtensions(out uint requiredCount); + var required = new List((int)requiredCount); + for (uint i = 0; i < requiredCount; i++) + required.Add(VulkanInterop.ReadString(requiredNames[i])); + + VulkanInstanceFactory.Created instance = VulkanInstanceFactory.Create( + _vk, + required, + enableOptionalExtensions: _options.DevTools); + _instance = instance.Instance; + InstanceExtensions = instance.EnabledExtensions; + + if (!_vk.TryGetInstanceExtension(_instance, out KhrSurface surfaceApi)) + { + throw new NotSupportedException( + "VK_KHR_surface is required but its entry points could not be loaded."); + } + + _surfaceApi = surfaceApi; + _surface = window.VkSurface.Create( + _instance.ToHandle(), + null).ToSurface(); + } + + private IReadOnlyList InstanceExtensions { get; set; } = []; + + private void SelectDeviceAndGate() + { + Silk.NET.Vulkan.Vk vk = _vk!; + IReadOnlyList candidates = + VulkanPhysicalDeviceInspector.Enumerate(vk, _instance, out PhysicalDevice[] handles); + VulkanPhysicalDeviceChoice? choice = VulkanPhysicalDeviceSelection.Choose( + candidates, + _options.VulkanDeviceOverride); + if (choice is null) + { + throw new NotSupportedException( + "No Vulkan physical device was enumerated. Install or update a " + + "Vulkan 1.3 driver for this GPU."); + } + + _physicalDevice = handles[choice.Device.Index]; + + IReadOnlyList queueFamilies = + VulkanPhysicalDeviceInspector.ReadQueueFamilies( + vk, + _physicalDevice, + _surfaceApi, + _surface); + VulkanQueueFamilyChoice? families = VulkanQueueFamilySelection.Choose(queueFamilies); + if (families is null) + { + throw new NotSupportedException( + $"'{choice.Device.DeviceName}' exposes no queue family that can both " + + "render and present to the window surface."); + } + + _families = families; + VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create( + vk, + _physicalDevice, + families, + requireSwapchain: true); + _device = created.Device; + _graphicsQueue = created.GraphicsQueue; + _presentQueue = created.PresentQueue; + + if (!vk.TryGetDeviceExtension(_instance, _device, out KhrSwapchain swapchainApi)) + { + throw new NotSupportedException( + "VK_KHR_swapchain is required but its entry points could not be loaded."); + } + + _swapchainApi = swapchainApi; + _swapchain = new VulkanSwapchain( + vk, + _surfaceApi!, + swapchainApi, + _physicalDevice, + _device, + _surface, + families); + + (SurfaceCapabilitiesKHR surfaceCapabilities, + IReadOnlyList formats, + IReadOnlyList presentModes) = _swapchain.QuerySurface(); + + Vector2D framebuffer = _window!.FramebufferSize; + VulkanSwapchainConfiguration planned = VulkanSwapchainConfigurationFactory.Create( + surfaceCapabilities, + formats, + presentModes, + _pacing, + (uint)Math.Max(0, framebuffer.X), + (uint)Math.Max(0, framebuffer.Y)); + + var surfaceSupport = new VulkanSurfaceSupport( + PresentSupported: true, + SelectedFormat: planned.ImageFormat, + SelectedColorSpace: planned.ColorSpace, + SelectedPresentMode: planned.PresentMode, + SelectedImageCount: planned.ImageCount, + SelectedWidth: planned.Width, + SelectedHeight: planned.Height, + SupportsTransferSource: + VulkanSwapchainConfigurationFactory.SupportsTransferSource(surfaceCapabilities), + AvailableFormats: [.. formats.Select(format => format.Format).Distinct()], + AvailablePresentModes: [.. presentModes]); + + VulkanFunctionProbeResult probe = VulkanActiveDeviceProbe.Run( + vk, + _physicalDevice, + _device, + _graphicsQueue, + families.GraphicsFamily); + + var record = new VulkanCapabilityRecord( + DateTimeOffset.UtcNow, + _platform.RuntimeIdentifier, + _platform.OperatingSystem, + _platform.WindowBackend.RequestedProtocol, + GlfwNativePlatformProbe.GetActiveProtocol(_platform.OperatingSystem), + VulkanApiVersion.Describe( + VulkanApiVersion.Make( + VulkanCapabilityRequirements.RequiredApiMajor, + VulkanCapabilityRequirements.RequiredApiMinor, + 0)), + VulkanApiVersion.Describe(choice.Device.ApiVersion), + choice.Device.ApiVersion, + choice.Device.DeviceName, + VulkanPhysicalDeviceInspector.DescribeDriver(choice.Device), + choice.Device.DeviceType, + choice.Device.Index, + choice.Reason, + _options.VulkanDeviceOverride, + ForcedUnsupportedFeature: null, + candidates, + InstanceExtensions, + created.EnabledExtensions, + families.GraphicsFamily, + families.PresentFamily, + VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice), + VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice), + VulkanPhysicalDeviceInspector.ReadFormats( + vk, + _physicalDevice, + VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)), + surfaceSupport, + probe, + SupportFailures: []); + + record = VulkanCapabilityRequirements.Reevaluate(record); + record = VulkanCapabilityRequirements.ApplyForcedUnsupported( + record, + _options.VulkanForcedUnsupportedFeature); + Capabilities = record; + + string reportPath = Path.Combine( + _platform.Paths.DiagnosticsDirectory, + VulkanCapabilityGuard.ReportFileName); + VulkanCapabilityReportWriter.Write(reportPath, record); + VulkanCapabilityGuard.ThrowIfUnsupported(record, reportPath); + + _log( + "vulkan: capability gate passed " + + $"({record.ActiveDisplayProtocol}, {record.DeviceName}, " + + $"{record.DeviceApiVersion}, {record.DriverInfo}); " + + $"swapchain {planned.ImageFormat}/{planned.PresentMode} " + + $"{planned.Width}x{planned.Height} x{planned.ImageCount}; " + + $"report={reportPath}"); + _log($"vulkan: device selection — {choice.Reason}"); + } + + private void CreateFrameResources() + { + Silk.NET.Vulkan.Vk vk = _vk!; + VulkanQueueFamilyChoice families = _families!; + + _commandPools = new CommandPool[FlightCount]; + _commandBuffers = new CommandBuffer[FlightCount]; + _imageAcquired = new Semaphore[FlightCount]; + for (int i = 0; i < FlightCount; i++) + { + var poolCreate = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = families.GraphicsFamily, + }; + VulkanInterop.Check( + vk.CreateCommandPool(_device, &poolCreate, null, out CommandPool pool), + "vkCreateCommandPool (flight slot)"); + _commandPools[i] = 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 (flight slot)"); + _commandBuffers[i] = commands; + + var semaphoreCreate = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + }; + VulkanInterop.Check( + vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore acquired), + "vkCreateSemaphore (image acquired)"); + _imageAcquired[i] = acquired; + } + + 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 (frame timeline)"); + + RecreateSwapchain(); + } + + private bool RecreateSwapchain() + { + Vector2D framebuffer = _window!.FramebufferSize; + uint width = (uint)Math.Max(0, framebuffer.X); + uint height = (uint)Math.Max(0, framebuffer.Y); + if (VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height) + == VulkanSwapchainAction.Idle) + { + return false; + } + + VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (recreate)"); + _recreateAtFrameBoundary = false; + return _swapchain!.Recreate(_pacing, width, height); + } + + private void Present() + { + IWindow window = _window!; + FrameScreenshotController? screenshots = CreateScreenshotController(); + bool screenshotRequested = false; + + while (!window.IsClosing) + { + window.DoEvents(); + if (window.IsClosing) + break; + + if (_recreateAtFrameBoundary || !_swapchain!.IsCreated) + { + if (!RecreateSwapchain()) + { + // Minimised: idle without burning a core, and without + // pretending a zero-area swapchain can be created. + Thread.Sleep(16); + continue; + } + } + + if (!RenderClearFrame(out uint imageIndex)) + continue; + + if (screenshots is not null && !screenshotRequested) + { + screenshotRequested = true; + if (screenshots.TryRequest(ScreenshotName, out string error)) + { + VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; + _lastPresentedImage = imageIndex; + screenshots.CapturePending( + (int)configuration.Width, + (int)configuration.Height); + } + else + { + _log($"vulkan: screenshot request rejected: {error}"); + } + } + } + + VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)"); + _log($"vulkan: presented {_frameSerial} clear-colour frame(s); shutting down."); + } + + private uint _lastPresentedImage; + + private FrameScreenshotController? CreateScreenshotController() + { + if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory)) + return null; + + return new FrameScreenshotController( + (_, _) => _swapchain!.CaptureImage( + _graphicsQueue, + _families!.GraphicsFamily, + _lastPresentedImage), + _options.AutomationArtifactDirectory, + _log); + } + + /// + /// One clear-colour frame: wait the timeline back to the flight window, + /// acquire, record two barriers around a dynamic-rendering clear, submit, and + /// present. This is the §4.8 frame skeleton with everything between the + /// barriers removed. + /// + private bool RenderClearFrame(out uint imageIndex) + { + imageIndex = 0; + Silk.NET.Vulkan.Vk vk = _vk!; + VulkanSwapchain swapchain = _swapchain!; + + ulong signalValue = _frameSerial + 1; + if (signalValue > FlightCount) + { + ulong waitValue = signalValue - FlightCount; + Semaphore timeline = _timeline; + var wait = new SemaphoreWaitInfo + { + SType = StructureType.SemaphoreWaitInfo, + SemaphoreCount = 1, + PSemaphores = &timeline, + PValues = &waitValue, + }; + VulkanInterop.Check( + vk.WaitSemaphores(_device, &wait, ulong.MaxValue), + "vkWaitSemaphores (frame flight)"); + } + + int slot = (int)((signalValue - 1) % FlightCount); + VulkanSwapchainAction acquired = swapchain.TryAcquire( + _imageAcquired[slot], + AcquireTimeoutNanoseconds, + out imageIndex); + switch (acquired) + { + case VulkanSwapchainAction.RecreateNow: + RecreateSwapchain(); + return false; + case VulkanSwapchainAction.Idle: + return false; + case VulkanSwapchainAction.Fail: + throw new InvalidOperationException( + "vkAcquireNextImageKHR returned an unrecoverable result."); + case VulkanSwapchainAction.RecreateAtFrameBoundary: + _recreateAtFrameBoundary = true; + break; + } + + VulkanInterop.Check( + vk.ResetCommandPool(_device, _commandPools[slot], 0), + "vkResetCommandPool"); + RecordClear(_commandBuffers[slot], swapchain, imageIndex); + + var commandSubmit = new CommandBufferSubmitInfo + { + SType = StructureType.CommandBufferSubmitInfo, + CommandBuffer = _commandBuffers[slot], + }; + var waitSemaphore = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _imageAcquired[slot], + StageMask = PipelineStageFlags2.ColorAttachmentOutputBit, + }; + SemaphoreSubmitInfo* signals = stackalloc SemaphoreSubmitInfo[2]; + signals[0] = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = swapchain.RenderCompleteAt(imageIndex), + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + signals[1] = new SemaphoreSubmitInfo + { + SType = StructureType.SemaphoreSubmitInfo, + Semaphore = _timeline, + Value = signalValue, + StageMask = PipelineStageFlags2.AllCommandsBit, + }; + var submit = new SubmitInfo2 + { + SType = StructureType.SubmitInfo2, + WaitSemaphoreInfoCount = 1, + PWaitSemaphoreInfos = &waitSemaphore, + CommandBufferInfoCount = 1, + PCommandBufferInfos = &commandSubmit, + SignalSemaphoreInfoCount = 2, + PSignalSemaphoreInfos = signals, + }; + VulkanInterop.Check( + vk.QueueSubmit2(_graphicsQueue, 1, &submit, default), + "vkQueueSubmit2 (clear frame)"); + _frameSerial = signalValue; + + VulkanSwapchainAction presented = swapchain.Present(_presentQueue, imageIndex); + switch (presented) + { + case VulkanSwapchainAction.RecreateNow: + RecreateSwapchain(); + return false; + case VulkanSwapchainAction.RecreateAtFrameBoundary: + _recreateAtFrameBoundary = true; + break; + case VulkanSwapchainAction.Fail: + throw new InvalidOperationException( + "vkQueuePresentKHR returned an unrecoverable result."); + } + + return true; + } + + private void RecordClear( + CommandBuffer commands, + VulkanSwapchain swapchain, + uint imageIndex) + { + Silk.NET.Vulkan.Vk vk = _vk!; + VulkanSwapchainConfiguration configuration = swapchain.Configuration!; + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer"); + + var subresource = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + swapchain.TransitionImage( + commands, + swapchain.ImageAt(imageIndex), + subresource, + ImageLayout.Undefined, + ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.TopOfPipeBit, + AccessFlags2.None, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + + var clear = new ClearValue + { + Color = new ClearColorValue + { + Float32_0 = ClearColor[0], + Float32_1 = ClearColor[1], + Float32_2 = ClearColor[2], + Float32_3 = ClearColor[3], + }, + }; + var attachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = swapchain.ViewAt(imageIndex), + ImageLayout = ImageLayout.ColorAttachmentOptimal, + LoadOp = AttachmentLoadOp.Clear, + StoreOp = AttachmentStoreOp.Store, + ClearValue = clear, + }; + var rendering = new RenderingInfo + { + SType = StructureType.RenderingInfo, + RenderArea = new Rect2D( + new Offset2D(0, 0), + new Extent2D(configuration.Width, configuration.Height)), + LayerCount = 1, + ColorAttachmentCount = 1, + PColorAttachments = &attachment, + }; + vk.CmdBeginRendering(commands, &rendering); + vk.CmdEndRendering(commands); + + swapchain.TransitionImage( + commands, + swapchain.ImageAt(imageIndex), + subresource, + ImageLayout.ColorAttachmentOptimal, + ImageLayout.PresentSrcKhr, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit, + PipelineStageFlags2.BottomOfPipeBit, + AccessFlags2.None); + + VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer"); + } + + /// + /// Teardown in strict reverse-construction order. Every handle is checked + /// before destruction because can throw at any stage — a + /// rejected capability gate is a normal, expected exit, not a crash, and it + /// must still leave zero Vulkan objects behind. + /// + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + Silk.NET.Vulkan.Vk? vk = _vk; + if (vk is not null && _device.Handle != 0) + { + vk.DeviceWaitIdle(_device); + + _swapchain?.Dispose(); + _swapchain = null; + + if (_timeline.Handle != 0) + { + vk.DestroySemaphore(_device, _timeline, null); + _timeline = default; + } + + 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); + } + + _imageAcquired = []; + _commandBuffers = []; + _commandPools = []; + + vk.DestroyDevice(_device, null); + _device = default; + } + else + { + _swapchain?.Dispose(); + _swapchain = null; + } + + if (vk is not null && _surfaceApi is not null && _surface.Handle != 0) + { + _surfaceApi.DestroySurface(_instance, _surface, null); + _surface = default; + } + + _swapchainApi?.Dispose(); + _swapchainApi = null; + _surfaceApi?.Dispose(); + _surfaceApi = null; + + if (vk is not null && _instance.Handle != 0) + { + vk.DestroyInstance(_instance, null); + _instance = default; + } + + vk?.Dispose(); + _vk = null; + + _window?.Dispose(); + _window = null; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs new file mode 100644 index 00000000..6374f8a6 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs @@ -0,0 +1,665 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.App.Platform; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Every Vulkan device feature the acdream renderer requires, with the reason +/// each one is mandatory recorded next to it (Campaign V plan §4.1). +/// +/// This is a plain data record with no Silk handles in it, which is the point: +/// the interop layer fills it from vkGetPhysicalDeviceFeatures2, the +/// forced-unsupported gate knob clears one field of it, and +/// turns it into operator-facing +/// sentences — all three testable with no driver, no device, and no window. +/// +internal sealed record VulkanDeviceFeatureSupport +{ + // ---- core 1.0 ---- + + /// The three MDI dispatch sites are the entire draw architecture. + public required bool MultiDrawIndirect { get; init; } + + /// Indirect commands carry a non-zero firstInstance as the per-group instance base. + public required bool DrawIndirectFirstInstance { get; init; } + + /// Phase U.3's per-cell screen-space clip gate writes gl_ClipDistance[8]. + public required bool ShaderClipDistance { get; init; } + + /// DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode. + public required bool TextureCompressionBc { get; init; } + + /// Sampler-quality parity with the GL path. + public required bool SamplerAnisotropy { get; init; } + + // ---- 1.1 ---- + + /// gl_DrawID. Resets per indirect dispatch exactly as GL's does. + public required bool ShaderDrawParameters { get; init; } + + // ---- 1.2 ---- + + /// One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys. + public required bool TimelineSemaphore { get; init; } + + /// Reset timestamp pools from the CPU instead of burning command-buffer calls. + public required bool HostQueryReset { get; init; } + + /// The global texture table is a runtime-sized descriptor array. + public required bool RuntimeDescriptorArray { get; init; } + + /// Unregistered table slots are legitimately absent rather than an error. + public required bool DescriptorBindingPartiallyBound { get; init; } + + /// Texture registration appends a descriptor write without rebuilding the set. + public required bool DescriptorBindingSampledImageUpdateAfterBind { get; init; } + + /// A slot may be rewritten while a command buffer that does not read it is pending. + public required bool DescriptorBindingUpdateUnusedWhilePending { get; init; } + + /// The table's 16384-slot capacity is a variable descriptor count. + public required bool DescriptorBindingVariableDescriptorCount { get; init; } + + /// + /// nonuniformEXT in the fragment shaders. Required, not optional: + /// within one MDI dispatch different draws read different Batches[] + /// entries, and "dynamically uniform" is defined over the whole dispatch on + /// some implementations (plan §4.6). + /// + public required bool ShaderSampledImageArrayNonUniformIndexing { get; init; } + + // ---- 1.3 ---- + + /// No render-pass or framebuffer objects anywhere in the frame. + public required bool DynamicRendering { get; init; } + + /// Every barrier in the frame skeleton is a vkCmdPipelineBarrier2. + public required bool Synchronization2 { get; init; } + + /// Relaxed shader interface rules for the dual-legal GLSL sources. + public required bool Maintenance4 { get; init; } + + /// + /// Every feature present. The starting point for the forced-unsupported gate + /// knob and for tests that assert one specific absence at a time. + /// + internal static VulkanDeviceFeatureSupport Complete { get; } = new() + { + MultiDrawIndirect = true, + DrawIndirectFirstInstance = true, + ShaderClipDistance = true, + TextureCompressionBc = true, + SamplerAnisotropy = true, + ShaderDrawParameters = true, + TimelineSemaphore = true, + HostQueryReset = true, + RuntimeDescriptorArray = true, + DescriptorBindingPartiallyBound = true, + DescriptorBindingSampledImageUpdateAfterBind = true, + DescriptorBindingUpdateUnusedWhilePending = true, + DescriptorBindingVariableDescriptorCount = true, + ShaderSampledImageArrayNonUniformIndexing = true, + DynamicRendering = true, + Synchronization2 = true, + Maintenance4 = true, + }; + + /// + /// Returns this record with the named feature cleared, or null when + /// the name matches no required feature. Drives + /// ACDREAM_VULKAN_FORCE_UNSUPPORTED; matching is case-insensitive on + /// the property name. + /// + internal VulkanDeviceFeatureSupport? Without(string featureName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(featureName); + return featureName.Trim() switch + { + var n when Is(n, nameof(MultiDrawIndirect)) => this with { MultiDrawIndirect = false }, + var n when Is(n, nameof(DrawIndirectFirstInstance)) => this with { DrawIndirectFirstInstance = false }, + var n when Is(n, nameof(ShaderClipDistance)) => this with { ShaderClipDistance = false }, + var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false }, + var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false }, + var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false }, + var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false }, + var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false }, + var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false }, + var n when Is(n, nameof(DescriptorBindingPartiallyBound)) => this with { DescriptorBindingPartiallyBound = false }, + var n when Is(n, nameof(DescriptorBindingSampledImageUpdateAfterBind)) => this with { DescriptorBindingSampledImageUpdateAfterBind = false }, + var n when Is(n, nameof(DescriptorBindingUpdateUnusedWhilePending)) => this with { DescriptorBindingUpdateUnusedWhilePending = false }, + var n when Is(n, nameof(DescriptorBindingVariableDescriptorCount)) => this with { DescriptorBindingVariableDescriptorCount = false }, + var n when Is(n, nameof(ShaderSampledImageArrayNonUniformIndexing)) => this with { ShaderSampledImageArrayNonUniformIndexing = false }, + var n when Is(n, nameof(DynamicRendering)) => this with { DynamicRendering = false }, + var n when Is(n, nameof(Synchronization2)) => this with { Synchronization2 = false }, + var n when Is(n, nameof(Maintenance4)) => this with { Maintenance4 = false }, + _ => null, + }; + + static bool Is(string candidate, string name) + => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase); + } +} + +/// +/// The device limits the plan asserts up front rather than discovering at draw +/// time (plan §4.1, §3.4). +/// +internal sealed record VulkanDeviceLimitSupport +{ + /// Must reach ; Vulkan guarantees 128. + public required uint MaxPushConstantsSize { get; init; } + + /// Must reach . + public required uint MaxClipDistances { get; init; } + + /// Sets 0, 1 and 2 are all bound simultaneously, so at least 3. + public required uint MaxBoundDescriptorSets { get; init; } + + /// Must reach . + public required uint MaxDescriptorSetUpdateAfterBindSampledImages { get; init; } + + /// Must reach for the fragment stage. + public required uint MaxPerStageDescriptorUpdateAfterBindSampledImages { get; init; } + + /// The frame profiler's GPU timings need graphics-queue timestamps. + public required bool TimestampComputeAndGraphics { get; init; } + + /// Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan. + public required uint MinStorageBufferOffsetAlignment { get; init; } + + /// As above, for the SceneLighting uniform block. + public required uint MinUniformBufferOffsetAlignment { get; init; } + + /// Largest 2D image edge; the terrain atlas and composite arrays are sized against it. + public required uint MaxImageDimension2D { get; init; } + + /// Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...). + public required uint MaxColorSampleCount { get; init; } + + /// + /// A profile that satisfies every requirement, used as the base for tests + /// and for the forced-unsupported knob. The numbers are the Vulkan 1.3 + /// guaranteed minimums where a guarantee exists, and the acdream requirement + /// where it does not. + /// + internal static VulkanDeviceLimitSupport Complete { get; } = new() + { + MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes, + MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, + MaxBoundDescriptorSets = 4, + MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity, + MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity, + TimestampComputeAndGraphics = true, + MinStorageBufferOffsetAlignment = 256, + MinUniformBufferOffsetAlignment = 256, + MaxImageDimension2D = 16384, + MaxColorSampleCount = 8, + }; +} + +/// +/// Format-level support that features alone do not prove: the swapchain colour +/// format, a depth+stencil format for #117's portal punch, and the three BC +/// blocks the DAT surfaces upload as. +/// +internal sealed record VulkanFormatSupport +{ + /// + /// B8G8R8A8_UNORM is offered by the surface. Corrected at slice V3: + /// the renderer is plain UNORM end to end and an _SRGB swapchain + /// would apply an unwanted encode to already-display-space values. + /// + public required bool SwapchainUnormFormat { get; init; } + + /// The chosen depth+stencil format, or when none is usable. + public required Format DepthStencilFormat { get; init; } + + /// BC1 (DXT1) sampled-image support with optimal tiling. + public required bool Bc1Sampled { get; init; } + + /// BC2 (DXT3) sampled-image support with optimal tiling. + public required bool Bc2Sampled { get; init; } + + /// BC3 (DXT5) sampled-image support with optimal tiling. + public required bool Bc3Sampled { get; init; } + + internal static VulkanFormatSupport Complete { get; } = new() + { + SwapchainUnormFormat = true, + DepthStencilFormat = Format.D32SfloatS8Uint, + Bc1Sampled = true, + Bc2Sampled = true, + Bc3Sampled = true, + }; +} + +/// +/// What the presentation surface offers, and what slice V5 selected from it. +/// Null on the record when the device was probed headlessly (no window), which +/// is how the capability logic stays testable without opening one. +/// +internal sealed record VulkanSurfaceSupport( + bool PresentSupported, + Format SelectedFormat, + ColorSpaceKHR SelectedColorSpace, + PresentModeKHR SelectedPresentMode, + uint SelectedImageCount, + uint SelectedWidth, + uint SelectedHeight, + bool SupportsTransferSource, + IReadOnlyList AvailableFormats, + IReadOnlyList AvailablePresentModes); + +/// +/// One physical device as the selector sees it. Ordering and tie-breaking are +/// computed from these fields alone (plan §4.11), so the ranking is unit-tested +/// without enumerating a real instance. +/// +internal sealed record VulkanPhysicalDeviceCandidate( + int Index, + string DeviceName, + PhysicalDeviceType DeviceType, + uint ApiVersion, + uint DriverVersion, + uint VendorId, + uint DeviceId, + ulong DeviceLocalHeapBytes); + +/// +/// Result of the active Vulkan probe. Mirrors +/// GraphicalFunctionProbeResult: advertisement is not evidence, so the +/// probe really creates the device, the descriptor layouts, an offscreen target, +/// and a submitted command buffer, then reads pixels back. +/// +internal sealed record VulkanFunctionProbeResult( + bool DeviceCreation, + bool DescriptorIndexingLayout, + bool PushConstantLayout, + bool DynamicRenderingClear, + bool TimelineSemaphoreWait, + bool HostQueryReset, + bool OffscreenReadback, + IReadOnlyList Failures) +{ + internal static VulkanFunctionProbeResult NotRun { get; } = new( + false, + false, + false, + false, + false, + false, + false, + ["active Vulkan device probe did not run"]); +} + +/// +/// Campaign V slice V5 — the Vulkan sibling of +/// GraphicalCapabilityRecord. Passive capture plus the active probe +/// result plus the derived failure list, written atomically to +/// graphical-capabilities-vulkan.json and turned into the same +/// → exit-code-4 contract. +/// +internal sealed record VulkanCapabilityRecord( + DateTimeOffset CapturedAtUtc, + string RuntimeIdentifier, + GraphicalHostOperatingSystem OperatingSystem, + GraphicalDisplayProtocol RequestedDisplayProtocol, + GraphicalDisplayProtocol ActiveDisplayProtocol, + string InstanceApiVersion, + string DeviceApiVersion, + uint DeviceApiVersionPacked, + string DeviceName, + string DriverInfo, + PhysicalDeviceType DeviceType, + int SelectedDeviceIndex, + string DeviceSelectionReason, + string? RequestedDeviceOverride, + string? ForcedUnsupportedFeature, + IReadOnlyList AvailableDevices, + IReadOnlyList InstanceExtensions, + IReadOnlyList DeviceExtensions, + uint GraphicsQueueFamily, + uint PresentQueueFamily, + VulkanDeviceFeatureSupport Features, + VulkanDeviceLimitSupport Limits, + VulkanFormatSupport Formats, + VulkanSurfaceSupport? Surface, + VulkanFunctionProbeResult FunctionProbe, + IReadOnlyList SupportFailures) +{ + internal bool IsSupported => SupportFailures.Count == 0; + + /// + /// Project onto the backend-neutral contract the renderers consult. The + /// alignment fields carry across verbatim because ring allocations are + /// validated against them, and SupportsPersistentlyMappedRings is + /// unconditionally true: writing per-frame data straight into mapped memory + /// is the mechanism behind Campaign V's CPU-cost target (plan §4.3). + /// + internal GpuCapabilityRecord ToGpuCapabilityRecord() => new() + { + Backend = GpuBackendKind.Vulkan, + DeviceName = DeviceName, + DriverInfo = DriverInfo, + ApiVersion = DeviceApiVersion, + MaxTextureTableSlots = + Math.Min( + Limits.MaxDescriptorSetUpdateAfterBindSampledImages, + Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages), + // Sets 0..2 give each binding its own namespace, so the ten storage + // bindings the model declares are always all available once the set + // count requirement passes. There is no per-set binding-count limit in + // Vulkan below maxPerStageDescriptorStorageBuffers, which is far higher. + MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, + MaxPushConstantBytes = Limits.MaxPushConstantsSize, + MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment, + MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment, + MaxClipDistances = Limits.MaxClipDistances, + MaxSampleCount = Limits.MaxColorSampleCount, + SupportsMultiDrawIndirect = Features.MultiDrawIndirect, + SupportsDrawParameters = Features.ShaderDrawParameters, + SupportsTextureCompressionBc = Features.TextureCompressionBc, + SupportsTimestampQueries = Limits.TimestampComputeAndGraphics, + SupportsPersistentlyMappedRings = true, + }; +} + +/// +/// Turns a captured into operator-facing +/// failure sentences. Deliberately the exact shape of +/// GraphicalCapabilityRequirements.Evaluate, and deliberately NOT +/// carrying its stale sRGB-framebuffer requirement forward: slice V3 established +/// that the renderer never enables sRGB encoding anywhere (plan §4.10). +/// +internal static class VulkanCapabilityRequirements +{ + /// Vulkan 1.3 is the floor; nothing below it is considered. + internal const uint RequiredApiMajor = 1; + + /// Vulkan 1.3 is the floor; nothing below it is considered. + internal const uint RequiredApiMinor = 3; + + internal static IReadOnlyList Evaluate(VulkanCapabilityRecord capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + var failures = new List(); + + uint major = VulkanApiVersion.Major(capabilities.DeviceApiVersionPacked); + uint minor = VulkanApiVersion.Minor(capabilities.DeviceApiVersionPacked); + if (major < RequiredApiMajor || (major == RequiredApiMajor && minor < RequiredApiMinor)) + { + failures.Add( + $"Vulkan {RequiredApiMajor}.{RequiredApiMinor} is required; " + + $"the selected device reports {major}.{minor}."); + } + + VulkanDeviceFeatureSupport features = capabilities.Features; + if (!features.MultiDrawIndirect) + failures.Add("multiDrawIndirect is required to submit world geometry."); + if (!features.DrawIndirectFirstInstance) + failures.Add("drawIndirectFirstInstance is required; indirect commands carry a per-group instance base."); + if (!features.ShaderDrawParameters) + failures.Add("shaderDrawParameters (gl_DrawID) is required to select per-draw batch data."); + if (!features.ShaderClipDistance) + failures.Add("shaderClipDistance is required by the per-cell clip gate."); + if (!features.TextureCompressionBc) + failures.Add("textureCompressionBC is required to upload DAT surfaces without transcoding."); + if (!features.SamplerAnisotropy) + failures.Add("samplerAnisotropy is required for sampler-quality parity."); + if (!features.TimelineSemaphore) + failures.Add("timelineSemaphore is required; the frame serial is the semaphore value."); + if (!features.HostQueryReset) + failures.Add("hostQueryReset is required to reset timestamp pools from the CPU."); + if (!features.RuntimeDescriptorArray) + failures.Add("runtimeDescriptorArray is required by the global texture table."); + if (!features.DescriptorBindingPartiallyBound) + failures.Add("descriptorBindingPartiallyBound is required; unregistered texture slots are legitimately absent."); + if (!features.DescriptorBindingSampledImageUpdateAfterBind) + failures.Add("descriptorBindingSampledImageUpdateAfterBind is required to register textures without rebuilding the set."); + if (!features.DescriptorBindingUpdateUnusedWhilePending) + failures.Add("descriptorBindingUpdateUnusedWhilePending is required to recycle texture slots while frames are in flight."); + if (!features.DescriptorBindingVariableDescriptorCount) + failures.Add("descriptorBindingVariableDescriptorCount is required to size the texture table."); + if (!features.ShaderSampledImageArrayNonUniformIndexing) + failures.Add("shaderSampledImageArrayNonUniformIndexing is required; one indirect dispatch reads different texture slots per draw."); + if (!features.DynamicRendering) + failures.Add("dynamicRendering is required; the frame uses no render-pass or framebuffer objects."); + if (!features.Synchronization2) + failures.Add("synchronization2 is required; every barrier in the frame is a barrier2."); + if (!features.Maintenance4) + failures.Add("maintenance4 is required for the relaxed shader interface rules the shared GLSL relies on."); + + VulkanDeviceLimitSupport limits = capabilities.Limits; + if (limits.MaxPushConstantsSize < GpuBindingModel.PushConstantBytes) + { + failures.Add( + $"{GpuBindingModel.PushConstantBytes} push-constant bytes are required; " + + $"this device provides {limits.MaxPushConstantsSize}."); + } + if (limits.MaxClipDistances < GpuBindingModel.ClipPlanesPerSlot) + { + failures.Add( + $"{GpuBindingModel.ClipPlanesPerSlot} clip distances are required by the per-cell clip gate; " + + $"this device provides {limits.MaxClipDistances}."); + } + if (limits.MaxBoundDescriptorSets < VulkanDescriptorSetCount) + { + failures.Add( + $"{VulkanDescriptorSetCount} simultaneously bound descriptor sets are required " + + $"(storage, uniform, texture table); this device provides {limits.MaxBoundDescriptorSets}."); + } + if (limits.MaxDescriptorSetUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity) + { + failures.Add( + $"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images; " + + $"this device provides {limits.MaxDescriptorSetUpdateAfterBindSampledImages} per set."); + } + if (limits.MaxPerStageDescriptorUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity) + { + failures.Add( + $"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images " + + $"in the fragment stage; this device provides {limits.MaxPerStageDescriptorUpdateAfterBindSampledImages}."); + } + if (!limits.TimestampComputeAndGraphics) + failures.Add("graphics-queue timestamps are required by the frame profiler."); + + VulkanFormatSupport formats = capabilities.Formats; + if (!formats.SwapchainUnormFormat) + { + failures.Add( + "the presentation surface must offer B8G8R8A8_UNORM; the renderer is " + + "plain UNORM end to end and an sRGB swapchain would re-encode every frame."); + } + if (formats.DepthStencilFormat == Format.Undefined) + failures.Add("a combined depth+stencil format is required by the portal aperture punch."); + if (!formats.Bc1Sampled || !formats.Bc2Sampled || !formats.Bc3Sampled) + failures.Add("BC1, BC2 and BC3 sampled-image support is required to upload DAT surfaces."); + + if (capabilities.Surface is { } surface) + { + if (!surface.PresentSupported) + failures.Add("the selected device cannot present to the window surface."); + if (!surface.SupportsTransferSource) + failures.Add("the swapchain must support TRANSFER_SRC usage for screenshot capture."); + } + + if (capabilities.FunctionProbe.Failures.Count != 0) + { + failures.AddRange( + capabilities.FunctionProbe.Failures.Select( + failure => $"Vulkan device probe: {failure}")); + } + else + { + if (!capabilities.FunctionProbe.DeviceCreation) + failures.Add("the Vulkan device-creation probe did not pass."); + if (!capabilities.FunctionProbe.DescriptorIndexingLayout) + failures.Add("the descriptor-indexing layout probe did not pass."); + if (!capabilities.FunctionProbe.PushConstantLayout) + failures.Add("the push-constant pipeline-layout probe did not pass."); + if (!capabilities.FunctionProbe.DynamicRenderingClear) + failures.Add("the dynamic-rendering clear probe did not pass."); + if (!capabilities.FunctionProbe.TimelineSemaphoreWait) + failures.Add("the timeline-semaphore wait probe did not pass."); + if (!capabilities.FunctionProbe.HostQueryReset) + failures.Add("the host query-reset probe did not pass."); + if (!capabilities.FunctionProbe.OffscreenReadback) + failures.Add("the offscreen readback probe did not return the expected pixels."); + } + + return failures; + } + + /// + /// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once, + /// so maxBoundDescriptorSets must reach 3. Vulkan guarantees 4. + /// + internal const uint VulkanDescriptorSetCount = + GpuBindingModel.TextureTableSet + 1; + + /// + /// Re-derive after any + /// mutation. Mirrors GraphicalCapabilityProbe.WithFunctionProbe: the + /// record is never trusted to carry a stale failure list. + /// + internal static VulkanCapabilityRecord Reevaluate(VulkanCapabilityRecord capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + VulkanCapabilityRecord cleared = capabilities with { SupportFailures = [] }; + return cleared with { SupportFailures = Evaluate(cleared) }; + } + + /// + /// Apply ACDREAM_VULKAN_FORCE_UNSUPPORTED. An unrecognised name is a + /// hard failure rather than a silent no-op: a gate knob that quietly does + /// nothing would report a pass the operator did not actually get. + /// + internal static VulkanCapabilityRecord ApplyForcedUnsupported( + VulkanCapabilityRecord capabilities, + string? featureName) + { + ArgumentNullException.ThrowIfNull(capabilities); + if (string.IsNullOrWhiteSpace(featureName)) + return capabilities; + + VulkanDeviceFeatureSupport? forced = capabilities.Features.Without(featureName); + if (forced is null) + { + return Reevaluate( + capabilities with + { + ForcedUnsupportedFeature = featureName, + FunctionProbe = capabilities.FunctionProbe with + { + Failures = + [ + .. capabilities.FunctionProbe.Failures, + $"ACDREAM_VULKAN_FORCE_UNSUPPORTED named '{featureName}', " + + "which is not a required Vulkan feature.", + ], + }, + }); + } + + return Reevaluate( + capabilities with + { + Features = forced, + ForcedUnsupportedFeature = featureName, + }); + } +} + +/// Packed VK_MAKE_API_VERSION arithmetic, kept out of the interop layer so it is testable. +internal static class VulkanApiVersion +{ + internal static uint Major(uint packed) => (packed >> 22) & 0x7Fu; + + internal static uint Minor(uint packed) => (packed >> 12) & 0x3FFu; + + internal static uint Patch(uint packed) => packed & 0xFFFu; + + internal static uint Make(uint major, uint minor, uint patch) + => (major << 22) | (minor << 12) | patch; + + internal static string Describe(uint packed) + => $"Vulkan {Major(packed)}.{Minor(packed)}.{Patch(packed)}"; +} + +/// +/// The same throw/format/report contract the GL gate publishes: an unsupported +/// device raises , which Program.cs +/// turns into exit code 4 next to the written report. +/// +internal static class VulkanCapabilityGuard +{ + /// File name of the Vulkan report, beside the GL one in the diagnostics directory. + internal const string ReportFileName = "graphical-capabilities-vulkan.json"; + + internal static void ThrowIfUnsupported( + VulkanCapabilityRecord capabilities, + string reportPath) + { + ArgumentNullException.ThrowIfNull(capabilities); + if (!capabilities.IsSupported) + throw new NotSupportedException(FormatUnsupportedMessage(capabilities, reportPath)); + } + + internal static string FormatUnsupportedMessage( + VulkanCapabilityRecord capabilities, + string reportPath) + { + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentException.ThrowIfNullOrWhiteSpace(reportPath); + return + "acdream's Vulkan renderer is unsupported by the selected device.\n" + + $"Platform: {capabilities.RuntimeIdentifier}, " + + $"{capabilities.ActiveDisplayProtocol}, " + + $"{capabilities.DeviceName} ({capabilities.DeviceType}), " + + $"{capabilities.DeviceApiVersion}, {capabilities.DriverInfo}\n" + + string.Join( + "\n", + capabilities.SupportFailures.Select(failure => $" - {failure}")) + + $"\nFull capability report: {Path.GetFullPath(reportPath)}"; + } +} + +/// +/// Atomic JSON writer, byte-for-byte the same temp-then-move contract as +/// GraphicalCapabilityReportWriter so a crashed launch never leaves a +/// half-written report behind. +/// +internal static class VulkanCapabilityReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter(), + }, + }; + + internal static void Write(string path, VulkanCapabilityRecord capabilities) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(capabilities); + string fullPath = Path.GetFullPath(path); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + string temporaryPath = fullPath + ".tmp"; + File.WriteAllText(temporaryPath, Serialize(capabilities)); + File.Move(temporaryPath, fullPath, overwrite: true); + } + + /// Exposed so the report's shape can be asserted without touching the file system. + internal static string Serialize(VulkanCapabilityRecord capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + return JsonSerializer.Serialize(capabilities, Options); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs new file mode 100644 index 00000000..69242e0d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs @@ -0,0 +1,94 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// The extensions actually asked for, plus the optional ones that were not available. +internal sealed record VulkanExtensionPlan( + IReadOnlyList Enabled, + IReadOnlyList MissingRequired, + IReadOnlyList UnavailableOptional) +{ + internal bool IsSatisfied => MissingRequired.Count == 0; +} + +/// +/// Campaign V slice V5, plan §4.1: "Vulkan 1.3 core plus VK_KHR_swapchain +/// (and the platform surface extensions). Optional and never required: +/// VK_EXT_memory_budget (telemetry), VK_EXT_debug_utils (object +/// naming in dev builds), VK_KHR_present_wait (an issue #235 +/// experiment)." +/// +/// Pure set arithmetic over extension name lists, so "an optional extension that +/// is missing must not stop startup, and a required one must" is a unit test +/// rather than a driver-dependent discovery. +/// +internal static class VulkanExtensionSelection +{ + /// Object naming for RenderDoc and validation output. Never required. + internal const string DebugUtilsExtension = "VK_EXT_debug_utils"; + + /// Real allocator headroom for GpuMemoryTracker. Never required. + internal const string MemoryBudgetExtension = "VK_EXT_memory_budget"; + + /// An issue #235 present-pacing experiment. Never required. + internal const string PresentWaitExtension = "VK_KHR_present_wait"; + + /// Presenting to a surface. Required on the real device; absent in the headless probe. + internal const string SwapchainExtension = "VK_KHR_swapchain"; + + /// + /// Resolve a plan. comes from Silk's + /// IVkSurface.GetRequiredExtensions for the instance and is a fixed + /// list for the device; is enabled only where + /// advertised. Comparison is ordinal — Vulkan extension names are ASCII and + /// case-sensitive. + /// + internal static VulkanExtensionPlan Resolve( + IReadOnlyList available, + IReadOnlyList required, + IReadOnlyList optional) + { + ArgumentNullException.ThrowIfNull(available); + ArgumentNullException.ThrowIfNull(required); + ArgumentNullException.ThrowIfNull(optional); + + var advertised = new HashSet(available, StringComparer.Ordinal); + var enabled = new List(); + var missing = new List(); + var unavailable = new List(); + + foreach (string name in required) + { + if (advertised.Contains(name)) + { + if (!enabled.Contains(name, StringComparer.Ordinal)) + enabled.Add(name); + } + else if (!missing.Contains(name, StringComparer.Ordinal)) + { + missing.Add(name); + } + } + + foreach (string name in optional) + { + if (advertised.Contains(name)) + { + if (!enabled.Contains(name, StringComparer.Ordinal)) + enabled.Add(name); + } + else if (!unavailable.Contains(name, StringComparer.Ordinal)) + { + unavailable.Add(name); + } + } + + return new VulkanExtensionPlan(enabled, missing, unavailable); + } + + /// The optional instance extensions slice V5 will take if offered. + internal static IReadOnlyList OptionalInstanceExtensions { get; } = + [DebugUtilsExtension]; + + /// The optional device extensions slice V5 will take if offered. + internal static IReadOnlyList OptionalDeviceExtensions { get; } = + [MemoryBudgetExtension, PresentWaitExtension]; +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs new file mode 100644 index 00000000..df359f5d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs @@ -0,0 +1,635 @@ +using Silk.NET.Core; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// Raised when a Vulkan entry point returns a failure code. +internal sealed class VulkanCallException : InvalidOperationException +{ + internal VulkanCallException(string operation, Result result) + : base($"Vulkan call failed: {operation} returned {result}.") + { + Operation = operation; + Result = result; + } + + internal string Operation { get; } + + internal Result Result { get; } +} + +/// +/// Small marshalling and error-checking helpers shared by the slice V5 interop +/// files. Deliberately tiny: the interesting decisions all live in the pure +/// selection types beside this one, which is what lets them be unit-tested with +/// no driver present. +/// +internal static unsafe class VulkanInterop +{ + /// Throw with the operation named when a call did not succeed. + internal static void Check(Result result, string operation) + { + if (result != Result.Success) + throw new VulkanCallException(operation, result); + } + + /// Read a NUL-terminated ASCII field out of a Vulkan struct. + internal static string ReadString(byte* value) + => value is null ? string.Empty : SilkMarshal.PtrToString((nint)value) ?? string.Empty; + + /// + /// Encode a managed string list as the const char* const* Vulkan + /// expects. The returned handle owns the native memory and must be freed. + /// + internal static nint AllocateStringArray(IReadOnlyList values) + => SilkMarshal.StringArrayToPtr(values.ToArray()); + + internal static void FreeStringArray(nint handle) + { + if (handle != 0) + SilkMarshal.Free(handle); + } + + /// Enumerate the instance extension names the loader advertises. + internal static IReadOnlyList EnumerateInstanceExtensions(Silk.NET.Vulkan.Vk vk) + { + ArgumentNullException.ThrowIfNull(vk); + uint count = 0; + Check( + vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, null), + "vkEnumerateInstanceExtensionProperties (count)"); + if (count == 0) + return []; + + var properties = new ExtensionProperties[count]; + fixed (ExtensionProperties* first = properties) + { + Check( + vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, first), + "vkEnumerateInstanceExtensionProperties"); + } + + return ReadNames(properties, count); + } + + /// Enumerate the device extension names one physical device advertises. + internal static IReadOnlyList EnumerateDeviceExtensions( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device) + { + ArgumentNullException.ThrowIfNull(vk); + uint count = 0; + Check( + vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, null), + "vkEnumerateDeviceExtensionProperties (count)"); + if (count == 0) + return []; + + var properties = new ExtensionProperties[count]; + fixed (ExtensionProperties* first = properties) + { + Check( + vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, first), + "vkEnumerateDeviceExtensionProperties"); + } + + return ReadNames(properties, count); + } + + private static IReadOnlyList ReadNames(ExtensionProperties[] properties, uint count) + { + var names = new List((int)count); + for (uint i = 0; i < count && i < properties.Length; i++) + { + ExtensionProperties entry = properties[i]; + names.Add(ReadString(entry.ExtensionName)); + } + + names.Sort(StringComparer.Ordinal); + return names; + } +} + +/// +/// Slice V5 interop: create the Vulkan instance. +/// +/// The instance is created at API version 1.3 because the whole feature floor is +/// core-1.3; a loader that cannot honour that fails here with a clear message +/// rather than producing a device that silently lacks +/// dynamicRendering. +/// +internal sealed unsafe class VulkanInstanceFactory +{ + internal sealed record Created( + Instance Instance, + IReadOnlyList EnabledExtensions, + IReadOnlyList UnavailableOptionalExtensions, + uint ApiVersion); + + /// + /// Create the instance. comes from + /// Silk's surface (VK_KHR_surface plus the platform one) and is empty + /// for the headless capability probe. + /// + internal static Created Create( + Silk.NET.Vulkan.Vk vk, + IReadOnlyList requiredExtensions, + bool enableOptionalExtensions) + { + ArgumentNullException.ThrowIfNull(vk); + ArgumentNullException.ThrowIfNull(requiredExtensions); + + IReadOnlyList available = VulkanInterop.EnumerateInstanceExtensions(vk); + VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve( + available, + requiredExtensions, + enableOptionalExtensions + ? VulkanExtensionSelection.OptionalInstanceExtensions + : []); + if (!plan.IsSatisfied) + { + throw new NotSupportedException( + "The Vulkan loader does not advertise the required instance " + + $"extension(s): {string.Join(", ", plan.MissingRequired)}."); + } + + uint apiVersion = VulkanApiVersion.Make( + VulkanCapabilityRequirements.RequiredApiMajor, + VulkanCapabilityRequirements.RequiredApiMinor, + 0); + + nint applicationName = SilkMarshal.StringToPtr("acdream"); + nint engineName = SilkMarshal.StringToPtr("acdream"); + nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled); + try + { + var application = new ApplicationInfo + { + SType = StructureType.ApplicationInfo, + PApplicationName = (byte*)applicationName, + ApplicationVersion = VulkanApiVersion.Make(0, 1, 0), + PEngineName = (byte*)engineName, + EngineVersion = VulkanApiVersion.Make(0, 1, 0), + ApiVersion = apiVersion, + }; + var create = new InstanceCreateInfo + { + SType = StructureType.InstanceCreateInfo, + PApplicationInfo = &application, + EnabledExtensionCount = (uint)plan.Enabled.Count, + PpEnabledExtensionNames = (byte**)extensionNames, + EnabledLayerCount = 0, + PpEnabledLayerNames = null, + }; + + VulkanInterop.Check( + vk.CreateInstance(&create, null, out Instance instance), + "vkCreateInstance"); + return new Created( + instance, + plan.Enabled, + plan.UnavailableOptional, + apiVersion); + } + finally + { + VulkanInterop.FreeStringArray(extensionNames); + SilkMarshal.Free(engineName); + SilkMarshal.Free(applicationName); + } + } +} + +/// +/// Slice V5 interop: read one physical device's identity, features, limits and +/// formats into the pure records the capability gate evaluates. +/// +/// Everything this class produces is plain data. That is the seam: the gate's +/// accept/reject matrix is exercised in unit tests against synthesised records, +/// and this class only has to be right about which Vulkan field feeds which +/// property. +/// +internal static unsafe class VulkanPhysicalDeviceInspector +{ + /// Enumerate every physical device as a rankable candidate. + internal static IReadOnlyList Enumerate( + Silk.NET.Vulkan.Vk vk, + Instance instance, + out PhysicalDevice[] handles) + { + ArgumentNullException.ThrowIfNull(vk); + + uint count = 0; + VulkanInterop.Check( + vk.EnumeratePhysicalDevices(instance, ref count, null), + "vkEnumeratePhysicalDevices (count)"); + handles = new PhysicalDevice[count]; + if (count == 0) + return []; + + fixed (PhysicalDevice* first = handles) + { + VulkanInterop.Check( + vk.EnumeratePhysicalDevices(instance, ref count, first), + "vkEnumeratePhysicalDevices"); + } + + var candidates = new List((int)count); + for (int i = 0; i < handles.Length; i++) + { + PhysicalDeviceProperties properties; + vk.GetPhysicalDeviceProperties(handles[i], &properties); + candidates.Add( + new VulkanPhysicalDeviceCandidate( + i, + VulkanInterop.ReadString(properties.DeviceName), + properties.DeviceType, + properties.ApiVersion, + properties.DriverVersion, + properties.VendorID, + properties.DeviceID, + LargestDeviceLocalHeap(vk, handles[i]))); + } + + return candidates; + } + + /// + /// Sum of the device-local heaps, which is the tie-break the plan specifies. + /// Summed rather than maximised because a device that splits its VRAM across + /// heaps is not smaller than one that does not. + /// + internal static ulong LargestDeviceLocalHeap( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device) + { + PhysicalDeviceMemoryProperties memory; + vk.GetPhysicalDeviceMemoryProperties(device, &memory); + ulong total = 0; + for (uint i = 0; i < memory.MemoryHeapCount && i < 16; i++) + { + MemoryHeap heap = memory.MemoryHeaps[(int)i]; + if (heap.Flags.HasFlag(MemoryHeapFlags.DeviceLocalBit)) + total += heap.Size; + } + + return total; + } + + /// Read the §4.1 feature chain through vkGetPhysicalDeviceFeatures2. + internal static VulkanDeviceFeatureSupport ReadFeatures( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device) + { + ArgumentNullException.ThrowIfNull(vk); + + var vulkan13 = new PhysicalDeviceVulkan13Features + { + SType = StructureType.PhysicalDeviceVulkan13Features, + }; + var vulkan12 = new PhysicalDeviceVulkan12Features + { + SType = StructureType.PhysicalDeviceVulkan12Features, + PNext = &vulkan13, + }; + var vulkan11 = new PhysicalDeviceVulkan11Features + { + SType = StructureType.PhysicalDeviceVulkan11Features, + PNext = &vulkan12, + }; + var features2 = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan11, + }; + vk.GetPhysicalDeviceFeatures2(device, &features2); + + PhysicalDeviceFeatures core = features2.Features; + return new VulkanDeviceFeatureSupport + { + MultiDrawIndirect = core.MultiDrawIndirect, + DrawIndirectFirstInstance = core.DrawIndirectFirstInstance, + ShaderClipDistance = core.ShaderClipDistance, + TextureCompressionBc = core.TextureCompressionBC, + SamplerAnisotropy = core.SamplerAnisotropy, + ShaderDrawParameters = vulkan11.ShaderDrawParameters, + TimelineSemaphore = vulkan12.TimelineSemaphore, + HostQueryReset = vulkan12.HostQueryReset, + RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray, + DescriptorBindingPartiallyBound = vulkan12.DescriptorBindingPartiallyBound, + DescriptorBindingSampledImageUpdateAfterBind = + vulkan12.DescriptorBindingSampledImageUpdateAfterBind, + DescriptorBindingUpdateUnusedWhilePending = + vulkan12.DescriptorBindingUpdateUnusedWhilePending, + DescriptorBindingVariableDescriptorCount = + vulkan12.DescriptorBindingVariableDescriptorCount, + ShaderSampledImageArrayNonUniformIndexing = + vulkan12.ShaderSampledImageArrayNonUniformIndexing, + DynamicRendering = vulkan13.DynamicRendering, + Synchronization2 = vulkan13.Synchronization2, + Maintenance4 = vulkan13.Maintenance4, + }; + } + + /// + /// Read the limits the plan asserts up front. The update-after-bind sampled + /// image counts come from the descriptor-indexing properties chain, not from + /// PhysicalDeviceLimits — reading the ordinary + /// maxDescriptorSetSampledImages instead is a real and easy mistake, + /// because it is usually large enough to look like a pass. + /// + internal static VulkanDeviceLimitSupport ReadLimits( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device) + { + ArgumentNullException.ThrowIfNull(vk); + + var indexing = new PhysicalDeviceDescriptorIndexingProperties + { + SType = StructureType.PhysicalDeviceDescriptorIndexingProperties, + }; + var properties2 = new PhysicalDeviceProperties2 + { + SType = StructureType.PhysicalDeviceProperties2, + PNext = &indexing, + }; + vk.GetPhysicalDeviceProperties2(device, &properties2); + + PhysicalDeviceLimits limits = properties2.Properties.Limits; + return new VulkanDeviceLimitSupport + { + MaxPushConstantsSize = limits.MaxPushConstantsSize, + MaxClipDistances = limits.MaxClipDistances, + MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets, + MaxDescriptorSetUpdateAfterBindSampledImages = + indexing.MaxDescriptorSetUpdateAfterBindSampledImages, + MaxPerStageDescriptorUpdateAfterBindSampledImages = + indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages, + TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics, + MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment, + MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment, + MaxImageDimension2D = limits.MaxImageDimension2D, + MaxColorSampleCount = HighestSampleCount( + limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts), + }; + } + + /// Highest set bit of a sample-count mask, as a plain count. + internal static uint HighestSampleCount(SampleCountFlags counts) + { + if (counts.HasFlag(SampleCountFlags.Count8Bit)) return 8; + if (counts.HasFlag(SampleCountFlags.Count4Bit)) return 4; + if (counts.HasFlag(SampleCountFlags.Count2Bit)) return 2; + return 1; + } + + /// + /// Probe the formats features alone do not prove. Depth prefers + /// D32_SFLOAT_S8_UINT and falls back to D24_UNORM_S8_UINT; the + /// stencil aspect is required by #117's portal punch (plan §4.5). + /// + internal static VulkanFormatSupport ReadFormats( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device, + bool surfaceOffersUnorm) + { + ArgumentNullException.ThrowIfNull(vk); + + return new VulkanFormatSupport + { + SwapchainUnormFormat = surfaceOffersUnorm, + DepthStencilFormat = ChooseDepthStencilFormat(vk, device), + Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock), + Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock), + Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock), + }; + } + + internal static Format ChooseDepthStencilFormat( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device) + { + foreach (Format candidate in new[] { Format.D32SfloatS8Uint, Format.D24UnormS8Uint }) + { + FormatProperties properties; + vk.GetPhysicalDeviceFormatProperties(device, candidate, &properties); + if (properties.OptimalTilingFeatures.HasFlag( + FormatFeatureFlags.DepthStencilAttachmentBit)) + { + return candidate; + } + } + + return Format.Undefined; + } + + internal static bool SupportsOptimalSampling( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device, + Format format) + { + FormatProperties properties; + vk.GetPhysicalDeviceFormatProperties(device, format, &properties); + return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit); + } + + /// + /// Enumerate queue families, reporting present support only when a surface + /// is supplied. The headless probe passes null and takes the + /// graphics-only path. + /// + internal static IReadOnlyList ReadQueueFamilies( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice device, + Silk.NET.Vulkan.Extensions.KHR.KhrSurface? surfaceApi, + SurfaceKHR surface) + { + ArgumentNullException.ThrowIfNull(vk); + + uint count = 0; + vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, null); + if (count == 0) + return []; + + var properties = new QueueFamilyProperties[count]; + fixed (QueueFamilyProperties* first = properties) + vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, first); + + var families = new List((int)count); + for (uint i = 0; i < count; i++) + { + bool graphics = properties[i].QueueFlags.HasFlag(QueueFlags.GraphicsBit); + bool present = false; + if (surfaceApi is not null) + { + VulkanInterop.Check( + surfaceApi.GetPhysicalDeviceSurfaceSupport( + device, + i, + surface, + out Bool32 supported), + "vkGetPhysicalDeviceSurfaceSupportKHR"); + present = supported; + } + + families.Add(new VulkanQueueFamilyCandidate(i, graphics, present)); + } + + return families; + } + + /// A short, stable driver identification string for bug reports. + internal static string DescribeDriver(VulkanPhysicalDeviceCandidate device) + { + ArgumentNullException.ThrowIfNull(device); + return + $"vendor 0x{device.VendorId:X4}, device 0x{device.DeviceId:X4}, " + + $"driver {VulkanApiVersion.Major(device.DriverVersion)}." + + $"{VulkanApiVersion.Minor(device.DriverVersion)}." + + $"{VulkanApiVersion.Patch(device.DriverVersion)} " + + $"(raw 0x{device.DriverVersion:X8})"; + } +} + +/// +/// Slice V5 interop: create the logical device with the exact §4.1 feature +/// chain enabled. +/// +/// Enabling precisely the features the capability gate verified — no more — +/// keeps the two in lockstep. A device created with a feature the gate never +/// checked is how a V6 renderer ends up depending on something a second GPU does +/// not have. +/// +internal sealed unsafe class VulkanLogicalDeviceFactory +{ + internal sealed record Created( + Device Device, + Queue GraphicsQueue, + Queue PresentQueue, + VulkanQueueFamilyChoice Families, + IReadOnlyList EnabledExtensions, + IReadOnlyList UnavailableOptionalExtensions); + + internal static Created Create( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + VulkanQueueFamilyChoice families, + bool requireSwapchain) + { + ArgumentNullException.ThrowIfNull(vk); + ArgumentNullException.ThrowIfNull(families); + + IReadOnlyList available = + VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice); + VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve( + available, + requireSwapchain ? [VulkanExtensionSelection.SwapchainExtension] : [], + VulkanExtensionSelection.OptionalDeviceExtensions); + if (!plan.IsSatisfied) + { + throw new NotSupportedException( + "The selected Vulkan device does not advertise the required " + + $"extension(s): {string.Join(", ", plan.MissingRequired)}."); + } + + var priority = 1f; + uint[] uniqueFamilies = families.IsUnified + ? [families.GraphicsFamily] + : [families.GraphicsFamily, families.PresentFamily]; + var queueCreates = new DeviceQueueCreateInfo[uniqueFamilies.Length]; + for (int i = 0; i < uniqueFamilies.Length; i++) + { + queueCreates[i] = new DeviceQueueCreateInfo + { + SType = StructureType.DeviceQueueCreateInfo, + QueueFamilyIndex = uniqueFamilies[i], + QueueCount = 1, + PQueuePriorities = &priority, + }; + } + + var vulkan13 = new PhysicalDeviceVulkan13Features + { + SType = StructureType.PhysicalDeviceVulkan13Features, + DynamicRendering = true, + Synchronization2 = true, + Maintenance4 = true, + }; + var vulkan12 = new PhysicalDeviceVulkan12Features + { + SType = StructureType.PhysicalDeviceVulkan12Features, + PNext = &vulkan13, + DescriptorIndexing = true, + RuntimeDescriptorArray = true, + DescriptorBindingPartiallyBound = true, + DescriptorBindingSampledImageUpdateAfterBind = true, + DescriptorBindingUpdateUnusedWhilePending = true, + DescriptorBindingVariableDescriptorCount = true, + ShaderSampledImageArrayNonUniformIndexing = true, + TimelineSemaphore = true, + HostQueryReset = true, + }; + var vulkan11 = new PhysicalDeviceVulkan11Features + { + SType = StructureType.PhysicalDeviceVulkan11Features, + PNext = &vulkan12, + ShaderDrawParameters = true, + }; + var core = new PhysicalDeviceFeatures + { + MultiDrawIndirect = true, + DrawIndirectFirstInstance = true, + ShaderClipDistance = true, + TextureCompressionBC = true, + SamplerAnisotropy = true, + }; + var features2 = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan11, + Features = core, + }; + + nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled); + try + { + fixed (DeviceQueueCreateInfo* queues = queueCreates) + { + var create = new DeviceCreateInfo + { + SType = StructureType.DeviceCreateInfo, + PNext = &features2, + QueueCreateInfoCount = (uint)queueCreates.Length, + PQueueCreateInfos = queues, + EnabledExtensionCount = (uint)plan.Enabled.Count, + PpEnabledExtensionNames = (byte**)extensionNames, + // PEnabledFeatures must stay null when the feature chain is + // supplied through pNext; passing both is invalid usage. + PEnabledFeatures = null, + }; + + VulkanInterop.Check( + vk.CreateDevice(physicalDevice, &create, null, out Device device), + "vkCreateDevice"); + + vk.GetDeviceQueue(device, families.GraphicsFamily, 0, out Queue graphics); + Queue present = graphics; + if (!families.IsUnified) + vk.GetDeviceQueue(device, families.PresentFamily, 0, out present); + + return new Created( + device, + graphics, + present, + families, + plan.Enabled, + plan.UnavailableOptional); + } + } + finally + { + VulkanInterop.FreeStringArray(extensionNames); + } + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs new file mode 100644 index 00000000..0da7b411 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPhysicalDeviceSelection.cs @@ -0,0 +1,232 @@ +using System.Globalization; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// The chosen device plus the sentence explaining why, which the report records verbatim. +internal sealed record VulkanPhysicalDeviceChoice( + VulkanPhysicalDeviceCandidate Device, + string Reason); + +/// +/// Campaign V slice V5, plan §4.11: "discrete > integrated > virtual > +/// CPU, tie-broken by largest device-local heap, with an +/// ACDREAM_VULKAN_DEVICE override recorded in the report." +/// +/// Pure ranking over values so the +/// policy is unit-tested without enumerating a real instance — which matters +/// because this machine has exactly one GPU and the ordering it exercises is +/// therefore never the interesting case. +/// +internal static class VulkanPhysicalDeviceSelection +{ + /// + /// Preference rank; lower is better. Vulkan's + /// numbering is Other(0) < Integrated(1) < Discrete(2) < Virtual(3) + /// < Cpu(4), which is neither our order nor a monotone one, so it is + /// mapped explicitly rather than compared numerically. + /// + internal static int PreferenceRank(PhysicalDeviceType type) => type switch + { + PhysicalDeviceType.DiscreteGpu => 0, + PhysicalDeviceType.IntegratedGpu => 1, + PhysicalDeviceType.VirtualGpu => 2, + PhysicalDeviceType.Cpu => 3, + _ => 4, + }; + + /// + /// Choose a device. + /// + /// is an enumeration index when it is + /// entirely decimal digits, and a case-insensitive device-name substring + /// otherwise. The split is exact rather than "try index, then fall back to + /// substring" because a bare digit is a substring of most real device names — + /// 7 occurs in "AMD Radeon RX 9070 XT" — so a fall-through would make + /// an out-of-range index quietly select a device by coincidence. A name that + /// genuinely contains digits ("RX 7900 XTX") still matches, because it is not + /// digits alone. + /// + /// An override that matches nothing falls back to the automatic choice + /// and says so in the reason: refusing to start over a stale environment + /// variable is worse than starting on the right GPU anyway. + /// + internal static VulkanPhysicalDeviceChoice? Choose( + IReadOnlyList candidates, + string? deviceOverride) + { + ArgumentNullException.ThrowIfNull(candidates); + if (candidates.Count == 0) + return null; + + if (!string.IsNullOrWhiteSpace(deviceOverride)) + { + string trimmed = deviceOverride.Trim(); + if (IsDecimalIndex(trimmed)) + { + int index = int.Parse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture); + VulkanPhysicalDeviceCandidate? byIndex = + candidates.FirstOrDefault(candidate => candidate.Index == index); + if (byIndex is not null) + { + return new VulkanPhysicalDeviceChoice( + byIndex, + $"ACDREAM_VULKAN_DEVICE={trimmed} selected device index {index}."); + } + } + else + { + VulkanPhysicalDeviceCandidate? byName = candidates.FirstOrDefault( + candidate => candidate.DeviceName.Contains( + trimmed, + StringComparison.OrdinalIgnoreCase)); + if (byName is not null) + { + return new VulkanPhysicalDeviceChoice( + byName, + $"ACDREAM_VULKAN_DEVICE={trimmed} matched device name '{byName.DeviceName}'."); + } + } + + VulkanPhysicalDeviceCandidate automatic = Rank(candidates); + return new VulkanPhysicalDeviceChoice( + automatic, + $"ACDREAM_VULKAN_DEVICE={trimmed} matched no enumerated device; " + + $"fell back to the automatic choice '{automatic.DeviceName}' " + + $"({automatic.DeviceType}, {Gib(automatic.DeviceLocalHeapBytes)} device-local)."); + } + + VulkanPhysicalDeviceCandidate chosen = Rank(candidates); + return new VulkanPhysicalDeviceChoice( + chosen, + $"automatic: '{chosen.DeviceName}' ({chosen.DeviceType}, " + + $"{Gib(chosen.DeviceLocalHeapBytes)} device-local) ranked first of " + + $"{candidates.Count} enumerated device(s)."); + } + + /// + /// Deterministic ordering: type preference, then largest device-local heap, + /// then enumeration index. The final index tie-break exists so two identical + /// GPUs always produce the same choice across launches. + /// + internal static VulkanPhysicalDeviceCandidate Rank( + IReadOnlyList candidates) + { + ArgumentNullException.ThrowIfNull(candidates); + if (candidates.Count == 0) + throw new ArgumentException("At least one candidate is required.", nameof(candidates)); + + VulkanPhysicalDeviceCandidate best = candidates[0]; + for (int i = 1; i < candidates.Count; i++) + { + if (Compare(candidates[i], best) < 0) + best = candidates[i]; + } + + return best; + } + + /// Negative when is the better device. + internal static int Compare( + VulkanPhysicalDeviceCandidate left, + VulkanPhysicalDeviceCandidate right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + int byType = PreferenceRank(left.DeviceType).CompareTo(PreferenceRank(right.DeviceType)); + if (byType != 0) + return byType; + + int byHeap = right.DeviceLocalHeapBytes.CompareTo(left.DeviceLocalHeapBytes); + return byHeap != 0 ? byHeap : left.Index.CompareTo(right.Index); + } + + /// An index override is decimal digits and nothing else. + internal static bool IsDecimalIndex(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + foreach (char character in value) + { + if (character is < '0' or > '9') + return false; + } + + return true; + } + + private static string Gib(ulong bytes) + => (bytes / (1024d * 1024d * 1024d)).ToString("0.##", CultureInfo.InvariantCulture) + " GiB"; +} + +/// +/// Which queue family carries graphics and which carries present. Slice V5 uses +/// one graphics+present queue with transfers riding it (plan §4.8); a device +/// whose present-capable family is separate is still supported, and the +/// swapchain then declares concurrent sharing. +/// +internal sealed record VulkanQueueFamilyChoice( + uint GraphicsFamily, + uint PresentFamily) +{ + /// True when one queue serves both, which is the case on every GPU we target. + internal bool IsUnified => GraphicsFamily == PresentFamily; +} + +/// One enumerated queue family, reduced to the two facts the selector needs. +internal readonly record struct VulkanQueueFamilyCandidate( + uint Index, + bool SupportsGraphics, + bool SupportsPresent); + +/// +/// Pure queue-family selection. Prefers a single family that does both, because +/// that removes the concurrent-sharing declaration and the ownership transfers +/// that would otherwise be needed on every swapchain image. +/// +internal static class VulkanQueueFamilySelection +{ + internal static VulkanQueueFamilyChoice? Choose( + IReadOnlyList families) + { + ArgumentNullException.ThrowIfNull(families); + + foreach (VulkanQueueFamilyCandidate family in families) + { + if (family.SupportsGraphics && family.SupportsPresent) + return new VulkanQueueFamilyChoice(family.Index, family.Index); + } + + uint? graphics = null; + uint? present = null; + foreach (VulkanQueueFamilyCandidate family in families) + { + if (graphics is null && family.SupportsGraphics) + graphics = family.Index; + if (present is null && family.SupportsPresent) + present = family.Index; + } + + return graphics is { } g && present is { } p + ? new VulkanQueueFamilyChoice(g, p) + : null; + } + + /// + /// The headless variant used by the offscreen capability probe, which has no + /// surface and therefore no present requirement. + /// + internal static uint? ChooseGraphicsOnly( + IReadOnlyList families) + { + ArgumentNullException.ThrowIfNull(families); + foreach (VulkanQueueFamilyCandidate family in families) + { + if (family.SupportsGraphics) + return family.Index; + } + + return null; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs new file mode 100644 index 00000000..af3d0238 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs @@ -0,0 +1,565 @@ +using AcDream.App.Rendering; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5, plan §4.9: the presentation swapchain. +/// +/// Every decision this class makes — colour format, present mode, image +/// count, extent, usage, transform, composite alpha, and what to do about +/// OUT_OF_DATE / SUBOPTIMAL / a minimised window — is delegated to +/// and +/// , which are pure and unit-tested. +/// What is left here is the calls themselves. +/// +/// Untested by the implementing slice. Nothing below can run +/// without a window and a driver, so it carries no automated coverage at V5. The +/// manual "Vulkan boots to a clear colour" check is the gate. +/// +internal sealed unsafe class VulkanSwapchain : IDisposable +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly KhrSurface _surfaceApi; + private readonly KhrSwapchain _swapchainApi; + private readonly PhysicalDevice _physicalDevice; + private readonly Device _device; + private readonly SurfaceKHR _surface; + private readonly VulkanQueueFamilyChoice _families; + + private SwapchainKHR _swapchain; + private Image[] _images = []; + private ImageView[] _views = []; + private Semaphore[] _renderComplete = []; + private bool _disposed; + + internal VulkanSwapchain( + Silk.NET.Vulkan.Vk vk, + KhrSurface surfaceApi, + KhrSwapchain swapchainApi, + PhysicalDevice physicalDevice, + Device device, + SurfaceKHR surface, + VulkanQueueFamilyChoice families) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _surfaceApi = surfaceApi ?? throw new ArgumentNullException(nameof(surfaceApi)); + _swapchainApi = swapchainApi ?? throw new ArgumentNullException(nameof(swapchainApi)); + _physicalDevice = physicalDevice; + _device = device; + _surface = surface; + _families = families ?? throw new ArgumentNullException(nameof(families)); + } + + /// The configuration the live swapchain was created from, or null before the first create. + internal VulkanSwapchainConfiguration? Configuration { get; private set; } + + /// True when a swapchain object currently exists. + internal bool IsCreated => _swapchain.Handle != 0; + + internal int ImageCount => _images.Length; + + internal ImageView ViewAt(uint index) => _views[index]; + + internal Image ImageAt(uint index) => _images[index]; + + internal Semaphore RenderCompleteAt(uint index) => _renderComplete[index]; + + /// Read the surface's current capabilities, formats and present modes. + internal (SurfaceCapabilitiesKHR Capabilities, + IReadOnlyList Formats, + IReadOnlyList PresentModes) QuerySurface() + { + VulkanInterop.Check( + _surfaceApi.GetPhysicalDeviceSurfaceCapabilities( + _physicalDevice, + _surface, + out SurfaceCapabilitiesKHR capabilities), + "vkGetPhysicalDeviceSurfaceCapabilitiesKHR"); + + uint formatCount = 0; + VulkanInterop.Check( + _surfaceApi.GetPhysicalDeviceSurfaceFormats( + _physicalDevice, + _surface, + ref formatCount, + null), + "vkGetPhysicalDeviceSurfaceFormatsKHR (count)"); + var formats = new SurfaceFormatKHR[formatCount]; + if (formatCount != 0) + { + fixed (SurfaceFormatKHR* first = formats) + { + VulkanInterop.Check( + _surfaceApi.GetPhysicalDeviceSurfaceFormats( + _physicalDevice, + _surface, + ref formatCount, + first), + "vkGetPhysicalDeviceSurfaceFormatsKHR"); + } + } + + uint modeCount = 0; + VulkanInterop.Check( + _surfaceApi.GetPhysicalDeviceSurfacePresentModes( + _physicalDevice, + _surface, + ref modeCount, + null), + "vkGetPhysicalDeviceSurfacePresentModesKHR (count)"); + var modes = new PresentModeKHR[modeCount]; + if (modeCount != 0) + { + fixed (PresentModeKHR* first = modes) + { + VulkanInterop.Check( + _surfaceApi.GetPhysicalDeviceSurfacePresentModes( + _physicalDevice, + _surface, + ref modeCount, + first), + "vkGetPhysicalDeviceSurfacePresentModesKHR"); + } + } + + return (capabilities, formats, modes); + } + + /// + /// Create or rebuild the swapchain for the current framebuffer size. Returns + /// false when the surface reports zero area — a minimised window — in which + /// case the caller idles rather than spinning on a failing create. + /// + internal bool Recreate(FramePacingPolicy pacing, uint framebufferWidth, uint framebufferHeight) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + (SurfaceCapabilitiesKHR capabilities, + IReadOnlyList formats, + IReadOnlyList modes) = QuerySurface(); + + VulkanSwapchainConfiguration configuration = + VulkanSwapchainConfigurationFactory.Create( + capabilities, + formats, + modes, + pacing, + framebufferWidth, + framebufferHeight); + if (!configuration.IsPresentable) + return false; + + SwapchainKHR old = _swapchain; + uint* families = stackalloc uint[2] + { + _families.GraphicsFamily, + _families.PresentFamily, + }; + var create = new SwapchainCreateInfoKHR + { + SType = StructureType.SwapchainCreateInfoKhr, + Surface = _surface, + MinImageCount = configuration.ImageCount, + ImageFormat = configuration.ImageFormat, + ImageColorSpace = configuration.ColorSpace, + ImageExtent = new Extent2D(configuration.Width, configuration.Height), + ImageArrayLayers = 1, + ImageUsage = configuration.Usage, + // One queue does both on every GPU we target; the concurrent path + // exists so a split-family device still works without per-image + // ownership transfers. + ImageSharingMode = _families.IsUnified ? SharingMode.Exclusive : SharingMode.Concurrent, + QueueFamilyIndexCount = _families.IsUnified ? 0u : 2u, + PQueueFamilyIndices = _families.IsUnified ? null : families, + PreTransform = configuration.PreTransform, + CompositeAlpha = configuration.CompositeAlpha, + PresentMode = configuration.PresentMode, + Clipped = true, + OldSwapchain = old, + }; + + VulkanInterop.Check( + _swapchainApi.CreateSwapchain(_device, &create, null, out SwapchainKHR created), + "vkCreateSwapchainKHR"); + + DestroyImageResources(); + if (old.Handle != 0) + _swapchainApi.DestroySwapchain(_device, old, null); + + _swapchain = created; + Configuration = configuration; + AcquireImages(configuration); + return true; + } + + private void AcquireImages(VulkanSwapchainConfiguration configuration) + { + uint count = 0; + VulkanInterop.Check( + _swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, null), + "vkGetSwapchainImagesKHR (count)"); + _images = new Image[count]; + fixed (Image* first = _images) + { + VulkanInterop.Check( + _swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, first), + "vkGetSwapchainImagesKHR"); + } + + _views = new ImageView[count]; + _renderComplete = new Semaphore[count]; + for (uint i = 0; i < count; i++) + { + var viewCreate = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = _images[i], + ViewType = ImageViewType.Type2D, + Format = configuration.ImageFormat, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + VulkanInterop.Check( + _vk.CreateImageView(_device, &viewCreate, null, out ImageView view), + "vkCreateImageView (swapchain image)"); + _views[i] = view; + + // One render-complete semaphore per image, not per flight slot: a + // present waits on the semaphore the submit for THAT image signalled, + // and reusing a slot-indexed semaphore is the classic way to signal a + // semaphore that is still pending. + var semaphoreCreate = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + }; + VulkanInterop.Check( + _vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore semaphore), + "vkCreateSemaphore (render complete)"); + _renderComplete[i] = semaphore; + } + } + + /// Acquire the next image. The action tells the caller what to do about the result. + internal VulkanSwapchainAction TryAcquire( + Semaphore acquired, + ulong timeoutNanoseconds, + out uint imageIndex) + { + imageIndex = 0; + if (!IsCreated) + return VulkanSwapchainAction.RecreateNow; + + Result result = _swapchainApi.AcquireNextImage( + _device, + _swapchain, + timeoutNanoseconds, + acquired, + default, + ref imageIndex); + return VulkanSwapchainRecreationPolicy.OnAcquire(result); + } + + /// Present the acquired image, waiting on that image's render-complete semaphore. + internal VulkanSwapchainAction Present(Queue presentQueue, uint imageIndex) + { + SwapchainKHR swapchain = _swapchain; + Semaphore wait = _renderComplete[imageIndex]; + uint index = imageIndex; + var present = new PresentInfoKHR + { + SType = StructureType.PresentInfoKhr, + WaitSemaphoreCount = 1, + PWaitSemaphores = &wait, + SwapchainCount = 1, + PSwapchains = &swapchain, + PImageIndices = &index, + }; + Result result = _swapchainApi.QueuePresent(presentQueue, &present); + return VulkanSwapchainRecreationPolicy.OnPresent(result); + } + + /// + /// Copy a presented image back to the CPU as tightly-packed RGBA, matching + /// FrameScreenshotController's Func<int, int, byte[]> + /// contract. Diagnostic-only, so it takes the simple synchronous route: idle + /// the device, run one throwaway command buffer, swizzle, done. + /// + internal byte[] CaptureImage( + Queue graphicsQueue, + uint graphicsFamily, + uint imageIndex) + { + if (Configuration is not { } configuration) + throw new InvalidOperationException("The swapchain has not been created."); + + uint width = configuration.Width; + uint height = configuration.Height; + uint byteCount = width * height * 4; + + VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (screenshot)"); + + Silk.NET.Vulkan.Buffer buffer = default; + DeviceMemory memory = default; + CommandPool pool = default; + try + { + var bufferCreate = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = byteCount, + Usage = BufferUsageFlags.TransferDstBit, + SharingMode = SharingMode.Exclusive, + }; + VulkanInterop.Check( + _vk.CreateBuffer(_device, &bufferCreate, null, out buffer), + "vkCreateBuffer (screenshot)"); + _vk.GetBufferMemoryRequirements(_device, buffer, out MemoryRequirements requirements); + uint? typeIndex = VulkanActiveDeviceProbe.FindMemoryType( + _vk, + _physicalDevice, + requirements.MemoryTypeBits, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + if (typeIndex is not { } index) + { + throw new NotSupportedException( + "No host-visible Vulkan memory type is available for screenshot readback."); + } + + var allocate = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = index, + }; + VulkanInterop.Check( + _vk.AllocateMemory(_device, &allocate, null, out memory), + "vkAllocateMemory (screenshot)"); + VulkanInterop.Check( + _vk.BindBufferMemory(_device, buffer, memory, 0), + "vkBindBufferMemory (screenshot)"); + + var poolCreate = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = graphicsFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + VulkanInterop.Check( + _vk.CreateCommandPool(_device, &poolCreate, null, out pool), + "vkCreateCommandPool (screenshot)"); + var allocateCommands = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = pool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + VulkanInterop.Check( + _vk.AllocateCommandBuffers(_device, &allocateCommands, out CommandBuffer commands), + "vkAllocateCommandBuffers (screenshot)"); + + RecordCapture(commands, _images[imageIndex], buffer, width, height); + + var commandSubmit = new CommandBufferSubmitInfo + { + SType = StructureType.CommandBufferSubmitInfo, + CommandBuffer = commands, + }; + var submit = new SubmitInfo2 + { + SType = StructureType.SubmitInfo2, + CommandBufferInfoCount = 1, + PCommandBufferInfos = &commandSubmit, + }; + VulkanInterop.Check( + _vk.QueueSubmit2(graphicsQueue, 1, &submit, default), + "vkQueueSubmit2 (screenshot)"); + VulkanInterop.Check( + _vk.QueueWaitIdle(graphicsQueue), + "vkQueueWaitIdle (screenshot)"); + + void* mapped = null; + VulkanInterop.Check( + _vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped), + "vkMapMemory (screenshot)"); + try + { + // vkCmdCopyImageToBuffer with bufferRowLength = 0 packs rows + // tightly, so the source pitch is exactly width * 4. The result + // is handed back in GL's bottom-left-origin convention because + // FrameScreenshotController flips what it receives. + return VulkanBackbufferSwizzle.ToGlOriginRgba( + new ReadOnlySpan(mapped, (int)byteCount), + (int)width, + (int)height, + (int)width * 4); + } + finally + { + _vk.UnmapMemory(_device, memory); + } + } + finally + { + if (pool.Handle != 0) + _vk.DestroyCommandPool(_device, pool, null); + if (buffer.Handle != 0) + _vk.DestroyBuffer(_device, buffer, null); + if (memory.Handle != 0) + _vk.FreeMemory(_device, memory, null); + } + } + + private void RecordCapture( + CommandBuffer commands, + Image image, + Silk.NET.Vulkan.Buffer destination, + uint width, + uint height) + { + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanInterop.Check( + _vk.BeginCommandBuffer(commands, &begin), + "vkBeginCommandBuffer (screenshot)"); + + var subresource = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + TransitionImage( + commands, + image, + subresource, + ImageLayout.PresentSrcKhr, + ImageLayout.TransferSrcOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + PipelineStageFlags2.CopyBit, + AccessFlags2.TransferReadBit); + + var region = new BufferImageCopy + { + BufferOffset = 0, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }, + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + _vk.CmdCopyImageToBuffer( + commands, + image, + ImageLayout.TransferSrcOptimal, + destination, + 1, + ®ion); + + TransitionImage( + commands, + image, + subresource, + ImageLayout.TransferSrcOptimal, + ImageLayout.PresentSrcKhr, + PipelineStageFlags2.CopyBit, + AccessFlags2.TransferReadBit, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None); + + VulkanInterop.Check( + _vk.EndCommandBuffer(commands), + "vkEndCommandBuffer (screenshot)"); + } + + /// One batched vkCmdPipelineBarrier2 image transition. + internal void TransitionImage( + CommandBuffer commands, + Image image, + ImageSubresourceRange subresource, + ImageLayout oldLayout, + ImageLayout newLayout, + PipelineStageFlags2 sourceStage, + AccessFlags2 sourceAccess, + PipelineStageFlags2 destinationStage, + AccessFlags2 destinationAccess) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = sourceStage, + SrcAccessMask = sourceAccess, + DstStageMask = destinationStage, + DstAccessMask = destinationAccess, + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = subresource, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + private void DestroyImageResources() + { + foreach (Semaphore semaphore in _renderComplete) + { + if (semaphore.Handle != 0) + _vk.DestroySemaphore(_device, semaphore, null); + } + + foreach (ImageView view in _views) + { + if (view.Handle != 0) + _vk.DestroyImageView(_device, view, null); + } + + _renderComplete = []; + _views = []; + _images = []; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + DestroyImageResources(); + if (_swapchain.Handle != 0) + { + _swapchainApi.DestroySwapchain(_device, _swapchain, null); + _swapchain = default; + } + + Configuration = null; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs new file mode 100644 index 00000000..59cff702 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs @@ -0,0 +1,389 @@ +using AcDream.App.Rendering; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Everything vkCreateSwapchainKHR needs, decided by pure code from the +/// surface's advertised capabilities plus the app's pacing policy. Keeping the +/// decision separate from the call is what lets slice V5 unit-test format, +/// present-mode, image-count and extent selection without a window. +/// +internal sealed record VulkanSwapchainConfiguration( + Format ImageFormat, + ColorSpaceKHR ColorSpace, + PresentModeKHR PresentMode, + uint ImageCount, + uint Width, + uint Height, + ImageUsageFlags Usage, + SurfaceTransformFlagsKHR PreTransform, + CompositeAlphaFlagsKHR CompositeAlpha) +{ + /// A zero-area swapchain is not creatable; the frame loop idles instead. + internal bool IsPresentable => Width > 0 && Height > 0; +} + +/// +/// Campaign V slice V5, plan §4.9. Three decisions live here and each has a +/// reason that is easy to get silently wrong: +/// +/// +/// Format is B8G8R8A8_UNORM, never _SRGB. +/// The V3 audit established that acdream never enables sRGB encoding anywhere — +/// not on upload, not in a shader, not at the framebuffer. An _SRGB +/// swapchain would encode already-display-space values and brighten every frame +/// globally, and it would pass every automated gate until V7. +/// Present mode follows . +/// FIFO when VSync is on; IMMEDIATE preferred then MAILBOX when it is off, with +/// the software pacer continuing to drive the cap. FIFO is the only mode Vulkan +/// guarantees, so it is always the final fallback. +/// Usage includes TRANSFER_SRC. Screenshots copy +/// the presented image; without the usage bit the copy is invalid. +/// +/// +internal static class VulkanSwapchainConfigurationFactory +{ + /// The swapchain colour format. Corrected at slice V3 — see the class remarks. + internal const Format PreferredFormat = Format.B8G8R8A8Unorm; + + /// Paired with ; the only colour space core Vulkan guarantees. + internal const ColorSpaceKHR PreferredColorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr; + + /// + /// Two frames in flight (plan §4.8), so three images is the working target: + /// one presenting, one queued, one being recorded. + /// + internal const uint PreferredImageCount = 3; + + /// Colour attachment for rendering; transfer source for screenshot capture. + internal const ImageUsageFlags RequiredUsage = + ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit; + + /// + /// Pick the surface format. Falls back to whatever the surface offers first + /// only when the preferred pair is genuinely absent; the capability gate has + /// already refused to start in that case, so the fallback exists purely so + /// this function is total. + /// + internal static SurfaceFormatKHR ChooseSurfaceFormat( + IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(available); + if (available.Count == 0) + return new SurfaceFormatKHR(PreferredFormat, PreferredColorSpace); + + foreach (SurfaceFormatKHR format in available) + { + if (format.Format == PreferredFormat && format.ColorSpace == PreferredColorSpace) + return format; + } + + foreach (SurfaceFormatKHR format in available) + { + if (format.Format == PreferredFormat) + return format; + } + + return available[0]; + } + + /// True when the surface offers the UNORM format the renderer requires. + internal static bool OffersUnormFormat(IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(available); + foreach (SurfaceFormatKHR format in available) + { + if (format.Format == PreferredFormat) + return true; + } + + return false; + } + + /// + /// Map the resolved pacing policy onto a present mode. + /// + /// VSync on means FIFO — the driver blocks and there is no tearing. VSync off + /// prefers IMMEDIATE over MAILBOX because + /// and its platform waiters already own the software ceiling; MAILBOX would + /// add a hidden queue between our pacing decision and the display, which is + /// the opposite of what issue #235 needs. MAILBOX is still preferred over + /// falling back to FIFO, because FIFO would re-impose the very block the user + /// turned off. + /// + internal static PresentModeKHR ChoosePresentMode( + FramePacingPolicy pacing, + IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(available); + + if (pacing.UseVSync) + return PresentModeKHR.FifoKhr; + + if (available.Contains(PresentModeKHR.ImmediateKhr)) + return PresentModeKHR.ImmediateKhr; + if (available.Contains(PresentModeKHR.MailboxKhr)) + return PresentModeKHR.MailboxKhr; + + // FIFO is the only mode Vulkan guarantees is present. + return PresentModeKHR.FifoKhr; + } + + /// + /// Clamp the requested image count into the surface's window. + /// MaxImageCount == 0 means "no upper limit" in Vulkan, which is the + /// easiest clamp in the API to get backwards. + /// + internal static uint ChooseImageCount(in SurfaceCapabilitiesKHR capabilities) + { + uint count = Math.Max(PreferredImageCount, capabilities.MinImageCount); + if (capabilities.MaxImageCount != 0 && count > capabilities.MaxImageCount) + count = capabilities.MaxImageCount; + return count; + } + + /// + /// Resolve the swapchain extent. + /// + /// CurrentExtent.Width == uint.MaxValue is the surface saying "you + /// choose", so the framebuffer size is clamped into the min/max window. + /// Otherwise the surface's own extent wins verbatim — including + /// 0 x 0, which is what a minimised window reports and which the + /// caller must treat as "do not create, do not present, idle". + /// + internal static (uint Width, uint Height) ChooseExtent( + in SurfaceCapabilitiesKHR capabilities, + uint framebufferWidth, + uint framebufferHeight) + { + if (capabilities.CurrentExtent.Width != uint.MaxValue) + return (capabilities.CurrentExtent.Width, capabilities.CurrentExtent.Height); + + uint width = Math.Clamp( + framebufferWidth, + capabilities.MinImageExtent.Width, + capabilities.MaxImageExtent.Width); + uint height = Math.Clamp( + framebufferHeight, + capabilities.MinImageExtent.Height, + capabilities.MaxImageExtent.Height); + return (width, height); + } + + /// + /// Prefer an identity pre-transform when the surface supports it; otherwise + /// pass its current transform straight through so the presentation engine + /// does not have to rotate. + /// + internal static SurfaceTransformFlagsKHR ChoosePreTransform( + in SurfaceCapabilitiesKHR capabilities) + => capabilities.SupportedTransforms.HasFlag(SurfaceTransformFlagsKHR.IdentityBitKhr) + ? SurfaceTransformFlagsKHR.IdentityBitKhr + : capabilities.CurrentTransform; + + /// + /// The window is opaque. OPAQUE is what every desktop surface supports; + /// INHERIT is the only sane fallback if a compositor ever refuses it. + /// + internal static CompositeAlphaFlagsKHR ChooseCompositeAlpha( + in SurfaceCapabilitiesKHR capabilities) + { + if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.OpaqueBitKhr)) + return CompositeAlphaFlagsKHR.OpaqueBitKhr; + if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.InheritBitKhr)) + return CompositeAlphaFlagsKHR.InheritBitKhr; + return CompositeAlphaFlagsKHR.OpaqueBitKhr; + } + + /// True when the surface permits the TRANSFER_SRC usage screenshots need. + internal static bool SupportsTransferSource(in SurfaceCapabilitiesKHR capabilities) + => capabilities.SupportedUsageFlags.HasFlag(ImageUsageFlags.TransferSrcBit); + + /// Assemble the complete configuration from surface capabilities and app policy. + internal static VulkanSwapchainConfiguration Create( + in SurfaceCapabilitiesKHR capabilities, + IReadOnlyList formats, + IReadOnlyList presentModes, + FramePacingPolicy pacing, + uint framebufferWidth, + uint framebufferHeight) + { + SurfaceFormatKHR format = ChooseSurfaceFormat(formats); + (uint width, uint height) = ChooseExtent( + capabilities, + framebufferWidth, + framebufferHeight); + + // TRANSFER_SRC is dropped rather than forced when a surface refuses it: + // the capability gate has already turned that into a startup failure, so + // forcing it here would only turn a clear message into a driver error. + ImageUsageFlags usage = SupportsTransferSource(capabilities) + ? RequiredUsage + : ImageUsageFlags.ColorAttachmentBit; + + return new VulkanSwapchainConfiguration( + format.Format, + format.ColorSpace, + ChoosePresentMode(pacing, presentModes), + ChooseImageCount(capabilities), + width, + height, + usage, + ChoosePreTransform(capabilities), + ChooseCompositeAlpha(capabilities)); + } +} + +/// What the frame loop must do after an acquire or a present returned. +internal enum VulkanSwapchainAction +{ + /// The image is usable; carry on. + Continue, + + /// Rebuild the swapchain before doing anything else with it. + RecreateNow, + + /// Usable this frame, but rebuild at the frame boundary. + RecreateAtFrameBoundary, + + /// Nothing to present to — a minimised window. Idle without spinning. + Idle, + + /// An error the loop cannot recover from; surface it rather than looping on it. + Fail, +} + +/// +/// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the +/// next frame boundary, both through FramebufferResizeController." +/// +/// Pure, so the swapchain lifecycle that the risk register calls out (resize, +/// minimise, RDP) is covered by unit tests rather than only by a connected gate. +/// +internal static class VulkanSwapchainRecreationPolicy +{ + /// Classify the result of vkAcquireNextImageKHR. + internal static VulkanSwapchainAction OnAcquire(Result result) => result switch + { + Result.Success => VulkanSwapchainAction.Continue, + // A suboptimal acquire is still a usable image, so the frame renders and + // the rebuild happens at the boundary rather than mid-frame. + Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary, + Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow, + Result.Timeout or Result.NotReady => VulkanSwapchainAction.Idle, + _ => VulkanSwapchainAction.Fail, + }; + + /// Classify the result of vkQueuePresentKHR. + internal static VulkanSwapchainAction OnPresent(Result result) => result switch + { + Result.Success => VulkanSwapchainAction.Continue, + Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary, + Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow, + _ => VulkanSwapchainAction.Fail, + }; + + /// + /// A zero-area framebuffer is a minimised window. Creating a swapchain for it + /// is invalid, so the loop idles until the surface reports area again — this + /// is the check that must run before any acquire, not after a + /// failure. + /// + internal static VulkanSwapchainAction OnFramebufferSize(uint width, uint height) + => width == 0 || height == 0 + ? VulkanSwapchainAction.Idle + : VulkanSwapchainAction.Continue; +} + +/// +/// The swapchain is BGRA and FrameScreenshotController takes RGBA +/// (Func<int, int, byte[]>). Plan §4.9: "Screenshots swizzle +/// BGRA→RGBA on the CPU to preserve FrameScreenshotController's RGBA byte +/// contract." +/// +internal static class VulkanBackbufferSwizzle +{ + /// + /// Swap the red and blue bytes of every 4-byte pixel in place. Alpha and + /// green are untouched, so the transform is its own inverse. + /// + internal static void SwapRedAndBlueInPlace(Span pixels) + { + if (pixels.Length % 4 != 0) + { + throw new ArgumentException( + "A BGRA/RGBA pixel span must be a whole number of 4-byte pixels.", + nameof(pixels)); + } + + for (int i = 0; i + 3 < pixels.Length; i += 4) + (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]); + } + + /// + /// Copy a tightly-packed or row-padded BGRA image into a tightly-packed RGBA + /// buffer. is the copy's row pitch, + /// which Vulkan reports per image layout and which is frequently larger than + /// width * 4 — reading it as if it were tight is the classic way to + /// get a diagonally sheared screenshot. + /// + internal static byte[] ToRgba( + ReadOnlySpan source, + int width, + int height, + int sourceRowPitchBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4); + + var destination = new byte[width * height * 4]; + for (int y = 0; y < height; y++) + { + ReadOnlySpan sourceRow = + source.Slice(y * sourceRowPitchBytes, width * 4); + Span destinationRow = + destination.AsSpan(y * width * 4, width * 4); + sourceRow.CopyTo(destinationRow); + SwapRedAndBlueInPlace(destinationRow); + } + + return destination; + } + + /// + /// As , but also flips the image vertically so the result + /// is in OpenGL's bottom-left-origin convention. + /// + /// This is the same rule §3.3 states for winding and viewport origin — + /// "renderers always speak GL conventions ... the Vulkan backend inverts in + /// exactly one mapping function." A Vulkan image is top-left-origin, and + /// FrameScreenshotController flips what it is given because + /// glReadPixels hands back bottom-up rows. Converting here, once, + /// keeps that consumer and its PNG orientation untouched. + /// + internal static byte[] ToGlOriginRgba( + ReadOnlySpan source, + int width, + int height, + int sourceRowPitchBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4); + + var destination = new byte[width * height * 4]; + for (int y = 0; y < height; y++) + { + ReadOnlySpan sourceRow = + source.Slice(y * sourceRowPitchBytes, width * 4); + Span destinationRow = + destination.AsSpan((height - 1 - y) * width * 4, width * 4); + sourceRow.CopyTo(destinationRow); + SwapRedAndBlueInPlace(destinationRow); + } + + return destination; + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 8324c504..7b37ae2e 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -56,7 +56,10 @@ public sealed record RuntimeOptions( float FogStartMultiplier, float FogEndMultiplier, ResidencyBudgetOptions ResidencyBudgets, - StreamingWorkBudgetOptions StreamingWorkBudgets) + StreamingWorkBudgetOptions StreamingWorkBudgets, + RenderBackendKind RenderBackend, + string? VulkanDeviceOverride, + string? VulkanForcedUnsupportedFeature) { /// /// Build options from the process environment. Used by @@ -118,9 +121,32 @@ public sealed record RuntimeOptions( FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, ResidencyBudgets: ResidencyBudgetOptions.Parse(env), - StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env)); + StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env), + // Campaign V slice V5. Unset, empty, or any unrecognised value means + // OpenGL: a typo must never silently start the dark Vulkan host. + RenderBackend: ParseRenderBackend(env("ACDREAM_RENDER_BACKEND")), + // Physical-device override, matched as a decimal index first and then + // as a case-insensitive device-name substring. Recorded verbatim in + // graphical-capabilities-vulkan.json whether or not it matched. + VulkanDeviceOverride: + NullIfEmpty(env("ACDREAM_VULKAN_DEVICE")), + // Slice V5 gate knob: names one required capability to report as + // absent so the NotSupportedException -> exit-code-4 -> report path + // can be exercised on hardware that actually supports everything. + VulkanForcedUnsupportedFeature: + NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED"))); } + /// + /// Startup backend request. Only the exact lower-case token vulkan + /// selects Vulkan; everything else — unset, gl, or a typo — is + /// OpenGL, which is the shipping backend until Campaign V slice V10. + /// + private static RenderBackendKind ParseRenderBackend(string? value) + => string.Equals(value, "vulkan", StringComparison.OrdinalIgnoreCase) + ? RenderBackendKind.Vulkan + : RenderBackendKind.Gl; + /// True iff live-mode credentials are present and valid for connecting. public bool HasLiveCredentials => LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs new file mode 100644 index 00000000..3dfd52c4 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanActiveDeviceProbeTests.cs @@ -0,0 +1,109 @@ +using System; +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5 — the parts of the active Vulkan probe that can be +/// checked without a device. +/// +/// The probe's submission path needs a driver and is covered by the manual +/// "Vulkan boots to a clear colour" gate. Its verdict — did the +/// readback really contain the colour we cleared to? — is pure, and it is the +/// step most worth pinning: a probe that accepts zeroed memory would report a +/// pass on a device that rendered nothing at all. +/// +public sealed class VulkanActiveDeviceProbeTests +{ + private static byte[] Filled(byte r, byte g, byte b, byte a) + { + int pixels = (int)(VulkanActiveDeviceProbe.ProbeExtent * VulkanActiveDeviceProbe.ProbeExtent); + var buffer = new byte[pixels * 4]; + for (int i = 0; i < buffer.Length; i += 4) + { + buffer[i] = r; + buffer[i + 1] = g; + buffer[i + 2] = b; + buffer[i + 3] = a; + } + + return buffer; + } + + private static byte[] Expected() => Filled( + VulkanActiveDeviceProbe.ExpectedClearRgba[0], + VulkanActiveDeviceProbe.ExpectedClearRgba[1], + VulkanActiveDeviceProbe.ExpectedClearRgba[2], + VulkanActiveDeviceProbe.ExpectedClearRgba[3]); + + /// + /// Every channel of the probe colour differs from the others and none is 0 + /// or 255, so zeroed or saturated memory cannot accidentally match. + /// + [Fact] + public void TheProbeColourCannotBeMatchedByZeroedOrSaturatedMemory() + { + ReadOnlySpan colour = VulkanActiveDeviceProbe.ExpectedClearRgba; + + Assert.Equal(4, colour.Length); + foreach (byte channel in colour) + { + Assert.NotEqual(0, channel); + Assert.NotEqual(255, channel); + } + + Assert.Throws( + () => VulkanActiveDeviceProbe.VerifyClearColour(Filled(0, 0, 0, 0))); + Assert.Throws( + () => VulkanActiveDeviceProbe.VerifyClearColour(Filled(255, 255, 255, 255))); + } + + [Fact] + public void TheExpectedClearColourIsAccepted() + { + VulkanActiveDeviceProbe.VerifyClearColour(Expected()); + } + + /// + /// The clear colour is specified as a float and quantised by the + /// implementation, so one least-significant bit of slack is allowed — and + /// exactly one. + /// + [Fact] + public void OneBitOfQuantisationSlackIsAllowedButTwoIsNot() + { + byte[] colour = VulkanActiveDeviceProbe.ExpectedClearRgba.ToArray(); + + VulkanActiveDeviceProbe.VerifyClearColour( + Filled((byte)(colour[0] + 1), colour[1], colour[2], colour[3])); + VulkanActiveDeviceProbe.VerifyClearColour( + Filled((byte)(colour[0] - 1), colour[1], colour[2], colour[3])); + + Assert.Throws( + () => VulkanActiveDeviceProbe.VerifyClearColour( + Filled((byte)(colour[0] + 2), colour[1], colour[2], colour[3]))); + } + + /// + /// A driver that clears only the first tile, or that returns a row-padded + /// copy, must fail here rather than at the V7 differential — which is why + /// every pixel is compared and not a sample. + /// + [Fact] + public void ASinglyWrongPixelAnywhereIsRejected() + { + byte[] pixels = Expected(); + int lastPixel = pixels.Length - 4; + pixels[lastPixel + 1] ^= 0xFF; + + Assert.Throws( + () => VulkanActiveDeviceProbe.VerifyClearColour(pixels)); + } + + [Fact] + public void AShortReadbackIsRejectedRatherThanPartiallyChecked() + { + Assert.Throws( + () => VulkanActiveDeviceProbe.VerifyClearColour(new byte[64])); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs new file mode 100644 index 00000000..e209563c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanCapabilityGateTests.cs @@ -0,0 +1,545 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using AcDream.App.Platform; +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 V5 — the Vulkan capability gate's accept/reject matrix. +/// +/// These run with no driver, no device and no window: the gate is a pure +/// function from a captured record to a list of operator-facing sentences, and +/// that separation is the whole reason the interop layer stays thin. Each +/// mandatory feature gets its own "absent means rejected" case, because a gate +/// that silently stops checking one requirement is exactly the failure the GL +/// probe was written to prevent. +/// +public sealed class VulkanCapabilityGateTests +{ + private static VulkanCapabilityRecord SupportedRecord( + VulkanDeviceFeatureSupport? features = null, + VulkanDeviceLimitSupport? limits = null, + VulkanFormatSupport? formats = null, + VulkanSurfaceSupport? surface = null, + VulkanFunctionProbeResult? probe = null, + uint? apiVersion = null) + { + var record = new VulkanCapabilityRecord( + DateTimeOffset.UnixEpoch, + "win-x64", + GraphicalHostOperatingSystem.Windows, + GraphicalDisplayProtocol.Windows, + GraphicalDisplayProtocol.Windows, + "Vulkan 1.3.0", + "Vulkan 1.3.280", + apiVersion ?? VulkanApiVersion.Make(1, 3, 280), + "AMD Radeon RX 9070 XT", + "vendor 0x1002, device 0x7550, driver 2.0.0 (raw 0x00800000)", + PhysicalDeviceType.DiscreteGpu, + 0, + "automatic", + RequestedDeviceOverride: null, + ForcedUnsupportedFeature: null, + AvailableDevices: [], + InstanceExtensions: ["VK_KHR_surface", "VK_KHR_win32_surface"], + DeviceExtensions: ["VK_KHR_swapchain"], + GraphicsQueueFamily: 0, + PresentQueueFamily: 0, + features ?? VulkanDeviceFeatureSupport.Complete, + limits ?? VulkanDeviceLimitSupport.Complete, + formats ?? VulkanFormatSupport.Complete, + surface ?? SupportedSurface(), + probe ?? PassingProbe(), + SupportFailures: []); + return VulkanCapabilityRequirements.Reevaluate(record); + } + + private static VulkanSurfaceSupport SupportedSurface() => new( + PresentSupported: true, + SelectedFormat: Format.B8G8R8A8Unorm, + SelectedColorSpace: ColorSpaceKHR.SpaceSrgbNonlinearKhr, + SelectedPresentMode: PresentModeKHR.FifoKhr, + SelectedImageCount: 3, + SelectedWidth: 1280, + SelectedHeight: 720, + SupportsTransferSource: true, + AvailableFormats: [Format.B8G8R8A8Unorm], + AvailablePresentModes: [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]); + + private static VulkanFunctionProbeResult PassingProbe() => new( + DeviceCreation: true, + DescriptorIndexingLayout: true, + PushConstantLayout: true, + DynamicRenderingClear: true, + TimelineSemaphoreWait: true, + HostQueryReset: true, + OffscreenReadback: true, + Failures: []); + + [Fact] + public void ACompleteDeviceIsAccepted() + { + VulkanCapabilityRecord record = SupportedRecord(); + + Assert.Empty(record.SupportFailures); + Assert.True(record.IsSupported); + } + + /// + /// Every field on is mandatory, so + /// clearing any one of them must produce exactly one new failure. Driving + /// this by reflection rather than by hand means a feature added to the record + /// without a matching Evaluate clause fails here instead of shipping + /// unchecked. + /// + [Fact] + public void EveryRequiredFeatureIsIndividuallyEnforced() + { + IEnumerable featureNames = typeof(VulkanDeviceFeatureSupport) + .GetProperties() + .Where(property => property.PropertyType == typeof(bool)) + .Select(property => property.Name); + + foreach (string name in featureNames) + { + VulkanDeviceFeatureSupport? reduced = + VulkanDeviceFeatureSupport.Complete.Without(name); + Assert.NotNull(reduced); + + VulkanCapabilityRecord record = SupportedRecord(features: reduced); + Assert.False( + record.IsSupported, + $"clearing {name} must reject the device."); + Assert.Single(record.SupportFailures); + } + } + + [Fact] + public void AnUnknownFeatureNameIsNotSilentlyIgnored() + { + Assert.Null(VulkanDeviceFeatureSupport.Complete.Without("NotAVulkanFeature")); + } + + [Theory] + [InlineData(1, 2)] + [InlineData(1, 0)] + [InlineData(0, 9)] + public void ADeviceBelowVulkan13IsRejected(uint major, uint minor) + { + VulkanCapabilityRecord record = SupportedRecord( + apiVersion: VulkanApiVersion.Make(major, minor, 0)); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("Vulkan 1.3 is required", StringComparison.Ordinal)); + } + + [Fact] + public void Vulkan14IsAccepted() + { + VulkanCapabilityRecord record = SupportedRecord( + apiVersion: VulkanApiVersion.Make(1, 4, 0)); + + Assert.True(record.IsSupported); + } + + [Fact] + public void PushConstantsBelowThePinnedBlockAreRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MaxPushConstantsSize = GpuBindingModel.PushConstantBytes - 1, + }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("push-constant bytes are required", StringComparison.Ordinal)); + } + + [Fact] + public void FewerThanEightClipDistancesAreRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with { MaxClipDistances = 6 }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("clip distances are required", StringComparison.Ordinal)); + } + + /// + /// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once, so + /// two bound sets is not enough. This is the limit the §3.4 binding model + /// silently assumes. + /// + [Fact] + public void FewerThanThreeBoundDescriptorSetsAreRejected() + { + Assert.Equal(3u, VulkanCapabilityRequirements.VulkanDescriptorSetCount); + + VulkanCapabilityRecord record = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with { MaxBoundDescriptorSets = 2 }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains( + "simultaneously bound descriptor sets are required", + StringComparison.Ordinal)); + } + + [Fact] + public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage() + { + VulkanCapabilityRecord perSet = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MaxDescriptorSetUpdateAfterBindSampledImages = + GpuBindingModel.TextureTableCapacity - 1, + }); + VulkanCapabilityRecord perStage = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MaxPerStageDescriptorUpdateAfterBindSampledImages = + GpuBindingModel.TextureTableCapacity - 1, + }); + + Assert.Contains( + perSet.SupportFailures, + failure => failure.Contains("update-after-bind sampled images", StringComparison.Ordinal)); + Assert.Contains( + perStage.SupportFailures, + failure => failure.Contains("fragment stage", StringComparison.Ordinal)); + } + + [Fact] + public void MissingGraphicsTimestampsAreRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with { TimestampComputeAndGraphics = false }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("timestamps are required", StringComparison.Ordinal)); + } + + /// + /// The single highest-severity finding of the V3 audit: the swapchain is + /// UNORM, not sRGB. A surface that cannot offer UNORM must be a startup + /// failure, because silently taking an _SRGB format would brighten every + /// frame and pass every automated gate until V7. + /// + [Fact] + public void ASurfaceWithoutTheUnormFormatIsRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + formats: VulkanFormatSupport.Complete with { SwapchainUnormFormat = false }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("B8G8R8A8_UNORM", StringComparison.Ordinal)); + } + + [Fact] + public void AMissingDepthStencilFormatIsRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + formats: VulkanFormatSupport.Complete with { DepthStencilFormat = Format.Undefined }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("depth+stencil format", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void MissingAnyBcBlockIsRejected(bool bc1, bool bc2, bool bc3) + { + VulkanCapabilityRecord record = SupportedRecord( + formats: VulkanFormatSupport.Complete with + { + Bc1Sampled = bc1, + Bc2Sampled = bc2, + Bc3Sampled = bc3, + }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("BC1, BC2 and BC3", StringComparison.Ordinal)); + } + + [Fact] + public void ASurfaceWithoutTransferSourceUsageIsRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + surface: SupportedSurface() with { SupportsTransferSource = false }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("TRANSFER_SRC", StringComparison.Ordinal)); + } + + [Fact] + public void ADeviceThatCannotPresentIsRejected() + { + VulkanCapabilityRecord record = SupportedRecord( + surface: SupportedSurface() with { PresentSupported = false }); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains("cannot present", StringComparison.Ordinal)); + } + + /// + /// A headless capture (no window) is still evaluable — that is what lets the + /// probe be offscreen — and simply carries no surface requirements. + /// + [Fact] + public void AHeadlessCaptureWithNoSurfaceIsAccepted() + { + VulkanCapabilityRecord record = SupportedRecord(surface: null); + + Assert.True(record.IsSupported); + } + + [Fact] + public void ProbeFailuresArePrefixedAndReplaceTheIndividualChecks() + { + VulkanCapabilityRecord record = SupportedRecord( + probe: VulkanFunctionProbeResult.NotRun); + + Assert.Contains( + record.SupportFailures, + failure => failure.StartsWith("Vulkan device probe:", StringComparison.Ordinal)); + // The per-step sentences are suppressed when the probe reported its own + // failure, so the operator gets the cause rather than seven symptoms. + Assert.DoesNotContain( + record.SupportFailures, + failure => failure.Contains("probe did not pass", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("DescriptorIndexingLayout", "descriptor-indexing layout probe")] + [InlineData("PushConstantLayout", "push-constant pipeline-layout probe")] + [InlineData("DynamicRenderingClear", "dynamic-rendering clear probe")] + [InlineData("TimelineSemaphoreWait", "timeline-semaphore wait probe")] + [InlineData("HostQueryReset", "host query-reset probe")] + [InlineData("OffscreenReadback", "offscreen readback probe")] + public void EachActiveProbeStepIsIndividuallyEnforced(string step, string expected) + { + VulkanFunctionProbeResult probe = step switch + { + "DescriptorIndexingLayout" => PassingProbe() with { DescriptorIndexingLayout = false }, + "PushConstantLayout" => PassingProbe() with { PushConstantLayout = false }, + "DynamicRenderingClear" => PassingProbe() with { DynamicRenderingClear = false }, + "TimelineSemaphoreWait" => PassingProbe() with { TimelineSemaphoreWait = false }, + "HostQueryReset" => PassingProbe() with { HostQueryReset = false }, + _ => PassingProbe() with { OffscreenReadback = false }, + }; + + VulkanCapabilityRecord record = SupportedRecord(probe: probe); + + Assert.Contains( + record.SupportFailures, + failure => failure.Contains(expected, StringComparison.Ordinal)); + } + + /// + /// The V3 audit noted the GL gate requires sRGB-framebuffer support the + /// renderer never uses, and that the Vulkan gate "must not carry the stale + /// requirement forward". This is that tripwire. + /// + [Fact] + public void TheGateDoesNotRequireSrgbAnything() + { + VulkanCapabilityRecord record = SupportedRecord( + features: VulkanDeviceFeatureSupport.Complete.Without("MultiDrawIndirect")!); + + Assert.DoesNotContain( + record.SupportFailures, + failure => failure.Contains("sRGB", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TheForcedUnsupportedKnobRejectsTheNamedFeature() + { + VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + "timelineSemaphore"); + + Assert.False(forced.IsSupported); + Assert.Equal("timelineSemaphore", forced.ForcedUnsupportedFeature); + Assert.Contains( + forced.SupportFailures, + failure => failure.Contains("timelineSemaphore is required", StringComparison.Ordinal)); + } + + [Fact] + public void TheForcedUnsupportedKnobIgnoresAnUnsetValue() + { + VulkanCapabilityRecord unchanged = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + featureName: null); + + Assert.True(unchanged.IsSupported); + Assert.Null(unchanged.ForcedUnsupportedFeature); + } + + /// + /// A knob that names a nonexistent feature must fail loudly. A silent no-op + /// would report a pass the operator never actually exercised. + /// + [Fact] + public void TheForcedUnsupportedKnobFailsOnAnUnknownName() + { + VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + "TeapotShading"); + + Assert.False(forced.IsSupported); + Assert.Contains( + forced.SupportFailures, + failure => failure.Contains( + "which is not a required Vulkan feature", + StringComparison.Ordinal)); + } + + [Fact] + public void TheUnsupportedMessageNamesThePlatformDeviceFailuresAndReport() + { + VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + "DynamicRendering"); + + string message = VulkanCapabilityGuard.FormatUnsupportedMessage( + record, + "artifacts/graphical-capabilities-vulkan.json"); + + Assert.Contains("win-x64", message, StringComparison.Ordinal); + Assert.Contains("AMD Radeon RX 9070 XT", message, StringComparison.Ordinal); + Assert.Contains("dynamicRendering is required", message, StringComparison.Ordinal); + Assert.Contains("graphical-capabilities-vulkan.json", message, StringComparison.Ordinal); + } + + [Fact] + public void ThrowIfUnsupportedRaisesNotSupportedExceptionForTheExitFourContract() + { + VulkanCapabilityRecord rejected = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + "Synchronization2"); + + // Program.cs maps NotSupportedException out of window.Run() to exit 4. + Assert.Throws( + () => VulkanCapabilityGuard.ThrowIfUnsupported(rejected, "report.json")); + VulkanCapabilityGuard.ThrowIfUnsupported(SupportedRecord(), "report.json"); + } + + [Fact] + public void TheReportFileNameSitsBesideTheGlOne() + { + Assert.Equal( + "graphical-capabilities-vulkan.json", + VulkanCapabilityGuard.ReportFileName); + } + + /// + /// The JSON report is the artifact an operator sends with a bug report, so + /// the fields that identify the machine and explain the refusal must be + /// present and readable — enums as names, not integers. + /// + [Fact] + public void TheJsonReportCarriesTheIdentifyingFieldsAsReadableNames() + { + VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported( + SupportedRecord(), + "Maintenance4"); + + string json = VulkanCapabilityReportWriter.Serialize(record); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Equal("win-x64", root.GetProperty("RuntimeIdentifier").GetString()); + Assert.Equal("AMD Radeon RX 9070 XT", root.GetProperty("DeviceName").GetString()); + Assert.Equal("DiscreteGpu", root.GetProperty("DeviceType").GetString()); + Assert.Equal("Windows", root.GetProperty("OperatingSystem").GetString()); + Assert.Equal("Maintenance4", root.GetProperty("ForcedUnsupportedFeature").GetString()); + Assert.False(root.GetProperty("Features").GetProperty("Maintenance4").GetBoolean()); + Assert.Equal( + "B8G8R8A8Unorm", + root.GetProperty("Surface").GetProperty("SelectedFormat").GetString()); + Assert.NotEmpty(root.GetProperty("SupportFailures").EnumerateArray().ToArray()); + } + + /// + /// The record projects onto the backend-neutral contract the renderers + /// consult. The alignment fields are load-bearing rather than informational — + /// a ring allocation that violates one is a driver error on Vulkan. + /// + [Fact] + public void TheRecordProjectsOntoTheBackendNeutralContract() + { + VulkanCapabilityRecord record = SupportedRecord( + limits: VulkanDeviceLimitSupport.Complete with + { + MinStorageBufferOffsetAlignment = 16, + MinUniformBufferOffsetAlignment = 64, + MaxColorSampleCount = 4, + MaxDescriptorSetUpdateAfterBindSampledImages = 500_000, + MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384, + }); + + GpuCapabilityRecord projected = record.ToGpuCapabilityRecord(); + + Assert.Equal(GpuBackendKind.Vulkan, projected.Backend); + Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName); + Assert.Equal("Vulkan 1.3.280", projected.ApiVersion); + Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment); + Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment); + Assert.Equal(4u, projected.MaxSampleCount); + // The table is limited by whichever of the two counts is smaller. + Assert.Equal(16_384u, projected.MaxTextureTableSlots); + Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings); + Assert.True(projected.SupportsMultiDrawIndirect); + Assert.True(projected.SupportsDrawParameters); + Assert.True(projected.SupportsTextureCompressionBc); + Assert.True(projected.SupportsTimestampQueries); + // The whole point of the campaign's CPU target: per-frame data written + // straight into mapped memory rather than copied through BufferSubData. + Assert.True(projected.SupportsPersistentlyMappedRings); + Assert.Empty(projected.SupportFailures); + } + + /// + /// A device the Vulkan gate accepted must also satisfy the backend-neutral + /// contract's own SupportFailures. If the two ever disagree, one of + /// them is checking something the other is not. + /// + [Fact] + public void AnAcceptedDeviceAlsoSatisfiesTheNeutralContract() + { + GpuCapabilityRecord projected = SupportedRecord().ToGpuCapabilityRecord(); + + Assert.True(projected.IsSupported); + } + + [Theory] + [InlineData(1u, 3u, 280u)] + [InlineData(1u, 4u, 0u)] + [InlineData(0u, 0u, 1u)] + public void ApiVersionPackingRoundTrips(uint major, uint minor, uint patch) + { + uint packed = VulkanApiVersion.Make(major, minor, patch); + + Assert.Equal(major, VulkanApiVersion.Major(packed)); + Assert.Equal(minor, VulkanApiVersion.Minor(packed)); + Assert.Equal(patch, VulkanApiVersion.Patch(packed)); + Assert.Equal($"Vulkan {major}.{minor}.{patch}", VulkanApiVersion.Describe(packed)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs new file mode 100644 index 00000000..a511445a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanDeviceSelectionTests.cs @@ -0,0 +1,340 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5 — physical-device ranking and queue-family selection. +/// +/// The development machine has exactly one GPU, so the ordering that actually +/// matters on a user's machine is never exercised by running the client. These +/// tests are the only coverage the policy gets, which is precisely why the +/// ranking is pure data rather than something the enumeration loop decides +/// inline. +/// +public sealed class VulkanDeviceSelectionTests +{ + private const ulong Gib = 1024ul * 1024ul * 1024ul; + + private static VulkanPhysicalDeviceCandidate Device( + int index, + string name, + PhysicalDeviceType type, + ulong heapBytes) => new( + index, + name, + type, + ApiVersion: VulkanApiVersion.Make(1, 3, 280), + DriverVersion: 1, + VendorId: 0x1002, + DeviceId: 0x7550, + DeviceLocalHeapBytes: heapBytes); + + [Fact] + public void DiscreteBeatsIntegratedBeatsVirtualBeatsCpu() + { + // Vulkan's own enum ordering is Other < Integrated < Discrete < Virtual + // < Cpu, which is neither our order nor monotone. Pin the mapping. + Assert.True( + VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.DiscreteGpu) + < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu)); + Assert.True( + VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu) + < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu)); + Assert.True( + VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu) + < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu)); + Assert.True( + VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu) + < VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Other)); + } + + [Fact] + public void TheDiscreteGpuWinsOverALargerIntegratedHeap() + { + // An integrated GPU can report the whole of system RAM as device-local. + // Type preference must dominate the heap tie-break, not the other way up. + List candidates = + [ + Device(0, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib), + Device(1, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null); + + Assert.NotNull(choice); + Assert.Equal("AMD Radeon RX 9070 XT", choice.Device.DeviceName); + Assert.Contains("automatic", choice.Reason, System.StringComparison.Ordinal); + } + + [Fact] + public void TwoDiscreteGpusAreBrokenByTheLargerDeviceLocalHeap() + { + List candidates = + [ + Device(0, "Small Discrete", PhysicalDeviceType.DiscreteGpu, 8 * Gib), + Device(1, "Big Discrete", PhysicalDeviceType.DiscreteGpu, 24 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null); + + Assert.Equal("Big Discrete", choice!.Device.DeviceName); + } + + /// + /// Two identical GPUs must produce the same choice on every launch, or a + /// capability report from one run does not describe the next. + /// + [Fact] + public void IdenticalDevicesAreBrokenDeterministicallyByEnumerationIndex() + { + List candidates = + [ + Device(0, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + Device(1, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + ]; + + Assert.Equal( + 0, + VulkanPhysicalDeviceSelection.Choose(candidates, null)!.Device.Index); + Assert.Equal( + 0, + VulkanPhysicalDeviceSelection.Choose([.. candidates.AsEnumerable().Reverse()], null)! + .Device.Index); + } + + [Fact] + public void CpuAndSoftwareDevicesAreLastButStillSelectable() + { + // lavapipe is the CI second implementation (plan §5, slice V9), so a + // CPU device must still be chosen when it is the only one present. + List candidates = + [ + Device(0, "llvmpipe", PhysicalDeviceType.Cpu, 0), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null); + + Assert.Equal("llvmpipe", choice!.Device.DeviceName); + } + + [Fact] + public void NoEnumeratedDeviceYieldsNoChoice() + { + Assert.Null(VulkanPhysicalDeviceSelection.Choose([], deviceOverride: null)); + } + + [Fact] + public void TheOverrideMatchesADecimalEnumerationIndex() + { + List candidates = + [ + Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, "1"); + + Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName); + Assert.Contains("ACDREAM_VULKAN_DEVICE=1", choice.Reason, System.StringComparison.Ordinal); + } + + [Theory] + [InlineData("iris")] + [InlineData("IRIS")] + [InlineData("Intel Iris Xe")] + public void TheOverrideMatchesACaseInsensitiveNameSubstring(string value) + { + List candidates = + [ + Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, value); + + Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName); + Assert.Contains("matched device name", choice.Reason, System.StringComparison.Ordinal); + } + + /// + /// A stale environment variable must not stop the client starting; it must + /// say so in the report and start on the right GPU anyway. + /// + [Fact] + public void AnOverrideThatMatchesNothingFallsBackAndSaysSo() + { + List candidates = + [ + Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, "GeForce"); + + Assert.Equal("AMD Radeon RX 9070 XT", choice!.Device.DeviceName); + Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal); + } + + /// + /// A bare digit is a substring of most real device names — "7" occurs in + /// "AMD Radeon RX 9070 XT" — so an all-digits override must be an index and + /// nothing else. Falling through to substring matching would make an + /// out-of-range index quietly select a device by coincidence. + /// + [Fact] + public void AnOutOfRangeIndexOverrideFallsBackRatherThanMatchingADigitInADeviceName() + { + List candidates = + [ + Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, "7"); + + Assert.Equal(0, choice!.Device.Index); + Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal); + } + + /// + /// The exact index/substring split must not break names that legitimately + /// contain digits: "RX 7900" is not digits alone, so it is a name. + /// + [Fact] + public void ANameContainingDigitsStillMatchesAsASubstring() + { + List candidates = + [ + Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 24 * Gib), + Device(1, "AMD Radeon RX 7900 XTX", PhysicalDeviceType.DiscreteGpu, 16 * Gib), + ]; + + VulkanPhysicalDeviceChoice? choice = + VulkanPhysicalDeviceSelection.Choose(candidates, "RX 7900"); + + Assert.Equal("AMD Radeon RX 7900 XTX", choice!.Device.DeviceName); + } + + [Theory] + [InlineData("0", true)] + [InlineData("12", true)] + [InlineData("", false)] + [InlineData("-1", false)] + [InlineData("7900 XTX", false)] + [InlineData("Radeon", false)] + public void OnlyAnAllDigitsOverrideIsTreatedAsAnIndex(string value, bool expected) + { + Assert.Equal(expected, VulkanPhysicalDeviceSelection.IsDecimalIndex(value)); + } + + [Fact] + public void OneFamilyThatDoesBothIsPreferredOverASplitPair() + { + List families = + [ + new(0, SupportsGraphics: true, SupportsPresent: false), + new(1, SupportsGraphics: false, SupportsPresent: true), + new(2, SupportsGraphics: true, SupportsPresent: true), + ]; + + VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families); + + Assert.NotNull(choice); + Assert.True(choice.IsUnified); + Assert.Equal(2u, choice.GraphicsFamily); + Assert.Equal(2u, choice.PresentFamily); + } + + [Fact] + public void ASplitDeviceStillProducesAUsablePair() + { + List families = + [ + new(0, SupportsGraphics: true, SupportsPresent: false), + new(1, SupportsGraphics: false, SupportsPresent: true), + ]; + + VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families); + + Assert.NotNull(choice); + Assert.False(choice.IsUnified); + Assert.Equal(0u, choice.GraphicsFamily); + Assert.Equal(1u, choice.PresentFamily); + } + + [Fact] + public void ADeviceWithNoPresentCapableFamilyYieldsNoChoice() + { + List families = + [ + new(0, SupportsGraphics: true, SupportsPresent: false), + ]; + + Assert.Null(VulkanQueueFamilySelection.Choose(families)); + // The headless probe has no surface and so no present requirement. + Assert.Equal(0u, VulkanQueueFamilySelection.ChooseGraphicsOnly(families)); + } + + [Fact] + public void AComputeOnlyDeviceHasNoGraphicsFamily() + { + List families = + [ + new(0, SupportsGraphics: false, SupportsPresent: false), + ]; + + Assert.Null(VulkanQueueFamilySelection.ChooseGraphicsOnly(families)); + } + + [Fact] + public void RequiredExtensionsMustBeAdvertisedButOptionalOnesMayBeAbsent() + { + VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve( + available: ["VK_KHR_surface", "VK_KHR_win32_surface", "VK_EXT_debug_utils"], + required: ["VK_KHR_surface", "VK_KHR_win32_surface"], + optional: + [ + VulkanExtensionSelection.DebugUtilsExtension, + VulkanExtensionSelection.MemoryBudgetExtension, + ]); + + Assert.True(plan.IsSatisfied); + Assert.Contains("VK_KHR_surface", plan.Enabled); + Assert.Contains(VulkanExtensionSelection.DebugUtilsExtension, plan.Enabled); + Assert.Contains(VulkanExtensionSelection.MemoryBudgetExtension, plan.UnavailableOptional); + Assert.Empty(plan.MissingRequired); + } + + [Fact] + public void AMissingRequiredExtensionIsReportedByName() + { + VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve( + available: ["VK_KHR_surface"], + required: ["VK_KHR_surface", VulkanExtensionSelection.SwapchainExtension], + optional: []); + + Assert.False(plan.IsSatisfied); + Assert.Equal( + [VulkanExtensionSelection.SwapchainExtension], + plan.MissingRequired); + } + + [Fact] + public void AnExtensionNamedBothRequiredAndOptionalIsEnabledOnce() + { + VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve( + available: [VulkanExtensionSelection.DebugUtilsExtension], + required: [VulkanExtensionSelection.DebugUtilsExtension], + optional: [VulkanExtensionSelection.DebugUtilsExtension]); + + Assert.Single(plan.Enabled); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs new file mode 100644 index 00000000..bf5956db --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanSwapchainConfigurationTests.cs @@ -0,0 +1,493 @@ +using System; +using System.Collections.Generic; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.Vulkan; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V5 — swapchain configuration and lifecycle policy. +/// +/// Everything here is a decision the swapchain makes before it calls anything, +/// which is what lets the risk register's "swapchain lifecycle (resize, +/// minimize, RDP)" row be covered by unit tests rather than only by a connected +/// gate on one machine. +/// +public sealed class VulkanSwapchainConfigurationTests +{ + private static SurfaceCapabilitiesKHR Capabilities( + uint minImages = 2, + uint maxImages = 8, + uint currentWidth = 1280, + uint currentHeight = 720, + uint minWidth = 1, + uint minHeight = 1, + uint maxWidth = 16384, + uint maxHeight = 16384, + ImageUsageFlags usage = + ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit, + SurfaceTransformFlagsKHR supportedTransforms = SurfaceTransformFlagsKHR.IdentityBitKhr, + SurfaceTransformFlagsKHR currentTransform = SurfaceTransformFlagsKHR.IdentityBitKhr, + CompositeAlphaFlagsKHR compositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr) + => new() + { + MinImageCount = minImages, + MaxImageCount = maxImages, + CurrentExtent = new Extent2D(currentWidth, currentHeight), + MinImageExtent = new Extent2D(minWidth, minHeight), + MaxImageExtent = new Extent2D(maxWidth, maxHeight), + MaxImageArrayLayers = 1, + SupportedTransforms = supportedTransforms, + CurrentTransform = currentTransform, + SupportedCompositeAlpha = compositeAlpha, + SupportedUsageFlags = usage, + }; + + /// + /// The single highest-severity finding of the V3 audit. acdream is plain + /// UNORM end to end; an _SRGB swapchain would encode already + /// display-space values and brighten every frame globally, and it would have + /// passed silently right up to the V7 differential. + /// + [Fact] + public void TheSwapchainFormatIsUnormAndNeverSrgb() + { + Assert.Equal( + Format.B8G8R8A8Unorm, + VulkanSwapchainConfigurationFactory.PreferredFormat); + + SurfaceFormatKHR chosen = VulkanSwapchainConfigurationFactory.ChooseSurfaceFormat( + [ + new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr), + new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr), + ]); + + Assert.Equal(Format.B8G8R8A8Unorm, chosen.Format); + Assert.Equal(ColorSpaceKHR.SpaceSrgbNonlinearKhr, chosen.ColorSpace); + } + + [Fact] + public void ASurfaceOfferingOnlySrgbIsDetectedAsMissingUnorm() + { + IReadOnlyList srgbOnly = + [ + new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr), + ]; + + Assert.False(VulkanSwapchainConfigurationFactory.OffersUnormFormat(srgbOnly)); + Assert.True( + VulkanSwapchainConfigurationFactory.OffersUnormFormat( + [ + new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr), + ])); + } + + [Fact] + public void VSyncOnAlwaysSelectsFifo() + { + FramePacingPolicy pacing = FramePacingPolicy.Resolve( + requestedVSync: true, + uncappedRendering: false, + monitorRefreshHz: 144); + + Assert.True(pacing.UseVSync); + Assert.Equal( + PresentModeKHR.FifoKhr, + VulkanSwapchainConfigurationFactory.ChoosePresentMode( + pacing, + [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr, PresentModeKHR.MailboxKhr])); + } + + /// + /// With VSync off the software pacer owns the ceiling, so IMMEDIATE is + /// preferred: MAILBOX inserts a hidden queue between our pacing decision and + /// the display, which is the opposite of what issue #235 needs. + /// + [Fact] + public void VSyncOffPrefersImmediateThenMailbox() + { + FramePacingPolicy softwareCapped = FramePacingPolicy.Resolve( + requestedVSync: false, + uncappedRendering: false, + monitorRefreshHz: 144); + Assert.False(softwareCapped.UseVSync); + Assert.Equal(144d, softwareCapped.SoftwareLimitHz); + + Assert.Equal( + PresentModeKHR.ImmediateKhr, + VulkanSwapchainConfigurationFactory.ChoosePresentMode( + softwareCapped, + [PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr, PresentModeKHR.ImmediateKhr])); + + Assert.Equal( + PresentModeKHR.MailboxKhr, + VulkanSwapchainConfigurationFactory.ChoosePresentMode( + softwareCapped, + [PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr])); + } + + [Fact] + public void UncappedRenderingAlsoTakesImmediate() + { + FramePacingPolicy uncapped = FramePacingPolicy.Resolve( + requestedVSync: true, + uncappedRendering: true, + monitorRefreshHz: 144); + + Assert.False(uncapped.UseVSync); + Assert.Null(uncapped.SoftwareLimitHz); + Assert.Equal( + PresentModeKHR.ImmediateKhr, + VulkanSwapchainConfigurationFactory.ChoosePresentMode( + uncapped, + [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr])); + } + + /// FIFO is the only present mode Vulkan guarantees exists. + [Fact] + public void FifoIsTheFallbackWhenNothingElseIsOffered() + { + FramePacingPolicy pacing = FramePacingPolicy.Resolve(false, false, 60); + + Assert.Equal( + PresentModeKHR.FifoKhr, + VulkanSwapchainConfigurationFactory.ChoosePresentMode( + pacing, + [PresentModeKHR.FifoKhr])); + } + + [Fact] + public void ThreeImagesAreRequestedForTwoFramesInFlight() + { + Assert.Equal(3u, VulkanSwapchainConfigurationFactory.PreferredImageCount); + Assert.Equal( + 3u, + VulkanSwapchainConfigurationFactory.ChooseImageCount(Capabilities())); + } + + [Fact] + public void TheImageCountIsRaisedToTheSurfaceMinimum() + { + Assert.Equal( + 5u, + VulkanSwapchainConfigurationFactory.ChooseImageCount( + Capabilities(minImages: 5, maxImages: 8))); + } + + /// + /// maxImageCount == 0 means "no upper limit" in Vulkan. Reading it as + /// a literal ceiling would clamp every swapchain to zero images. + /// + [Fact] + public void AZeroMaximumImageCountMeansNoUpperLimit() + { + Assert.Equal( + 3u, + VulkanSwapchainConfigurationFactory.ChooseImageCount( + Capabilities(minImages: 2, maxImages: 0))); + } + + [Fact] + public void TheImageCountIsClampedToTheSurfaceMaximum() + { + Assert.Equal( + 2u, + VulkanSwapchainConfigurationFactory.ChooseImageCount( + Capabilities(minImages: 1, maxImages: 2))); + } + + [Fact] + public void TheSurfacesOwnExtentWinsWhenItReportsOne() + { + (uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent( + Capabilities(currentWidth: 2560, currentHeight: 1440), + framebufferWidth: 1280, + framebufferHeight: 720); + + Assert.Equal(2560u, width); + Assert.Equal(1440u, height); + } + + /// + /// currentExtent.width == uint.MaxValue is the surface saying "you + /// choose" — Wayland does this — so the framebuffer size is used, clamped. + /// + [Fact] + public void TheFramebufferSizeIsUsedAndClampedWhenTheSurfaceDefers() + { + (uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent( + Capabilities( + currentWidth: uint.MaxValue, + currentHeight: uint.MaxValue, + minWidth: 64, + minHeight: 64, + maxWidth: 1024, + maxHeight: 1024), + framebufferWidth: 4000, + framebufferHeight: 32); + + Assert.Equal(1024u, width); + Assert.Equal(64u, height); + } + + [Fact] + public void AMinimisedWindowProducesAZeroExtentThatIsNotPresentable() + { + VulkanSwapchainConfiguration configuration = + VulkanSwapchainConfigurationFactory.Create( + Capabilities(currentWidth: 0, currentHeight: 0), + [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)], + [PresentModeKHR.FifoKhr], + FramePacingPolicy.Resolve(true, false, 60), + framebufferWidth: 0, + framebufferHeight: 0); + + Assert.False(configuration.IsPresentable); + } + + /// Screenshots copy the presented image, so TRANSFER_SRC is not optional. + [Fact] + public void TheUsageIncludesColourAttachmentAndTransferSource() + { + Assert.Equal( + ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit, + VulkanSwapchainConfigurationFactory.RequiredUsage); + + VulkanSwapchainConfiguration configuration = + VulkanSwapchainConfigurationFactory.Create( + Capabilities(), + [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)], + [PresentModeKHR.FifoKhr], + FramePacingPolicy.Resolve(true, false, 60), + 1280, + 720); + + Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit)); + Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.ColorAttachmentBit)); + Assert.True(configuration.IsPresentable); + } + + /// + /// A surface that refuses TRANSFER_SRC has already failed the capability + /// gate, so the configuration drops the bit rather than forcing it — turning + /// a clear startup message into a driver error would be a regression. + /// + [Fact] + public void AnUnsupportedTransferSourceUsageIsDroppedRatherThanForced() + { + SurfaceCapabilitiesKHR capabilities = + Capabilities(usage: ImageUsageFlags.ColorAttachmentBit); + + Assert.False(VulkanSwapchainConfigurationFactory.SupportsTransferSource(capabilities)); + + VulkanSwapchainConfiguration configuration = + VulkanSwapchainConfigurationFactory.Create( + capabilities, + [new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)], + [PresentModeKHR.FifoKhr], + FramePacingPolicy.Resolve(true, false, 60), + 1280, + 720); + + Assert.False(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit)); + } + + [Fact] + public void IdentityPreTransformIsPreferredAndTheCurrentOneIsTheFallback() + { + Assert.Equal( + SurfaceTransformFlagsKHR.IdentityBitKhr, + VulkanSwapchainConfigurationFactory.ChoosePreTransform(Capabilities())); + + Assert.Equal( + SurfaceTransformFlagsKHR.Rotate90BitKhr, + VulkanSwapchainConfigurationFactory.ChoosePreTransform( + Capabilities( + supportedTransforms: SurfaceTransformFlagsKHR.Rotate90BitKhr, + currentTransform: SurfaceTransformFlagsKHR.Rotate90BitKhr))); + } + + [Fact] + public void CompositeAlphaPrefersOpaqueAndFallsBackToInherit() + { + Assert.Equal( + CompositeAlphaFlagsKHR.OpaqueBitKhr, + VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(Capabilities())); + + Assert.Equal( + CompositeAlphaFlagsKHR.InheritBitKhr, + VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha( + Capabilities(compositeAlpha: CompositeAlphaFlagsKHR.InheritBitKhr))); + } + + /// + /// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the next + /// frame boundary." A suboptimal image is still renderable, so rebuilding + /// mid-frame would throw away work for nothing. + /// + [Fact] + public void AcquireResultsMapToTheDocumentedRecreationTiming() + { + Assert.Equal( + VulkanSwapchainAction.Continue, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.Success)); + Assert.Equal( + VulkanSwapchainAction.RecreateNow, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorOutOfDateKhr)); + Assert.Equal( + VulkanSwapchainAction.RecreateAtFrameBoundary, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.SuboptimalKhr)); + Assert.Equal( + VulkanSwapchainAction.Idle, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.Timeout)); + Assert.Equal( + VulkanSwapchainAction.Idle, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.NotReady)); + Assert.Equal( + VulkanSwapchainAction.Fail, + VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorDeviceLost)); + } + + [Fact] + public void PresentResultsMapToTheDocumentedRecreationTiming() + { + Assert.Equal( + VulkanSwapchainAction.Continue, + VulkanSwapchainRecreationPolicy.OnPresent(Result.Success)); + Assert.Equal( + VulkanSwapchainAction.RecreateNow, + VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorOutOfDateKhr)); + Assert.Equal( + VulkanSwapchainAction.RecreateAtFrameBoundary, + VulkanSwapchainRecreationPolicy.OnPresent(Result.SuboptimalKhr)); + Assert.Equal( + VulkanSwapchainAction.Fail, + VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorSurfaceLostKhr)); + } + + [Theory] + [InlineData(0u, 0u)] + [InlineData(0u, 720u)] + [InlineData(1280u, 0u)] + public void AZeroAreaFramebufferIsIdleRatherThanAFailedCreate(uint width, uint height) + { + Assert.Equal( + VulkanSwapchainAction.Idle, + VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height)); + } + + [Fact] + public void ANonZeroFramebufferContinues() + { + Assert.Equal( + VulkanSwapchainAction.Continue, + VulkanSwapchainRecreationPolicy.OnFramebufferSize(1280, 720)); + } + + [Fact] + public void TheBgraToRgbaSwizzleSwapsOnlyRedAndBlue() + { + byte[] bgra = [0x10, 0x20, 0x30, 0x40, 0x01, 0x02, 0x03, 0x04]; + + VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(bgra); + + Assert.Equal([0x30, 0x20, 0x10, 0x40, 0x03, 0x02, 0x01, 0x04], bgra); + } + + [Fact] + public void TheSwizzleIsItsOwnInverse() + { + byte[] pixels = [0x10, 0x20, 0x30, 0x40]; + byte[] original = [.. pixels]; + + VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels); + VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels); + + Assert.Equal(original, pixels); + } + + [Fact] + public void APartialPixelSpanIsRejected() + { + Assert.Throws( + () => VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(new byte[6])); + } + + /// + /// Vulkan reports a row pitch per image layout that is frequently larger than + /// width * 4; reading it as tight produces a diagonally sheared image. + /// + [Fact] + public void RowPaddingIsHonouredWhenConvertingToRgba() + { + // 2x2 BGRA with 4 bytes of padding per row. + byte[] source = + [ + 0x01, 0x02, 0x03, 0xFF, 0x11, 0x12, 0x13, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA, + 0x21, 0x22, 0x23, 0xFF, 0x31, 0x32, 0x33, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA, + ]; + + byte[] rgba = VulkanBackbufferSwizzle.ToRgba(source, 2, 2, sourceRowPitchBytes: 12); + + Assert.Equal( + [ + 0x03, 0x02, 0x01, 0xFF, 0x13, 0x12, 0x11, 0xFF, + 0x23, 0x22, 0x21, 0xFF, 0x33, 0x32, 0x31, 0xFF, + ], + rgba); + } + + /// + /// A Vulkan image is top-left-origin; FrameScreenshotController flips + /// what it is given because glReadPixels hands back bottom-up rows. + /// Converting once at the readback boundary keeps that consumer — and the + /// PNG's orientation — untouched. + /// + [Fact] + public void TheGlOriginConversionFlipsRowsAsWellAsSwizzling() + { + byte[] source = + [ + 0x01, 0x02, 0x03, 0xFF, + 0x21, 0x22, 0x23, 0xFF, + ]; + + byte[] rgba = VulkanBackbufferSwizzle.ToGlOriginRgba( + source, + width: 1, + height: 2, + sourceRowPitchBytes: 4); + + Assert.Equal( + [ + 0x23, 0x22, 0x21, 0xFF, + 0x03, 0x02, 0x01, 0xFF, + ], + rgba); + } + + [Fact] + public void ARowPitchNarrowerThanTheImageIsRejected() + { + Assert.Throws( + () => VulkanBackbufferSwizzle.ToRgba(new byte[16], 2, 2, sourceRowPitchBytes: 4)); + } + + /// + /// The bring-up clear colour must be visibly deliberate: black reads as an + /// unpainted window, and magenta is this codebase's "unresolved texture slot" + /// sentinel. + /// + [Fact] + public void TheBringUpClearColourIsNeitherBlackNorTheMagentaSentinel() + { + float[] colour = VulkanBringUpHost.ClearColor; + + Assert.Equal(4, colour.Length); + Assert.Equal(1f, colour[3]); + Assert.True(colour[0] + colour[1] + colour[2] > 0f); + Assert.False(colour[0] == 1f && colour[1] == 0f && colour[2] == 1f); + Assert.Equal(2, VulkanBringUpHost.FlightCount); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 9d9d5f51..8b53ff8c 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -408,4 +408,86 @@ public sealed class RuntimeOptionsTests Assert.Throws(() => RuntimeOptions.Parse(null!, EmptyEnv())); Assert.Throws(() => RuntimeOptions.Parse(AnyDatDir, null!)); } + + /// + /// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so + /// the default must be GL and a typo must never silently start the dark + /// Vulkan host. + /// + [Fact] + public void RenderBackend_DefaultsToGl() + { + Assert.Equal( + RenderBackendKind.Gl, + RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend); + } + + [Theory] + [InlineData("vulkan")] + [InlineData("Vulkan")] + [InlineData("VULKAN")] + public void RenderBackend_SelectsVulkanCaseInsensitively(string value) + { + Assert.Equal( + RenderBackendKind.Vulkan, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend); + } + + [Theory] + [InlineData("gl")] + [InlineData("opengl")] + [InlineData("")] + [InlineData("vulcan")] + [InlineData("vk")] + [InlineData(" vulkan")] + public void RenderBackend_AnythingElseStaysOnGl(string value) + { + Assert.Equal( + RenderBackendKind.Gl, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend); + } + + [Fact] + public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty() + { + Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanDeviceOverride); + Assert.Null( + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_VULKAN_DEVICE"] = "" })).VulkanDeviceOverride); + } + + /// + /// The override is carried verbatim — index or name substring — because the + /// capability report records exactly what the operator asked for, matched or + /// not. + /// + [Theory] + [InlineData("1")] + [InlineData("Radeon")] + public void VulkanDeviceOverride_IsCarriedVerbatim(string value) + { + Assert.Equal( + value, + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_VULKAN_DEVICE"] = value })).VulkanDeviceOverride); + } + + [Fact] + public void VulkanForcedUnsupportedFeature_DrivesTheExitFourGateKnob() + { + Assert.Null( + RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanForcedUnsupportedFeature); + Assert.Equal( + "timelineSemaphore", + RuntimeOptions.Parse( + AnyDatDir, + Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" })) + .VulkanForcedUnsupportedFeature); + } }