diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index e47fb6ec..2c517d49 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -58,6 +58,12 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs index 28cca247..34cbd376 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -66,14 +66,19 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable 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; + // ── Campaign V slice V6c: the RHI backend and the scene that proves it ── + private VulkanGpuDevice? _gpuDevice; + private VulkanRhiScene? _scene; + private VulkanDebugNames _debugNames = VulkanDebugNames.Disabled; + private VulkanDeviceFeatureSupport? _features; + private VulkanDeviceLimitSupport? _limits; + private VulkanFormatSupport? _formats; + internal VulkanBringUpHost( RuntimeOptions options, GraphicalHostPlatformServices platform, @@ -253,6 +258,13 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable _graphicsQueue, families.GraphicsFamily); + _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice); + _limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice); + _formats = VulkanPhysicalDeviceInspector.ReadFormats( + vk, + _physicalDevice, + VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)); + var record = new VulkanCapabilityRecord( DateTimeOffset.UtcNow, _platform.RuntimeIdentifier, @@ -278,12 +290,9 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable created.EnabledExtensions, families.GraphicsFamily, families.PresentFamily, - VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice), - VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice), - VulkanPhysicalDeviceInspector.ReadFormats( - vk, - _physicalDevice, - VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)), + _features, + _limits, + _formats, surfaceSupport, probe, SupportFailures: []); @@ -310,66 +319,6 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable _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; @@ -386,11 +335,118 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable return _swapchain!.Recreate(_pacing, width, height); } + + /// + /// Campaign V slice V6c: build the RHI backend and the scene that proves it. + /// + /// The host's own command pools, acquire semaphores and timeline are + /// gone — owns all three now, because a frame + /// recorded through the contract has to be the same frame that presents. The + /// host keeps exactly what the contract deliberately does not cover: + /// swapchain configuration and the OUT_OF_DATE/SUBOPTIMAL policy, both of + /// which are slice V5's pure, unit-tested decisions. + /// + private void CreateFrameResources() + { + Silk.NET.Vulkan.Vk vk = _vk!; + _debugNames = VulkanDebugNames.Create(vk, _instance, _device, [.. InstanceExtensions]); + + if (!RecreateSwapchain()) + { + throw new InvalidOperationException( + "The swapchain could not be created for the initial framebuffer size."); + } + + VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; + _gpuDevice = new VulkanGpuDevice( + vk, + _physicalDevice, + _device, + _graphicsQueue, + _presentQueue, + _families!.GraphicsFamily, + _features!, + _limits!, + _formats!, + Capabilities!.DeviceName, + Capabilities.DriverInfo, + Capabilities.DeviceApiVersion, + _debugNames, + new SwapchainBackbuffer(_swapchain!, _presentQueue), + ShaderSpirvDirectory(), + _platform.Paths.CacheDirectory); + + // Four samples where the device allows it, so the backbuffer pass really + // resolves rather than rendering straight into the swapchain image. + // Plan §4.10 records that the V7 differential must force MSAA off; this + // is not that gate, and a resolve path that is never exercised is a + // resolve path that does not work. + int sampleCount = (int)Math.Min(4u, Math.Max(1u, _gpuDevice.Capabilities.MaxSampleCount)); + _gpuDevice.ConfigureBackbufferAttachments( + configuration.Width, + configuration.Height, + configuration.ImageFormat, + sampleCount); + + _scene = new VulkanRhiScene(_gpuDevice, sampleCount); + _log( + $"vulkan: RHI backend up — {_gpuDevice.Allocator.Describe()}, " + + $"{sampleCount}x MSAA, pipeline cache " + + (_gpuDevice.PipelineCacheLoadedFromDisk ? "reused" : "cold") + + $", debug names {(_debugNames.IsEnabled ? "on" : "off")}"); + } + + /// Where the committed SPIR-V lives beside the binary. + private static string ShaderSpirvDirectory() => + Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv"); + + /// + /// Adapts slice V5's swapchain to the narrow surface the RHI device needs. + /// The device deliberately does not own presentation: format, extent, + /// present-mode and the recreation policy are pure decisions that are + /// already unit-tested, and duplicating that judgement inside the backend + /// would fork it. + /// + private sealed class SwapchainBackbuffer(VulkanSwapchain swapchain, Queue presentQueue) : IVulkanBackbuffer + { + public Format ImageFormat => swapchain.Configuration!.ImageFormat; + + public uint Width => swapchain.Configuration!.Width; + + public uint Height => swapchain.Configuration!.Height; + + public bool TryAcquire(Semaphore acquired, out uint imageIndex) + { + VulkanSwapchainAction action = swapchain.TryAcquire( + acquired, + AcquireTimeoutNanoseconds, + out imageIndex); + return action is VulkanSwapchainAction.Continue + or VulkanSwapchainAction.RecreateAtFrameBoundary; + } + + public Image ImageAt(uint imageIndex) => swapchain.ImageAt(imageIndex); + + public ImageView ViewAt(uint imageIndex) => swapchain.ViewAt(imageIndex); + + public Semaphore RenderCompleteAt(uint imageIndex) => swapchain.RenderCompleteAt(imageIndex); + + public bool Present(uint imageIndex) => + swapchain.Present(presentQueue, imageIndex) is VulkanSwapchainAction.Continue; + } + + /// + /// The frame loop: record the verification scene through the RHI, present, + /// and capture one screenshot once the scene has settled. + /// private void Present() { IWindow window = _window!; + VulkanGpuDevice device = _gpuDevice!; + VulkanRhiScene scene = _scene!; FrameScreenshotController? screenshots = CreateScreenshotController(); bool screenshotRequested = false; + DateTimeOffset started = DateTimeOffset.UtcNow; while (!window.IsClosing) { @@ -407,21 +463,44 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable Thread.Sleep(16); continue; } + + VulkanSwapchainConfiguration resized = _swapchain!.Configuration!; + device.ConfigureBackbufferAttachments( + resized.Width, + resized.Height, + resized.ImageFormat, + scene.SampleCount); } - if (!RenderClearFrame(out uint imageIndex)) + if (!device.TryBeginFrame(out IGpuFrame? frame) || frame is null) + { + _recreateAtFrameBoundary = true; continue; + } - if (screenshots is not null && !screenshotRequested) + VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; + using (frame) + { + scene.Render( + frame, + configuration.Width, + configuration.Height, + (DateTimeOffset.UtcNow - started).TotalSeconds); + } + + _frameSerial = (ulong)frame.Serial; + if (!device.PresentSucceeded) + _recreateAtFrameBoundary = true; + + // Capture after a few frames so the timer pool has resolved and the + // ring has cycled through both flight slots at least once. + if (screenshots is not null && !screenshotRequested && _frameSerial >= 4) { screenshotRequested = true; if (screenshots.TryRequest(ScreenshotName, out string error)) { - VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; - _lastPresentedImage = imageIndex; - screenshots.CapturePending( - (int)configuration.Width, - (int)configuration.Height); + screenshots.CapturePending((int)configuration.Width, (int)configuration.Height); + ReportTimings(device); } else { @@ -431,10 +510,25 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable } VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)"); - _log($"vulkan: presented {_frameSerial} clear-colour frame(s); shutting down."); + _log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down."); } - private uint _lastPresentedImage; + private void ReportTimings(VulkanGpuDevice device) + { + if (!device.Timers.IsSupported) + { + _log("vulkan: GPU timestamps are unsupported on this device"); + return; + } + + string offscreen = device.Timers.TryResolve("offscreen", out double offscreenMs) + ? $"{offscreenMs:F3} ms" + : "pending"; + string main = device.Timers.TryResolve("main", out double mainMs) + ? $"{mainMs:F3} ms" + : "pending"; + _log($"vulkan: GPU timer scopes — offscreen {offscreen}, main {main}"); + } private FrameScreenshotController? CreateScreenshotController() { @@ -442,205 +536,20 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable return null; return new FrameScreenshotController( - (_, _) => _swapchain!.CaptureImage( - _graphicsQueue, - _families!.GraphicsFamily, - _lastPresentedImage), + // IGpuDevice.CaptureBackbuffer is documented top-left-origin and a + // Vulkan image already is; FrameScreenshotController flips what it + // receives because glReadPixels hands back bottom-up rows. Flipping + // here makes the two cancel, so the PNG is right-side-up — and it + // routes the screenshot through the RHI capture path, which is the + // thing slice V6c has to prove rather than assume. + (width, height) => FrameScreenshotController.FlipRows( + _gpuDevice!.CaptureBackbuffer(width, height), + width, + height), _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 @@ -658,40 +567,33 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable { vk.DeviceWaitIdle(_device); + // Scene before device: the scene owns buffers, textures, render + // targets and pipelines whose release routes through the device's + // retirement queue, so the device has to still be alive to drain it. + _scene?.Dispose(); + _scene = null; + _gpuDevice?.Dispose(); + _gpuDevice = null; + _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 { + _scene?.Dispose(); + _scene = null; + _gpuDevice?.Dispose(); + _gpuDevice = null; _swapchain?.Dispose(); _swapchain = null; } + _debugNames.Dispose(); + _debugNames = VulkanDebugNames.Disabled; + if (vk is not null && _surfaceApi is not null && _surface.Handle != 0) { _surfaceApi.DestroySurface(_instance, _surface, null); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs new file mode 100644 index 00000000..2b1a0ddb --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs @@ -0,0 +1,233 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c, plan §4.4: sets 0 and 1 for one flight slot, bound with +/// dynamic offsets so no descriptor is ever written mid-frame. +/// +/// The contract lets a renderer bind an arbitrary buffer range per draw, +/// and ring allocations mean that range moves every frame. The obvious +/// implementation — write a descriptor per bind — would put a +/// vkUpdateDescriptorSets in the hot path and reintroduce the exact cost +/// the texture table was designed to remove. So each binding is a +/// *_BUFFER_DYNAMIC descriptor pointing at the whole ring, and the +/// per-draw offset travels in vkCmdBindDescriptorSets's dynamic-offset +/// array, which is free. +/// +/// Every binding is always bound, whether a renderer uses it or +/// not. Bindings a shader does not declare still need a live descriptor, so +/// unused ones point at a shared dummy range. That is what lets there be ONE +/// descriptor set layout and one pipeline layout rather than a permutation per +/// renderer — plan §4.4's requirement, and the thing that makes switching +/// pipelines mid-pass free. +/// +internal sealed unsafe class VulkanFrameBindings : IDisposable +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly DescriptorPool _pool; + private readonly DescriptorSet _storageSet; + private readonly DescriptorSet _uniformSet; + + private readonly uint[] _storageOffsets = new uint[GpuBindingModel.StorageBindingCount]; + private readonly uint[] _uniformOffsets = new uint[UniformBindingCount]; + private readonly Silk.NET.Vulkan.Buffer[] _storageBuffers = + new Silk.NET.Vulkan.Buffer[GpuBindingModel.StorageBindingCount]; + private readonly Silk.NET.Vulkan.Buffer[] _uniformBuffers = + new Silk.NET.Vulkan.Buffer[UniformBindingCount]; + + private bool _disposed; + + /// Bindings 0..3 of set 1; only 1 (SceneLighting) and 3 (terrain tiling) are used. + internal const int UniformBindingCount = 4; + + /// + /// Widest range any single binding may address. Dynamic descriptors take a + /// static range at write time and slide it with an offset, so this bounds + /// how much of the ring one binding can see at once. + /// + internal const uint MaxBindingRangeBytes = 4 * 1024 * 1024; + + internal VulkanFrameBindings( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanPipelineLayouts.Created layouts, + VulkanGpuBuffer ring, + VulkanGpuBuffer dummy) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + ArgumentNullException.ThrowIfNull(layouts); + ArgumentNullException.ThrowIfNull(ring); + ArgumentNullException.ThrowIfNull(dummy); + + DescriptorPoolSize* sizes = stackalloc DescriptorPoolSize[2]; + sizes[0] = new DescriptorPoolSize + { + Type = DescriptorType.StorageBufferDynamic, + DescriptorCount = GpuBindingModel.StorageBindingCount, + }; + sizes[1] = new DescriptorPoolSize + { + Type = DescriptorType.UniformBufferDynamic, + DescriptorCount = UniformBindingCount, + }; + var poolCreate = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = 2, + PoolSizeCount = 2, + PPoolSizes = sizes, + }; + VulkanInterop.Check( + _vk.CreateDescriptorPool(_device, &poolCreate, null, out _pool), + "vkCreateDescriptorPool (frame bindings)"); + + _storageSet = Allocate(layouts.Storage); + _uniformSet = Allocate(layouts.Uniform); + + for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++) + { + _storageBuffers[binding] = dummy.Handle; + WriteStorage(binding, dummy.Handle, (uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes)); + } + + // Only the two bindings the layout declares exist; the rest of the + // array is bookkeeping so the offsets stay index-aligned. + WriteUniform(GpuBindingModel.UniformSceneLighting, dummy.Handle, (uint)Math.Min(dummy.SizeBytes, 65536)); + WriteUniform(GpuBindingModel.UniformTerrainTiling, dummy.Handle, (uint)Math.Min(dummy.SizeBytes, 65536)); + _uniformBuffers[GpuBindingModel.UniformSceneLighting] = dummy.Handle; + _uniformBuffers[GpuBindingModel.UniformTerrainTiling] = dummy.Handle; + + Ring = ring; + Dummy = dummy; + } + + internal VulkanGpuBuffer Ring { get; } + + internal VulkanGpuBuffer Dummy { get; } + + /// Points a storage binding at a range, re-writing the descriptor only when the BUFFER changes. + internal void SetStorage(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, GpuBindingModel.StorageBindingCount); + if (_storageBuffers[binding].Handle != buffer.Handle.Handle) + { + _storageBuffers[binding] = buffer.Handle; + WriteStorage(binding, buffer.Handle, ClampRange(buffer, sizeBytes)); + } + + _storageOffsets[binding] = offsetBytes; + } + + internal void SetUniform(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, (uint)UniformBindingCount); + if (_uniformBuffers[binding].Handle != buffer.Handle.Handle) + { + _uniformBuffers[binding] = buffer.Handle; + WriteUniform(binding, buffer.Handle, Math.Min(ClampRange(buffer, sizeBytes), 65536)); + } + + _uniformOffsets[binding] = offsetBytes; + } + + /// Binds all three sets with the current dynamic offsets. + internal void Bind(CommandBuffer commands, VulkanGpuDevice device) + { + DescriptorSet* sets = stackalloc DescriptorSet[3]; + sets[0] = _storageSet; + sets[1] = _uniformSet; + sets[2] = device.TextureTable.Set; + + int dynamicCount = _storageOffsets.Length + 2; + uint* offsets = stackalloc uint[dynamicCount]; + for (int i = 0; i < _storageOffsets.Length; i++) + offsets[i] = _storageOffsets[i]; + // Dynamic offsets are ordered by set, then by binding number. + offsets[_storageOffsets.Length + 0] = _uniformOffsets[GpuBindingModel.UniformSceneLighting]; + offsets[_storageOffsets.Length + 1] = _uniformOffsets[GpuBindingModel.UniformTerrainTiling]; + + _vk.CmdBindDescriptorSets( + commands, + PipelineBindPoint.Graphics, + device.Layouts.PipelineLayout, + 0, + 3, + sets, + (uint)dynamicCount, + offsets); + } + + private static uint ClampRange(VulkanGpuBuffer buffer, uint requested) + { + uint available = (uint)Math.Min(buffer.SizeBytes, MaxBindingRangeBytes); + return requested == 0 ? available : Math.Min(Math.Max(requested, 16), available); + } + + private DescriptorSet Allocate(DescriptorSetLayout layout) + { + DescriptorSetLayout handle = layout; + var allocate = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pool, + DescriptorSetCount = 1, + PSetLayouts = &handle, + }; + VulkanInterop.Check( + _vk.AllocateDescriptorSets(_device, &allocate, out DescriptorSet set), + "vkAllocateDescriptorSets (frame bindings)"); + return set; + } + + private void WriteStorage(uint binding, Silk.NET.Vulkan.Buffer buffer, uint rangeBytes) + { + var info = new DescriptorBufferInfo + { + Buffer = buffer, + Offset = 0, + Range = rangeBytes, + }; + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = _storageSet, + DstBinding = binding, + DescriptorCount = 1, + DescriptorType = DescriptorType.StorageBufferDynamic, + PBufferInfo = &info, + }; + _vk.UpdateDescriptorSets(_device, 1, &write, 0, null); + } + + private void WriteUniform(uint binding, Silk.NET.Vulkan.Buffer buffer, uint rangeBytes) + { + var info = new DescriptorBufferInfo + { + Buffer = buffer, + Offset = 0, + Range = rangeBytes, + }; + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = _uniformSet, + DstBinding = binding, + DescriptorCount = 1, + DescriptorType = DescriptorType.UniformBufferDynamic, + PBufferInfo = &info, + }; + _vk.UpdateDescriptorSets(_device, 1, &write, 0, null); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_pool.Handle != 0) + _vk.DestroyDescriptorPool(_device, _pool, null); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index d14ccf6e..18874f4e 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Silk.NET.Vulkan; namespace AcDream.App.Rendering.Gpu.Vk; @@ -8,11 +9,9 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// /// Split into its own file because the three V6 commits divide along /// exactly this line: V6a landed memory, buffers, rings and the frame timeline — -/// everything in VulkanGpuDevice.cs — V6b lands textures, samplers, the -/// descriptor table and render targets here, and pipelines, passes and readback -/// arrive at V6c. Until each lands, the corresponding contract member throws -/// with the slice named, rather than returning something that would fail later -/// and further away. +/// everything in VulkanGpuDevice.cs — V6b landed textures, samplers, the +/// descriptor table and render targets, and V6c completes it with pipelines from +/// committed SPIR-V, dynamic-rendering passes, timestamps and readback. /// internal sealed unsafe partial class VulkanGpuDevice { @@ -20,19 +19,34 @@ internal sealed unsafe partial class VulkanGpuDevice private VulkanTextureTable? _textureTable; private VulkanBackbufferAttachments? _backbufferAttachments; private VulkanGpuTexture? _defaultTexture; + private VulkanPipelineCache? _pipelineCache; + private VulkanGpuTimerPool? _timerPool; + private VulkanGpuBuffer? _bindingDummy; + private VulkanFrameBindings[] _frameBindings = []; private readonly Dictionary _samplers = []; + private readonly Dictionary _shaderModules = []; + private string _shaderSpirvDirectory = string.Empty; private float _maxSamplerAnisotropy = 1f; + private VulkanGpuPassEncoder? _openPass; + private bool _openPassIsBackbuffer; + private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory) { - _ = shaderSpirvDirectory; - _ = pipelineCacheDirectory; + _shaderSpirvDirectory = shaderSpirvDirectory ?? string.Empty; _vk.GetPhysicalDeviceProperties(_physicalDevice, out PhysicalDeviceProperties properties); _maxSamplerAnisotropy = properties.Limits.MaxSamplerAnisotropy; _layouts = VulkanPipelineLayouts.Create(_vk, _device); + _pipelineCache = new VulkanPipelineCache(_vk, _physicalDevice, _device, pipelineCacheDirectory); + _timerPool = new VulkanGpuTimerPool( + _vk, + _physicalDevice, + _device, + _flights.SlotCount, + Capabilities.SupportsTimestampQueries); _textureTable = new VulkanTextureTable( _vk, _device, @@ -59,7 +73,7 @@ internal sealed unsafe partial class VulkanGpuDevice _debugNames, new GpuTextureDescription( "vk-default-white", - GpuTextureKind.Texture2D, + GpuTextureKind.Texture2DArray, GpuTextureFormat.Rgba8Unorm, Width: 1, Height: 1, @@ -70,27 +84,79 @@ internal sealed unsafe partial class VulkanGpuDevice var defaultSampler = (VulkanGpuSampler)CreateSampler(GpuSamplerDescription.UiNearest); _textureTable.SetScrubTarget(_defaultTexture.View, defaultSampler.Handle); DefaultTextureSlot = _textureTable.Register(_defaultTexture.View, defaultSampler.Handle); + + // One dummy range every unused binding points at, so there is a single + // descriptor set layout rather than a permutation per renderer. + _bindingDummy = new VulkanGpuBuffer( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + new GpuBufferDescription( + "vk-binding-dummy", + 65536, + GpuBufferUsage.Storage | GpuBufferUsage.Uniform, + GpuMemoryResidency.HostWritable)); + + _frameBindings = new VulkanFrameBindings[_flights.SlotCount]; + for (int slot = 0; slot < _flights.SlotCount; slot++) + { + _frameBindings[slot] = new VulkanFrameBindings( + _vk, + _device, + _layouts, + _ringBuffers[slot], + _bindingDummy); + } } - private void BeginFrameResources(int slotIndex) => _ = slotIndex; + private void BeginFrameResources(int slotIndex) => _timerPool?.BeginSlot(slotIndex); private void EndFrameResources(int slotIndex, CommandBuffer commands) { _ = slotIndex; _ = commands; + if (_openPass is not null) + { + throw new InvalidOperationException( + "A pass is still open at frame end. Dispose the encoder before ending the frame — " + + "a dynamic-rendering block left open makes the whole command buffer invalid."); + } } private void DisposeResources() { + foreach (VulkanFrameBindings bindings in _frameBindings) + bindings.Dispose(); + _frameBindings = []; + + foreach ((ShaderModule vertex, ShaderModule fragment) in _shaderModules.Values) + { + if (vertex.Handle != 0) + _vk.DestroyShaderModule(_device, vertex, null); + if (fragment.Handle != 0) + _vk.DestroyShaderModule(_device, fragment, null); + } + + _shaderModules.Clear(); + foreach (VulkanGpuSampler sampler in _samplers.Values) sampler.Dispose(); _samplers.Clear(); + _bindingDummy?.Dispose(); + _bindingDummy = null; _defaultTexture?.Dispose(); _defaultTexture = null; _flights.DrainAll(); + _timerPool?.Dispose(); + _timerPool = null; + _pipelineCache?.Dispose(); + _pipelineCache = null; _backbufferAttachments?.Dispose(); _backbufferAttachments = null; _textureTable?.Dispose(); @@ -111,8 +177,16 @@ internal sealed unsafe partial class VulkanGpuDevice internal VulkanBackbufferAttachments BackbufferAttachments => _backbufferAttachments ?? throw new InvalidOperationException("The backbuffer attachments have not been created."); + internal VulkanGpuTimerPool TimerPool => + _timerPool ?? throw new InvalidOperationException("The device's timer pool has not been created."); + + /// True when a compatible pipeline cache blob was reused from disk. + internal bool PipelineCacheLoadedFromDisk => _pipelineCache?.LoadedFromDisk ?? false; + public GpuTextureSlot DefaultTextureSlot { get; private set; } = GpuTextureSlot.Unassigned; + public IGpuTimerPool Timers => TimerPool; + /// /// Matches the backbuffer pass's attachments to the swapchain's current /// extent and the requested sample count. Called by the host after a @@ -192,17 +266,524 @@ internal sealed unsafe partial class VulkanGpuDevice _flights.Retire(() => table.ReleaseNow(slot)); } - public IGpuTimerPool Timers => throw NotYet("GPU timer scopes", "V6c"); + /// + /// Builds a pipeline from the committed SPIR-V for + /// . There is no runtime GLSL compilation and + /// no lazy build: plan §4.5 has every pipeline created at startup, so no + /// frame ever pays a shader compile or a driver state revalidation. + /// + public IGpuPipeline CreatePipeline(GpuPipelineDescription description) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(description); - public IGpuPipeline CreatePipeline(GpuPipelineDescription description) => - throw NotYet($"pipelines ('{description?.Name}')", "V6c"); + (ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name); + // One colour format for every pipeline; see + // VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for why the + // offscreen targets adopt the swapchain`s format rather than the other + // way round. + Format colorFormat = VulkanTextureFormatMapping.CanonicalColorAttachmentFormat; + return new VulkanGpuPipeline( + _vk, + _device, + _flights, + _debugNames, + Layouts.PipelineLayout, + _pipelineCache?.Handle ?? default, + vertex, + fragment, + description, + colorFormat, + DepthStencilFormat); + } - internal IGpuPassEncoder BeginPass(VulkanGpuFrame frame, GpuPassDescription description) => - throw NotYet($"render passes ('{description.Name}')", "V6c"); + private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name) + { + if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing)) + return existing; - public byte[] CaptureBackbuffer(int width, int height) => - throw NotYet("backbuffer capture", "V6c"); + ShaderModule vertex = CreateShaderModule(name, "vert"); + ShaderModule fragment = CreateShaderModule(name, "frag"); + _shaderModules[name] = (vertex, fragment); + return (vertex, fragment); + } - private static NotSupportedException NotYet(string what, string slice) => - new($"The Vulkan backend does not implement {what} yet; it lands at Campaign V slice {slice}."); + private ShaderModule CreateShaderModule(string name, string stage) + { + string path = Path.Combine(_shaderSpirvDirectory, $"{name}.{stage}.spv"); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"No committed SPIR-V for '{name}.{stage}'. Run tools/compile-shaders.ps1; if that " + + "reports the shader as not yet Vulkan-expressible, its renderer-port slice has not " + + "landed and no Vulkan pipeline can be built from it.", + path); + } + + byte[] code = File.ReadAllBytes(path); + if (code.Length % 4 != 0) + throw new InvalidDataException($"'{path}' is {code.Length} bytes, which is not a whole number of SPIR-V words."); + + fixed (byte* first = code) + { + var create = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)code.Length, + PCode = (uint*)first, + }; + VulkanInterop.Check( + _vk.CreateShaderModule(_device, &create, null, out ShaderModule module), + $"vkCreateShaderModule ('{name}.{stage}')"); + return module; + } + } + + /// + /// Applies the pipeline's default dynamic state. Called right after a bind + /// so the pipeline's declared cull/front-face/depth-write are in effect + /// unless a renderer overrides them, which is what makes those fields on + /// mean what they say even though the + /// state itself is dynamic. + /// + internal void CmdBindPipelineDefaults(CommandBuffer commands, GpuPipelineDescription description) + { + _vk.CmdSetCullMode(commands, VulkanViewportMapping.ToVulkan(description.Cull)); + _vk.CmdSetFrontFace(commands, VulkanViewportMapping.ToVulkan(description.FrontFace)); + _vk.CmdSetDepthWriteEnable(commands, description.Depth.Write); + } + + /// + /// Opens a dynamic-rendering block for . + /// + /// Plan §5.4: a null colour target is the acquired swapchain image, + /// literally — or the multisampled scratch that resolves into it. There is no + /// ambient framebuffer for it to inherit, and this backend never pretends + /// otherwise even while the GL backend still carries its transitional + /// inheritance. + /// + internal IGpuPassEncoder BeginPass(VulkanGpuFrame frame, GpuPassDescription description) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(description); + if (_openPass is not null) + throw new InvalidOperationException("A pass is already open; dispose its encoder first."); + + CommandBuffer commands = _commandBuffers[frame.SlotIndex]; + + // Transfers cannot be recorded inside a rendering block, and anything + // queued so far may be read by this pass's draws. This is the analogue + // of the GL backend's flush-immediately-before-every-draw discipline at + // the granularity Vulkan actually permits. + _uploads.Record(commands); + + _debugNames.BeginLabel(commands, description.Name); + + uint width; + uint height; + ImageView colorView; + ImageView resolveView = default; + ImageView depthView = default; + bool backbuffer = description.Color.Target is null; + + if (backbuffer) + { + if (_backbuffer is null || _acquiredImageIndex is not { } imageIndex) + { + throw new InvalidOperationException( + "A pass declared Target: null, which the Vulkan backend honours literally as the " + + "swapchain image, but this device has no backbuffer or none was acquired for this frame."); + } + + VulkanBackbufferAttachments attachments = BackbufferAttachments; + width = _backbuffer.Width; + height = _backbuffer.Height; + TransitionBackbufferForRendering(commands, _backbuffer.ImageAt(imageIndex)); + + if (attachments.HasMultisampledColor && description.SampleCount > 1) + { + colorView = attachments.ColorView; + resolveView = _backbuffer.ViewAt(imageIndex); + } + else + { + colorView = _backbuffer.ViewAt(imageIndex); + } + + if (description.Depth is not null && attachments.HasDepth) + depthView = attachments.DepthView; + } + else + { + if (description.Color.Target is not VulkanGpuRenderTarget target) + throw new ArgumentException("The Vulkan backend can only render into a Vulkan render target."); + width = (uint)target.Description.Width; + height = (uint)target.Description.Height; + colorView = target.Color.View; + TransitionRenderTargetForRendering(commands, target); + if (description.Depth is not null && target.Depth is { } depth) + depthView = depth.View; + } + + Vector4 clear = description.Color.ClearColor; + var colorAttachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = colorView, + ImageLayout = ImageLayout.ColorAttachmentOptimal, + LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load), + StoreOp = description.Color.Store == GpuStoreOp.Resolve + ? AttachmentStoreOp.DontCare + : VulkanViewportMapping.ToVulkan(description.Color.Store), + ClearValue = new ClearValue + { + Color = new ClearColorValue + { + Float32_0 = clear.X, + Float32_1 = clear.Y, + Float32_2 = clear.Z, + Float32_3 = clear.W, + }, + }, + }; + if (resolveView.Handle != 0) + { + colorAttachment.ResolveMode = ResolveModeFlags.AverageBit; + colorAttachment.ResolveImageView = resolveView; + colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal; + } + + RenderingAttachmentInfo depthAttachment = default; + if (description.Depth is { } depthDescription && depthView.Handle != 0) + { + depthAttachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = depthView, + ImageLayout = ImageLayout.DepthStencilAttachmentOptimal, + LoadOp = VulkanViewportMapping.ToVulkan(depthDescription.Load), + StoreOp = VulkanViewportMapping.ToVulkan(depthDescription.Store), + ClearValue = new ClearValue + { + DepthStencil = new ClearDepthStencilValue( + depthDescription.ClearDepth, + depthDescription.ClearStencil), + }, + }; + } + + var rendering = new RenderingInfo + { + SType = StructureType.RenderingInfo, + RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)), + LayerCount = 1, + ColorAttachmentCount = 1, + PColorAttachments = &colorAttachment, + PDepthAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo + ? &depthAttachment + : null, + PStencilAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo + ? &depthAttachment + : null, + }; + _vk.CmdBeginRendering(commands, &rendering); + + _openPassIsBackbuffer = backbuffer; + var encoder = new VulkanGpuPassEncoder( + this, + frame, + commands, + _frameBindings[frame.SlotIndex], + description, + width, + height); + _openPass = encoder; + return encoder; + } + + internal void EndPass(VulkanGpuPassEncoder encoder) + { + if (!ReferenceEquals(_openPass, encoder)) + return; + + CommandBuffer commands = CurrentCommands; + _vk.CmdEndRendering(commands); + _debugNames.EndLabel(commands); + + if (!_openPassIsBackbuffer && encoder.Pass.Color.Target is VulkanGpuRenderTarget target) + TransitionRenderTargetForSampling(commands, target); + + _openPass = null; + } + + private void TransitionBackbufferForRendering(CommandBuffer commands, Image image) + { + if (_backbufferRenderingReady) + return; + _backbufferRenderingReady = true; + + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.TopOfPipeBit, + SrcAccessMask = AccessFlags2.None, + DstStageMask = PipelineStageFlags2.ColorAttachmentOutputBit, + DstAccessMask = AccessFlags2.ColorAttachmentWriteBit, + OldLayout = ImageLayout.Undefined, + NewLayout = ImageLayout.ColorAttachmentOptimal, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + private void TransitionRenderTargetForRendering(CommandBuffer commands, VulkanGpuRenderTarget target) + { + TransitionImage( + commands, + target.Color.Image, + ImageAspectFlags.ColorBit, + target.Color.CurrentLayout, + ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit); + target.Color.MarkLayout(ImageLayout.ColorAttachmentOptimal); + + if (target.Depth is { } depth) + { + TransitionImage( + commands, + depth.Image, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + depth.CurrentLayout, + ImageLayout.DepthStencilAttachmentOptimal, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None, + PipelineStageFlags2.EarlyFragmentTestsBit, + AccessFlags2.DepthStencilAttachmentWriteBit); + depth.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal); + } + } + + private void TransitionRenderTargetForSampling(CommandBuffer commands, VulkanGpuRenderTarget target) + { + TransitionImage( + commands, + target.Color.Image, + ImageAspectFlags.ColorBit, + ImageLayout.ColorAttachmentOptimal, + ImageLayout.ShaderReadOnlyOptimal, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentWriteBit, + PipelineStageFlags2.FragmentShaderBit, + AccessFlags2.ShaderReadBit); + target.Color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal); + } + + private void TransitionImage( + CommandBuffer commands, + Image image, + ImageAspectFlags aspect, + 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 = new ImageSubresourceRange + { + AspectMask = aspect, + BaseMipLevel = 0, + LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, + BaseArrayLayer = 0, + LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers, + }, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _vk.CmdPipelineBarrier2(commands, &dependency); + } + + /// + /// Reads the presented image back as tightly packed top-left-origin RGBA8. + /// + /// The swapchain is B8G8R8A8_UNORM (plan §4.9), so the channels + /// are swizzled on the CPU to preserve FrameScreenshotController's + /// RGBA byte contract — the same seam every automated screenshot gate already + /// uses, so the comparison tooling is unaffected by the backend swap. + /// + public byte[] CaptureBackbuffer(int width, int height) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + if (_backbuffer is null) + throw new InvalidOperationException("This device has no backbuffer to capture."); + + return CaptureImage(_backbuffer.ImageAt(_lastPresentedImageIndex), (uint)width, (uint)height); + } + + private uint _lastPresentedImageIndex; + private bool _backbufferRenderingReady; + + private byte[] CaptureImage(Image image, uint width, uint height) + { + uint byteCount = width * height * 4; + VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (capture)"); + + var readback = new VulkanGpuBuffer( + _vk, + _device, + _allocator, + _uploads, + ImmediateGpuResourceRetirementQueue.Instance, + _debugNames, + new GpuBufferDescription( + "vk-backbuffer-capture", + byteCount, + GpuBufferUsage.TransferDestination, + GpuMemoryResidency.HostReadable)); + CommandPool pool = default; + try + { + var poolCreate = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = _graphicsFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + VulkanInterop.Check( + _vk.CreateCommandPool(_device, &poolCreate, null, out pool), + "vkCreateCommandPool (capture)"); + var allocate = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = pool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + VulkanInterop.Check( + _vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands), + "vkAllocateCommandBuffers (capture)"); + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanInterop.Check(_vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer (capture)"); + + TransitionImage( + commands, + image, + ImageAspectFlags.ColorBit, + 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, + readback.Handle, + 1, + ®ion); + + TransitionImage( + commands, + image, + ImageAspectFlags.ColorBit, + ImageLayout.TransferSrcOptimal, + ImageLayout.PresentSrcKhr, + PipelineStageFlags2.CopyBit, + AccessFlags2.TransferReadBit, + PipelineStageFlags2.AllCommandsBit, + AccessFlags2.None); + + VulkanInterop.Check(_vk.EndCommandBuffer(commands), "vkEndCommandBuffer (capture)"); + + 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 (capture)"); + VulkanInterop.Check(_vk.QueueWaitIdle(_graphicsQueue), "vkQueueWaitIdle (capture)"); + + var pixels = new byte[byteCount]; + readback.Read(0, pixels); + // ToRgba, NOT ToGlOriginRgba: IGpuDevice.CaptureBackbuffer is + // documented as top-left-origin, and a Vulkan image already is. + // (VulkanSwapchain.CaptureImage feeds FrameScreenshotController + // instead, which flips again on the way to the PNG, so THAT path + // flips here to cancel. Two consumers, two conventions, one + // difference — worth stating because a single wrong choice produces + // a perfectly plausible upside-down screenshot.) + return VulkanBackbufferSwizzle.ToRgba(pixels, (int)width, (int)height, (int)width * 4); + } + finally + { + if (pool.Handle != 0) + _vk.DestroyCommandPool(_device, pool, null); + readback.Dispose(); + } + } } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs index 1ddaee37..5d392341 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs @@ -345,6 +345,8 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice _vk.BeginCommandBuffer(_commandBuffers[slot], &begin), "vkBeginCommandBuffer (frame)"); + _backbufferRenderingReady = false; + BeginFrameResources(slot); var opened = new VulkanGpuFrame(this, slot, serial); @@ -459,6 +461,7 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice if (_acquiredImageIndex is { } toPresent && _backbuffer is not null) { + _lastPresentedImageIndex = toPresent; PresentSucceeded = _backbuffer.Present(toPresent); _acquiredImageIndex = null; } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs new file mode 100644 index 00000000..8a041beb --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -0,0 +1,220 @@ +using System.Runtime.CompilerServices; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c: on Vulkan. +/// +/// Almost everything here is one Vulkan call, which is the point — the +/// contract was shaped around what Vulkan wants, so the GL backend does the +/// translating and this one mostly forwards. The three places worth reading are +/// the viewport (negative height, see +/// ), the descriptor binding (three sets, +/// bound once, never rewritten per draw) and the ring-backed storage bindings. +/// +/// +/// Storage and uniform bindings go through a dynamic descriptor +/// set. The contract lets a renderer bind an arbitrary buffer range per +/// draw, and ring allocations mean that range moves every frame. Rather than +/// writing descriptors mid-frame, set 0 and set 1 are allocated per flight slot +/// with DYNAMIC descriptor types and the per-draw offset is supplied at bind +/// time — which is what keeps the campaign's "zero descriptor writes per frame" +/// property true for buffers as well as for textures. +/// +internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder +{ + private readonly VulkanGpuDevice _device; + private readonly VulkanGpuFrame _frame; + private readonly CommandBuffer _commands; + private readonly VulkanFrameBindings _bindings; + private readonly uint _attachmentHeight; + + private VulkanGpuPipeline? _pipeline; + private bool _closed; + + internal VulkanGpuPassEncoder( + VulkanGpuDevice device, + VulkanGpuFrame frame, + CommandBuffer commands, + VulkanFrameBindings bindings, + GpuPassDescription pass, + uint attachmentWidth, + uint attachmentHeight) + { + _device = device; + _frame = frame; + _commands = commands; + _bindings = bindings; + _attachmentHeight = attachmentHeight; + Pass = pass; + + // A pass always starts with the whole attachment drawable. GL's + // BeginPass deliberately does not touch viewport or scissor because a + // raw-GL renderer may have set them; Vulkan has no such ambient state, + // and a pipeline with dynamic viewport MUST have one set before any + // draw, so the full-attachment default is the only safe starting point. + SetViewport(0, 0, (int)attachmentWidth, (int)attachmentHeight); + SetScissor(0, 0, (int)attachmentWidth, (int)attachmentHeight); + } + + public GpuPassDescription Pass { get; } + + public void BindPipeline(IGpuPipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + ThrowIfClosed(); + if (pipeline is not VulkanGpuPipeline vulkanPipeline) + throw new ArgumentException("The Vulkan backend can only bind a Vulkan pipeline.", nameof(pipeline)); + + _pipeline = vulkanPipeline; + _device.Api.CmdBindPipeline(_commands, PipelineBindPoint.Graphics, vulkanPipeline.Handle); + + // Every pipeline shares one layout, so the descriptor sets and push + // constants bound earlier in the pass survive this call. That is the + // whole reason for the shared layout, and it is why a bucketed world + // pass can change pipeline per bucket for free. + _device.CmdBindPipelineDefaults(_commands, vulkanPipeline.Description); + } + + public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ThrowIfClosed(); + _bindings.SetStorage(binding, RequireBuffer(buffer), offsetBytes, sizeBytes); + _bindings.Bind(_commands, _device); + } + + public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ThrowIfClosed(); + _bindings.SetUniform(binding, RequireBuffer(buffer), offsetBytes, sizeBytes); + _bindings.Bind(_commands, _device); + } + + public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + { + ThrowIfClosed(); + Silk.NET.Vulkan.Buffer handle = RequireBuffer(buffer).Handle; + ulong offset = offsetBytes; + _device.Api.CmdBindVertexBuffers(_commands, 0, 1, &handle, &offset); + } + + public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) + { + ThrowIfClosed(); + _device.Api.CmdBindIndexBuffer( + _commands, + RequireBuffer(buffer).Handle, + offsetBytes, + VulkanViewportMapping.ToVulkan(indexType)); + } + + public void SetPushConstants(in GpuPushConstants constants) + { + ThrowIfClosed(); + fixed (GpuPushConstants* pointer = &constants) + { + _device.Api.CmdPushConstants( + _commands, + _device.Layouts.PipelineLayout, + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + (uint)GpuBindingModel.PushConstantBytes, + pointer); + } + } + + public void SetViewport(int x, int y, int width, int height) + { + ThrowIfClosed(); + Viewport viewport = VulkanViewportMapping.ToVulkan(x, y, width, height, _attachmentHeight); + _device.Api.CmdSetViewport(_commands, 0, 1, &viewport); + } + + public void SetScissor(int x, int y, int width, int height) + { + ThrowIfClosed(); + Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(x, y, width, height, _attachmentHeight); + _device.Api.CmdSetScissor(_commands, 0, 1, &scissor); + } + + public void SetCullMode(GpuCullMode cullMode) + { + ThrowIfClosed(); + _device.Api.CmdSetCullMode(_commands, VulkanViewportMapping.ToVulkan(cullMode)); + } + + public void SetFrontFace(GpuFrontFace frontFace) + { + ThrowIfClosed(); + _device.Api.CmdSetFrontFace(_commands, VulkanViewportMapping.ToVulkan(frontFace)); + } + + public void SetDepthWrite(bool enabled) + { + ThrowIfClosed(); + _device.Api.CmdSetDepthWriteEnable(_commands, enabled); + } + + public void DrawIndexed( + uint indexCount, + uint instanceCount, + uint firstIndex, + int vertexOffset, + uint firstInstance) + { + ThrowIfClosed(); + RequirePipeline(); + _device.Api.CmdDrawIndexed(_commands, indexCount, instanceCount, firstIndex, vertexOffset, firstInstance); + } + + public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) + { + ThrowIfClosed(); + RequirePipeline(); + _device.Api.CmdDraw(_commands, vertexCount, instanceCount, firstVertex, firstInstance); + } + + public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes) + { + ThrowIfClosed(); + RequirePipeline(); + if (drawCount == 0) + return; + _device.Api.CmdDrawIndexedIndirect( + _commands, + RequireBuffer(commands).Handle, + offsetBytes, + drawCount, + strideBytes); + } + + public IDisposable BeginTimerScope(string scopeName) => + _device.TimerPool.BeginScope(_commands, scopeName); + + public void Dispose() + { + if (_closed) + return; + _closed = true; + _device.EndPass(this); + _frame.ClosePass(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static VulkanGpuBuffer RequireBuffer(IGpuBuffer buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + if (buffer is not VulkanGpuBuffer vulkanBuffer) + throw new ArgumentException("The Vulkan backend can only bind Vulkan buffers.", nameof(buffer)); + return vulkanBuffer; + } + + private void RequirePipeline() + { + if (_pipeline is null) + throw new InvalidOperationException("BindPipeline must be called before drawing."); + } + + private void ThrowIfClosed() => ObjectDisposedException.ThrowIf(_closed, this); +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs new file mode 100644 index 00000000..f2ed1bf3 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs @@ -0,0 +1,401 @@ +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c, plan §4.5: on Vulkan. +/// +/// Core 1.3 dynamic state covers viewport, scissor, cull mode, front face, +/// depth test/write/compare and topology class, which folds the GL pass matrix's +/// per-draw toggles into command-time calls. Blend and alpha-to-coverage are +/// not dynamic, so they are what actually define the pipeline list — +/// roughly a dozen objects, all known statically and all built at startup. +/// +/// No render pass or framebuffer object appears anywhere: the attachment +/// formats are declared inline through VK_KHR_dynamic_rendering, which is +/// core in 1.3. That is what lets a pass be described by +/// alone rather than by an object that has to be +/// created, cached and matched. +/// +internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly IGpuResourceRetirementQueue _retirement; + private bool _disposed; + + internal VulkanGpuPipeline( + Silk.NET.Vulkan.Vk vk, + Device device, + IGpuResourceRetirementQueue retirement, + VulkanDebugNames debugNames, + PipelineLayout layout, + PipelineCache cache, + ShaderModule vertexModule, + ShaderModule fragmentModule, + GpuPipelineDescription description, + Format colorFormat, + Format depthStencilFormat) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + + nint entryPoint = SilkMarshal.StringToPtr("main"); + try + { + PipelineShaderStageCreateInfo* stages = stackalloc PipelineShaderStageCreateInfo[2]; + stages[0] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.VertexBit, + Module = vertexModule, + PName = (byte*)entryPoint, + }; + stages[1] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.FragmentBit, + Module = fragmentModule, + PName = (byte*)entryPoint, + }; + + GpuVertexLayout vertexLayout = description.VertexLayout; + var binding = new VertexInputBindingDescription + { + Binding = 0, + Stride = vertexLayout.StrideBytes, + InputRate = VertexInputRate.Vertex, + }; + int attributeCount = vertexLayout.Attributes.Length; + VertexInputAttributeDescription* attributes = + stackalloc VertexInputAttributeDescription[Math.Max(1, attributeCount)]; + for (int i = 0; i < attributeCount; i++) + { + GpuVertexAttribute attribute = vertexLayout.Attributes[i]; + attributes[i] = new VertexInputAttributeDescription + { + Location = attribute.Location, + Binding = 0, + Format = VulkanViewportMapping.ToVulkan(attribute.Format), + Offset = attribute.OffsetBytes, + }; + } + + var vertexInput = new PipelineVertexInputStateCreateInfo + { + SType = StructureType.PipelineVertexInputStateCreateInfo, + VertexBindingDescriptionCount = attributeCount == 0 ? 0u : 1u, + PVertexBindingDescriptions = attributeCount == 0 ? null : &binding, + VertexAttributeDescriptionCount = (uint)attributeCount, + PVertexAttributeDescriptions = attributeCount == 0 ? null : attributes, + }; + var assembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = VulkanViewportMapping.ToVulkan(description.Topology), + PrimitiveRestartEnable = false, + }; + var viewport = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, + ViewportCount = 1, + ScissorCount = 1, + }; + var rasterization = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = PolygonMode.Fill, + LineWidth = 1f, + CullMode = VulkanViewportMapping.ToVulkan(description.Cull), + // The single inversion that pairs with the negative viewport + // height. See VulkanViewportMapping. + FrontFace = VulkanViewportMapping.ToVulkan(description.FrontFace), + DepthClampEnable = false, + RasterizerDiscardEnable = false, + DepthBiasEnable = false, + }; + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = VulkanTextureFormatMapping.SampleCountOf(description.SampleCount), + SampleShadingEnable = false, + // Alpha-to-coverage is only meaningful multisampled; the backend + // ignores it at one sample exactly as the contract says. + AlphaToCoverageEnable = description.AlphaToCoverage && description.SampleCount > 1, + }; + var depthStencil = new PipelineDepthStencilStateCreateInfo + { + SType = StructureType.PipelineDepthStencilStateCreateInfo, + DepthTestEnable = description.Depth.Test, + DepthWriteEnable = description.Depth.Write, + DepthCompareOp = VulkanViewportMapping.ToVulkan(description.Depth.Compare), + DepthBoundsTestEnable = false, + StencilTestEnable = false, + }; + + (BlendFactor source, BlendFactor destination) = + VulkanViewportMapping.BlendFactorsOf(description.Blend); + var attachment = new PipelineColorBlendAttachmentState + { + BlendEnable = description.Blend != GpuBlendMode.None, + SrcColorBlendFactor = source, + DstColorBlendFactor = destination, + ColorBlendOp = BlendOp.Add, + // Alpha follows colour, matching glBlendFunc's single-function + // form which is all the GL pass matrix ever sets. + SrcAlphaBlendFactor = source, + DstAlphaBlendFactor = destination, + AlphaBlendOp = BlendOp.Add, + ColorWriteMask = description.ColorWrite + ? ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit + : 0, + }; + var blend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, + LogicOpEnable = false, + AttachmentCount = 1, + PAttachments = &attachment, + }; + + DynamicState* dynamicStates = stackalloc DynamicState[5]; + dynamicStates[0] = DynamicState.Viewport; + dynamicStates[1] = DynamicState.Scissor; + dynamicStates[2] = DynamicState.CullMode; + dynamicStates[3] = DynamicState.FrontFace; + dynamicStates[4] = DynamicState.DepthWriteEnable; + var dynamic = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, + DynamicStateCount = 5, + PDynamicStates = dynamicStates, + }; + + Format color = colorFormat; + var rendering = new PipelineRenderingCreateInfo + { + SType = StructureType.PipelineRenderingCreateInfo, + ColorAttachmentCount = 1, + PColorAttachmentFormats = &color, + DepthAttachmentFormat = description.Depth.Test || description.Depth.Write + ? depthStencilFormat + : Format.Undefined, + StencilAttachmentFormat = description.Depth.Test || description.Depth.Write + ? depthStencilFormat + : Format.Undefined, + }; + + var create = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + PNext = &rendering, + StageCount = 2, + PStages = stages, + PVertexInputState = &vertexInput, + PInputAssemblyState = &assembly, + PViewportState = &viewport, + PRasterizationState = &rasterization, + PMultisampleState = &multisample, + PDepthStencilState = &depthStencil, + PColorBlendState = &blend, + PDynamicState = &dynamic, + Layout = layout, + // No RenderPass: dynamic rendering declares the formats inline. + RenderPass = default, + Subpass = 0, + }; + + VulkanInterop.Check( + _vk.CreateGraphicsPipelines(_device, cache, 1, &create, null, out Pipeline pipeline), + $"vkCreateGraphicsPipelines ('{description.Name}')"); + Handle = pipeline; + debugNames.NamePipeline(pipeline, description.Name); + } + finally + { + SilkMarshal.Free(entryPoint); + } + } + + public GpuPipelineDescription Description { get; } + + internal Pipeline Handle { get; } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Pipeline handle = Handle; + _retirement.Retire(() => _vk.DestroyPipeline(_device, handle, null)); + } +} + +/// +/// Campaign V slice V6c, plan §4.5: the persisted VkPipelineCache. +/// +/// Every pipeline is built at startup, which on a cold cache costs a few +/// hundred milliseconds once. Persisting the cache to +/// ApplicationPathSet.CacheDirectory turns every later launch into +/// milliseconds — and, unlike GL, no frame ever pays a hidden first-draw driver +/// recompile. +/// +/// The blob is validated by its header before use: a driver update, a GPU +/// change or a truncated write must be treated as a cold cache rather than fed +/// to vkCreatePipelineCache. Drivers are required to ignore incompatible +/// data, but "required to" is a poor foundation for something that runs before +/// anything else in the process, and checking the vendor/device/UUID ourselves +/// costs 32 bytes of comparison. +/// +internal sealed unsafe class VulkanPipelineCache : IDisposable +{ + private const uint HeaderLengthBytes = 32; + private const uint HeaderVersionOne = 1; + + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly string? _path; + private bool _disposed; + + internal VulkanPipelineCache( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + string? cacheDirectory) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + + vk.GetPhysicalDeviceProperties(physicalDevice, out PhysicalDeviceProperties properties); + byte[] pipelineCacheUuid = new byte[16]; + for (int i = 0; i < 16; i++) + pipelineCacheUuid[i] = properties.PipelineCacheUuid[i]; + + byte[]? initial = null; + if (!string.IsNullOrWhiteSpace(cacheDirectory)) + { + _path = Path.Combine(cacheDirectory, "vulkan-pipeline-cache.bin"); + initial = TryReadCompatible(_path, properties.VendorID, properties.DeviceID, pipelineCacheUuid); + } + + LoadedFromDisk = initial is not null; + fixed (byte* data = initial) + { + var create = new PipelineCacheCreateInfo + { + SType = StructureType.PipelineCacheCreateInfo, + InitialDataSize = (nuint)(initial?.Length ?? 0), + PInitialData = initial is null ? null : data, + }; + VulkanInterop.Check( + _vk.CreatePipelineCache(_device, &create, null, out PipelineCache cache), + "vkCreatePipelineCache"); + Handle = cache; + } + } + + internal PipelineCache Handle { get; } + + /// True when a compatible cache blob was found and reused. + internal bool LoadedFromDisk { get; } + + /// + /// Validates a cache blob's 32-byte header against this device. Returns null + /// for anything that is not a byte-for-byte match, which is the honest + /// answer for a driver update as much as for a corrupt file. + /// + internal static byte[]? ValidateHeader( + byte[]? blob, + uint vendorId, + uint deviceId, + ReadOnlySpan pipelineCacheUuid) + { + if (blob is null || blob.Length < HeaderLengthBytes) + return null; + + uint length = BitConverter.ToUInt32(blob, 0); + uint version = BitConverter.ToUInt32(blob, 4); + uint blobVendor = BitConverter.ToUInt32(blob, 8); + uint blobDevice = BitConverter.ToUInt32(blob, 12); + if (length != HeaderLengthBytes || version != HeaderVersionOne) + return null; + if (blobVendor != vendorId || blobDevice != deviceId) + return null; + if (!blob.AsSpan(16, 16).SequenceEqual(pipelineCacheUuid)) + return null; + + return blob; + } + + private static byte[]? TryReadCompatible( + string path, + uint vendorId, + uint deviceId, + ReadOnlySpan pipelineCacheUuid) + { + try + { + if (!File.Exists(path)) + return null; + return ValidateHeader(File.ReadAllBytes(path), vendorId, deviceId, pipelineCacheUuid); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Writes the cache back. Failures are swallowed with intent: a cache that + /// cannot be saved costs a few hundred milliseconds at the next launch and + /// nothing else, so it must never take the process down. + /// + internal void Save() + { + if (_disposed || _path is null) + return; + + try + { + nuint size = 0; + if (_vk.GetPipelineCacheData(_device, Handle, ref size, null) != Result.Success || size == 0) + return; + + var data = new byte[(int)size]; + fixed (byte* first = data) + { + if (_vk.GetPipelineCacheData(_device, Handle, ref size, first) != Result.Success) + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + string temporary = _path + ".tmp"; + File.WriteAllBytes(temporary, data); + File.Move(temporary, _path, overwrite: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + public void Dispose() + { + if (_disposed) + return; + Save(); + _disposed = true; + if (Handle.Handle != 0) + _vk.DestroyPipelineCache(_device, Handle, null); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs new file mode 100644 index 00000000..abe24bd4 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs @@ -0,0 +1,210 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c: on Vulkan timestamps. +/// +/// Two timestamps bracket each named scope, written into a per-flight-slot +/// query pool. Results are read only after the frame that issued them has +/// retired, so reports the most recent completed +/// measurement and never blocks — the same contract the GL pool honours, and the +/// property the campaign's own debugging record says matters most: an instrument +/// that stalls the pipeline reports on a pipeline that no longer exists. +/// +/// hostQueryReset is why the pool is reset from the CPU rather than +/// with vkCmdResetQueryPool: the reset is free and costs no command-buffer +/// space in a frame that measures nothing. +/// +/// Unlike GL's TIME_ELAPSED query, Vulkan timestamps do not nest or +/// conflict, so multiple scopes per pass are legal here. The pool still refuses +/// to reuse a scope name within one frame, because two ranges sharing a name +/// would silently report whichever finished last. +/// +internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable +{ + /// Distinct named scopes measurable per frame. Two queries each. + internal const int MaxScopesPerFrame = 16; + + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly double _timestampPeriodNanoseconds; + private readonly QueryPool[] _pools; + private readonly List[] _scopeNames; + private readonly Dictionary _resolved = new(StringComparer.Ordinal); + + private int _currentSlot; + private bool _disposed; + + internal VulkanGpuTimerPool( + Silk.NET.Vulkan.Vk vk, + PhysicalDevice physicalDevice, + Device device, + int flightCount, + bool isSupported) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + IsSupported = isSupported; + + vk.GetPhysicalDeviceProperties(physicalDevice, out PhysicalDeviceProperties properties); + _timestampPeriodNanoseconds = properties.Limits.TimestampPeriod; + + _pools = new QueryPool[flightCount]; + _scopeNames = new List[flightCount]; + for (int slot = 0; slot < flightCount; slot++) + { + _scopeNames[slot] = []; + if (!isSupported) + continue; + + var create = new QueryPoolCreateInfo + { + SType = StructureType.QueryPoolCreateInfo, + QueryType = QueryType.Timestamp, + QueryCount = MaxScopesPerFrame * 2, + }; + VulkanInterop.Check( + _vk.CreateQueryPool(_device, &create, null, out QueryPool pool), + "vkCreateQueryPool (timer pool)"); + _pools[slot] = pool; + } + } + + public bool IsSupported { get; } + + /// + /// Reads back the scopes recorded into the last + /// time it was used and clears it for reuse. Called at frame start, after + /// the timeline has proved that frame complete. + /// + internal void BeginSlot(int slotIndex) + { + if (!IsSupported || _disposed) + return; + + _currentSlot = slotIndex; + List names = _scopeNames[slotIndex]; + if (names.Count > 0) + { + Resolve(slotIndex, names); + names.Clear(); + } + + // hostQueryReset: no command-buffer call, and it means an unmeasured + // frame costs literally nothing. + _vk.ResetQueryPool(_device, _pools[slotIndex], 0, MaxScopesPerFrame * 2); + } + + /// Opens a scope. Dispose the result to write the closing timestamp. + internal IDisposable BeginScope(CommandBuffer commands, string scopeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scopeName); + if (!IsSupported || _disposed) + return NullScope.Instance; + + List names = _scopeNames[_currentSlot]; + if (names.Count >= MaxScopesPerFrame) + return NullScope.Instance; + if (names.Contains(scopeName, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"GPU timer scope '{scopeName}' has already been measured this frame. Two ranges " + + "sharing a name would silently report whichever finished last."); + } + + int index = names.Count; + names.Add(scopeName); + _vk.CmdWriteTimestamp2( + commands, + PipelineStageFlags2.TopOfPipeBit, + _pools[_currentSlot], + (uint)(index * 2)); + return new ActiveScope(this, commands, _currentSlot, index); + } + + private void EndScope(CommandBuffer commands, int slotIndex, int index) + { + if (!IsSupported || _disposed) + return; + _vk.CmdWriteTimestamp2( + commands, + PipelineStageFlags2.BottomOfPipeBit, + _pools[slotIndex], + (uint)((index * 2) + 1)); + } + + private void Resolve(int slotIndex, List names) + { + int queryCount = names.Count * 2; + Span results = stackalloc ulong[MaxScopesPerFrame * 2]; + fixed (ulong* first = results) + { + Result status = _vk.GetQueryPoolResults( + _device, + _pools[slotIndex], + 0, + (uint)queryCount, + (nuint)(queryCount * sizeof(ulong)), + first, + sizeof(ulong), + QueryResultFlags.Result64Bit); + // NotReady is normal and expected the first time a slot recurs on a + // fast GPU; the previous value simply stands. It is never worth a + // wait, which is the whole design. + if (status != Result.Success) + return; + } + + for (int i = 0; i < names.Count; i++) + { + ulong start = results[i * 2]; + ulong end = results[(i * 2) + 1]; + if (end <= start) + continue; + double nanoseconds = (end - start) * _timestampPeriodNanoseconds; + _resolved[names[i]] = nanoseconds / 1_000_000d; + } + } + + public bool TryResolve(string scopeName, out double milliseconds) => + _resolved.TryGetValue(scopeName, out milliseconds); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (QueryPool pool in _pools) + { + if (pool.Handle != 0) + _vk.DestroyQueryPool(_device, pool, null); + } + } + + private sealed class ActiveScope( + VulkanGpuTimerPool pool, + CommandBuffer commands, + int slotIndex, + int index) : IDisposable + { + private bool _ended; + + public void Dispose() + { + if (_ended) + return; + _ended = true; + pool.EndScope(commands, slotIndex, index); + } + } + + private sealed class NullScope : IDisposable + { + internal static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs index a29eb0cd..8bc862c8 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs @@ -81,7 +81,21 @@ internal static unsafe class VulkanPipelineLayouts } } - /// Set 0 — the ten storage bindings pins. + /// + /// Set 0 — the ten storage bindings pins. + /// + /// DYNAMIC storage buffers, because the RHI contract lets a renderer + /// bind an arbitrary range per draw and ring allocations move that range + /// every frame. A non-dynamic descriptor would have to be rewritten each + /// time, putting a vkUpdateDescriptorSets in the hot path — exactly the cost + /// the texture table was designed to remove. The dynamic offset travels in + /// vkCmdBindDescriptorSets instead, which is free. + /// + /// Ten dynamic storage descriptors is above Vulkan`s guaranteed + /// minimum of four, so this is a real requirement rather than a free choice. + /// It is asserted at layout creation, which fails loudly at startup on a + /// device that cannot serve it rather than at the first draw. + /// internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device) { int count = (int)GpuBindingModel.StorageBindingCount; @@ -91,7 +105,7 @@ internal static unsafe class VulkanPipelineLayouts bindings[i] = new DescriptorSetLayoutBinding { Binding = (uint)i, - DescriptorType = DescriptorType.StorageBuffer, + DescriptorType = DescriptorType.StorageBufferDynamic, DescriptorCount = 1, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, }; @@ -116,14 +130,14 @@ internal static unsafe class VulkanPipelineLayouts bindings[0] = new DescriptorSetLayoutBinding { Binding = GpuBindingModel.UniformSceneLighting, - DescriptorType = DescriptorType.UniformBuffer, + DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, }; bindings[1] = new DescriptorSetLayoutBinding { Binding = GpuBindingModel.UniformTerrainTiling, - DescriptorType = DescriptorType.UniformBuffer, + DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, }; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs new file mode 100644 index 00000000..1ee6d599 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRhiScene.cs @@ -0,0 +1,514 @@ +using System.Numerics; +using System.Runtime.InteropServices; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c: the scene that proves the Vulkan RHI end to end. +/// +/// Why this exists. V6's milestone is "a full game frame on +/// Vulkan", and on this branch that cannot be the game's own frame: V4c and V4d +/// are parked (plan §5.5.5) so the world renderers are still raw GL, and +/// TextRenderer/DebugLineRenderer — the two renderers that DO +/// speak the RHI — currently require a GlGpuDevice for their loose +/// uniforms and their classic texture-unit binding, and refuse any other +/// backend. So the only honest way to exercise the whole backend now is to drive +/// it through the pinned contract with a scene of our own. +/// +/// It is not a toy. Every verb the contract exposes is used the way a +/// renderer would use it: a device-local mesh arena filled through the staging +/// ring, per-frame instance and batch data written straight into mapped ring +/// memory, an offscreen render target whose colour is registered into the global +/// texture table and sampled by a later pass, block-compressed and uncompressed +/// textures with CPU-built and blit-built mip chains, multi-draw-indirect with +/// gl_DrawID selecting per-draw batch data, a second pipeline with +/// line-list topology bound mid-pass, dynamic cull/front-face/depth-write, push +/// constants, GPU timer scopes, and an MSAA colour attachment resolving into the +/// swapchain image. +/// +/// Deliberately asymmetric. The layout has a distinct marker in +/// each quadrant and nothing is mirror-symmetric in either axis, because the one +/// thing a uniform clear could never prove is that the negative-viewport Y flip +/// and the capture path agree. Slice V5's screenshot was uniform and its +/// orientation was right "by construction"; this one has to be right by +/// inspection, and a wrong flip is unmissable. +/// +internal sealed class VulkanRhiScene : IDisposable +{ + /// The quadrant marker colours, in the order the layout places them. + internal static readonly (string Corner, uint Rgba)[] QuadrantMarkers = + [ + ("top-left", 0xE04040FFu), + ("top-right", 0x40E040FFu), + ("bottom-left", 0x4060E0FFu), + ("bottom-right", 0xF0F0F0FFu), + ]; + + private const int OffscreenExtent = 128; + + private readonly VulkanGpuDevice _device; + private readonly IGpuBuffer _vertexArena; + private readonly IGpuBuffer _indexArena; + private readonly IGpuPipeline _meshPipeline; + private readonly IGpuPipeline _linePipeline; + private readonly IGpuRenderTarget _offscreen; + private readonly IGpuTexture _cardTexture; + private readonly IGpuTexture _compressedTexture; + private readonly List _owned = []; + + private readonly GpuTextureSlot _cardSlot; + private readonly GpuTextureSlot _compressedSlot; + private GpuTextureSlot _offscreenSlot = GpuTextureSlot.Unassigned; + + private readonly uint _quadIndexCount; + private readonly uint _lineVertexCount; + private readonly uint _lineFirstVertex; + + private bool _disposed; + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct Vertex(Vector3 position, Vector3 normal, Vector2 texCoord) + { + public Vector3 Position = position; + public Vector3 Normal = normal; + public Vector2 TexCoord = texCoord; + } + + /// std430 BatchData at the pinned 16-byte stride. + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct BatchData + { + public uint TextureIndex; + public uint TextureLayer; + public uint Tint; + public uint Pad; + } + + /// The indirect command layout vkCmdDrawIndexedIndirect reads. + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct DrawIndexedIndirectCommand + { + public uint IndexCount; + public uint InstanceCount; + public uint FirstIndex; + public int VertexOffset; + public uint FirstInstance; + } + + internal VulkanRhiScene(VulkanGpuDevice device, int sampleCount) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + SampleCount = Math.Max(1, sampleCount); + + // ── the mesh arena: device-local, filled through the staging ring ── + Vertex[] vertices = BuildVertices(out ushort[] indices, out _quadIndexCount, out _lineFirstVertex, out _lineVertexCount); + _vertexArena = device.CreateBuffer(new GpuBufferDescription( + "vk-scene-vertex-arena", + vertices.Length * Marshal.SizeOf(), + GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _indexArena = device.CreateBuffer(new GpuBufferDescription( + "vk-scene-index-arena", + indices.Length * sizeof(ushort), + GpuBufferUsage.Index | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + _vertexArena.Upload(0, MemoryMarshal.AsBytes(vertices)); + _indexArena.Upload(0, MemoryMarshal.AsBytes(indices)); + _owned.Add(_vertexArena); + _owned.Add(_indexArena); + + // ── textures: one uncompressed array with a blit chain, one BC1 with a + // CPU chain. Both paths matter; only one of them can use the GPU. + _cardTexture = BuildOrientationCard(device); + _compressedTexture = BuildCompressedCheckerboard(device); + _owned.Add(_cardTexture); + _owned.Add(_compressedTexture); + + IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); + _cardSlot = device.RegisterTexture(_cardTexture, sampler); + _compressedSlot = device.RegisterTexture(_compressedTexture, sampler); + + _offscreen = device.CreateRenderTarget(new GpuRenderTargetDescription( + "vk-scene-offscreen", + OffscreenExtent, + OffscreenExtent, + GpuTextureFormat.Rgba8UnormRenderTarget, + DepthFormat: null, + SampleCount: 1)); + _owned.Add(_offscreen); + + _meshPipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "vk-scene-mesh", + Shaders = new GpuShaderSet("vk_probe"), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.StraightAlpha, + Depth = GpuDepthState.OpaqueDefault, + Cull = GpuCullMode.None, + SampleCount = SampleCount, + }); + _linePipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "vk-scene-line", + Shaders = new GpuShaderSet("vk_probe"), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.LineList, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + SampleCount = SampleCount, + }); + _owned.Add(_meshPipeline); + _owned.Add(_linePipeline); + + // The offscreen pass needs its own pipeline: its target is + // single-sampled, and sample count is baked into a pipeline rather than + // dynamic. + OffscreenPipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "vk-scene-offscreen", + Shaders = new GpuShaderSet("vk_probe"), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + SampleCount = 1, + }); + _owned.Add(OffscreenPipeline); + } + + internal int SampleCount { get; } + + internal IGpuPipeline OffscreenPipeline { get; } + + /// Records one complete frame: offscreen pass, then the backbuffer pass. + internal void Render(IGpuFrame frame, uint width, uint height, double seconds) + { + ArgumentNullException.ThrowIfNull(frame); + + RenderOffscreen(frame); + RenderMain(frame, width, height, seconds); + } + + /// + /// Fills the offscreen target with a flat quad and registers its colour into + /// the texture table. Registration happens after the first pass has run so + /// the image is in a defined layout; the slot is then stable for the process. + /// + private void RenderOffscreen(IGpuFrame frame) + { + using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = "vk-scene-offscreen", + Color = new GpuColorAttachment( + _offscreen, + GpuLoadOp.Clear, + GpuStoreOp.Store, + new Vector4(0.12f, 0.02f, 0.24f, 1f)), + Depth = null, + SampleCount = 1, + })) + { + using IDisposable _ = encoder.BeginTimerScope("offscreen"); + encoder.BindPipeline(OffscreenPipeline); + + GpuPushConstants constants = GpuPushConstants.Default; + // Straight to NDC: the offscreen pass is a flat 2-D fill, so an + // identity transform is the whole camera. + constants.LightingMode = 1; + encoder.SetPushConstants(constants); + + WriteInstances(frame, encoder, [Matrix4x4.CreateScale(0.75f)]); + WriteBatches(frame, encoder, [new BatchData { Tint = 0xFFC020FFu }]); + BindArena(encoder); + encoder.DrawIndexed(6, 1, 0, 0, 0); + } + + if (!_offscreenSlot.IsAssigned) + { + _offscreenSlot = _device.RegisterTexture( + _offscreen.ColorTexture, + _device.CreateSampler(GpuSamplerDescription.UiNearest)); + } + } + + private void RenderMain(IGpuFrame frame, uint width, uint height, double seconds) + { + using IGpuPassEncoder encoder = frame.BeginPass(GpuPassDescription.BackbufferClear( + "vk-scene-main", + new Vector4(0.043f, 0.075f, 0.153f, 1f), + SampleCount)); + using IDisposable scope = encoder.BeginTimerScope("main"); + + float aspect = height == 0 ? 1f : width / (float)height; + // Matrix4x4.CreatePerspectiveFieldOfView is D3D convention with NDC z in + // [0,1] — already Vulkan's, which is exactly why plan §4.7 concludes no + // projection rework is needed anywhere. + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + aspect, + 0.1f, + 50f); + Matrix4x4 view = Matrix4x4.CreateLookAt( + new Vector3(0f, 0f, 3.4f), + Vector3.Zero, + Vector3.UnitY); + + GpuPushConstants constants = GpuPushConstants.Default; + constants.ViewProjection = view * projection; + constants.LightingMode = 0; + + encoder.BindPipeline(_meshPipeline); + encoder.SetPushConstants(constants); + encoder.SetCullMode(GpuCullMode.None); + encoder.SetDepthWrite(true); + + // Four quadrant markers plus one wide backdrop. Nothing here is + // mirror-symmetric, on purpose. + float wobble = (float)Math.Sin(seconds) * 0.05f; + Matrix4x4[] instances = + [ + Matrix4x4.CreateScale(2.6f, 1.6f, 1f) * Matrix4x4.CreateTranslation(0f, 0f, -0.4f), + Matrix4x4.CreateScale(0.5f) * Matrix4x4.CreateTranslation(-1.0f, 0.55f + wobble, 0f), + Matrix4x4.CreateScale(0.36f) * Matrix4x4.CreateTranslation(0.95f, 0.55f, 0f), + Matrix4x4.CreateScale(0.28f) * Matrix4x4.CreateTranslation(-1.0f, -0.6f, 0f), + Matrix4x4.CreateScale(0.44f) * Matrix4x4.CreateTranslation(0.6f, -0.62f, 0f), + ]; + BatchData[] batches = + [ + new BatchData { TextureIndex = _cardSlot.Index, Tint = 0xFFFFFFFFu }, + new BatchData { TextureIndex = _compressedSlot.Index, Tint = QuadrantMarkers[0].Rgba }, + new BatchData { TextureIndex = _offscreenSlot.Index, Tint = QuadrantMarkers[1].Rgba }, + new BatchData { TextureIndex = _compressedSlot.Index, Tint = QuadrantMarkers[2].Rgba }, + new BatchData { TextureIndex = _cardSlot.Index, Tint = QuadrantMarkers[3].Rgba }, + ]; + + WriteInstances(frame, encoder, instances); + WriteBatches(frame, encoder, batches); + BindArena(encoder); + + // One multi-draw covering every quad, with gl_DrawID selecting the batch + // — the production dispatch shape, not a loop of single draws. + GpuRingAllocation commands = frame.AllocateRing( + instances.Length * Marshal.SizeOf(), + GpuRingUsage.Indirect); + Span span = commands.AsSpan(); + for (int i = 0; i < instances.Length; i++) + { + span[i] = new DrawIndexedIndirectCommand + { + IndexCount = _quadIndexCount, + InstanceCount = 1, + FirstIndex = 0, + VertexOffset = 0, + // The per-group instance base — the reason + // drawIndirectFirstInstance is a required feature. + FirstInstance = (uint)i, + }; + } + + encoder.MultiDrawIndexedIndirect( + commands.Buffer, + commands.OffsetBytes, + (uint)instances.Length, + (uint)Marshal.SizeOf()); + + // A second pipeline bound mid-pass. Because every pipeline shares one + // layout, the descriptor sets and push constants above survive this. + constants.LightingMode = 1; + encoder.BindPipeline(_linePipeline); + encoder.SetPushConstants(constants); + encoder.SetDepthWrite(false); + + WriteInstances(frame, encoder, [Matrix4x4.Identity]); + WriteBatches(frame, encoder, [new BatchData { Tint = 0xFFE060FFu }]); + BindArena(encoder); + encoder.Draw(_lineVertexCount, 1, _lineFirstVertex, 0); + } + + private void BindArena(IGpuPassEncoder encoder) + { + encoder.BindVertexBuffer(_vertexArena, 0); + encoder.BindIndexBuffer(_indexArena, 0, GpuIndexType.UInt16); + } + + private static void WriteInstances( + IGpuFrame frame, + IGpuPassEncoder encoder, + ReadOnlySpan transforms) + { + GpuRingAllocation allocation = frame.AllocateRing( + transforms.Length * Marshal.SizeOf(), + GpuRingUsage.Storage); + transforms.CopyTo(allocation.AsSpan()); + encoder.BindStorageBuffer( + GpuBindingModel.StorageInstances, + allocation.Buffer, + allocation.OffsetBytes, + (uint)allocation.Data.Length); + } + + private static void WriteBatches( + IGpuFrame frame, + IGpuPassEncoder encoder, + ReadOnlySpan batches) + { + GpuRingAllocation allocation = frame.AllocateRing( + batches.Length * GpuBindingModel.GpuBatchDataStrideBytes, + GpuRingUsage.Storage); + batches.CopyTo(allocation.AsSpan()); + encoder.BindStorageBuffer( + GpuBindingModel.StorageBatches, + allocation.Buffer, + allocation.OffsetBytes, + (uint)allocation.Data.Length); + } + + /// + /// One unit quad (indexed) followed by an asymmetric open line figure. Both + /// live in the same arena, which is what a real mesh arena does and what the + /// vertex-offset/first-vertex plumbing has to get right. + /// + private static Vertex[] BuildVertices( + out ushort[] indices, + out uint quadIndexCount, + out uint lineFirstVertex, + out uint lineVertexCount) + { + var vertices = new List + { + // Quad, counter-clockwise when viewed from +Z. v = 0 is the TOP + // edge, so texture row 0 lands at the top and the orientation card + // reads the same way in memory and on screen. + new(new Vector3(-0.5f, 0.5f, 0f), Vector3.UnitZ, new Vector2(0f, 0f)), + new(new Vector3(-0.5f, -0.5f, 0f), Vector3.UnitZ, new Vector2(0f, 1f)), + new(new Vector3(0.5f, -0.5f, 0f), Vector3.UnitZ, new Vector2(1f, 1f)), + new(new Vector3(0.5f, 0.5f, 0f), Vector3.UnitZ, new Vector2(1f, 0f)), + }; + indices = [0, 1, 2, 0, 2, 3]; + quadIndexCount = 6; + + lineFirstVertex = (uint)vertices.Count; + // An "L" opening up and to the left, drawn as a line list: three + // segments, no symmetry in either axis. + Vector3[] path = + [ + new(-1.5f, 0.9f, 0.2f), + new(-1.5f, -0.9f, 0.2f), + new(-1.5f, -0.9f, 0.2f), + new(0.2f, -0.9f, 0.2f), + new(0.2f, -0.9f, 0.2f), + new(0.2f, -0.4f, 0.2f), + ]; + foreach (Vector3 point in path) + vertices.Add(new Vertex(point, Vector3.UnitZ, Vector2.Zero)); + lineVertexCount = (uint)path.Length; + + return [.. vertices]; + } + + /// + /// A 16x16 RGBA orientation card: red top-left, green top-right, blue + /// bottom-left, white bottom-right, with a one-texel black frame. Its mips + /// come from vkCmdBlitImage, which is the path only uncompressed + /// formats can take. + /// + private static IGpuTexture BuildOrientationCard(VulkanGpuDevice device) + { + const int extent = 16; + int levels = VulkanTextureFormatMapping.FullMipLevelCount(extent, extent); + IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( + "vk-scene-orientation-card", + GpuTextureKind.Texture2DArray, + GpuTextureFormat.Rgba8Unorm, + extent, + extent, + LayerCount: 1, + MipLevelCount: levels)); + + var pixels = new byte[extent * extent * 4]; + for (int y = 0; y < extent; y++) + { + for (int x = 0; x < extent; x++) + { + bool top = y < extent / 2; + bool left = x < extent / 2; + uint colour = (top, left) switch + { + (true, true) => QuadrantMarkers[0].Rgba, + (true, false) => QuadrantMarkers[1].Rgba, + (false, true) => QuadrantMarkers[2].Rgba, + _ => QuadrantMarkers[3].Rgba, + }; + bool frame = x == 0 || y == 0 || x == extent - 1 || y == extent - 1; + if (frame) + colour = 0x101010FFu; + + int offset = ((y * extent) + x) * 4; + pixels[offset + 0] = (byte)(colour >> 24); + pixels[offset + 1] = (byte)(colour >> 16); + pixels[offset + 2] = (byte)(colour >> 8); + pixels[offset + 3] = (byte)colour; + } + } + + texture.Upload(0, 0, pixels); + texture.GenerateMipChain(); + return texture; + } + + /// + /// A BC1 checkerboard whose mip chain is built on the CPU, because Vulkan + /// cannot blit into a compressed image. This is the path every DAT surface + /// in the game will take. + /// + private static IGpuTexture BuildCompressedCheckerboard(VulkanGpuDevice device) + { + const int extent = 32; + int levels = VulkanTextureFormatMapping.FullMipLevelCount(extent, extent); + IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( + "vk-scene-checkerboard", + GpuTextureKind.Texture2DArray, + GpuTextureFormat.Bc1Unorm, + extent, + extent, + LayerCount: 1, + MipLevelCount: levels)); + + var rgba = new byte[extent * extent * 4]; + for (int y = 0; y < extent; y++) + { + for (int x = 0; x < extent; x++) + { + bool light = ((x / 4) + (y / 4)) % 2 == 0; + byte value = light ? (byte)0xFF : (byte)0x50; + int offset = ((y * extent) + x) * 4; + rgba[offset + 0] = value; + rgba[offset + 1] = value; + rgba[offset + 2] = value; + rgba[offset + 3] = 0xFF; + } + } + + texture.Upload(0, 0, BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, rgba, extent, extent)); + foreach (BlockCompressionMipChain.Level level in + BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, rgba, extent, extent, levels)) + { + texture.Upload(level.MipLevel, 0, level.Data); + } + + return texture; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + for (int i = _owned.Count - 1; i >= 0; i--) + _owned[i].Dispose(); + _owned.Clear(); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs index 901829ee..095ef290 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs @@ -17,6 +17,36 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// internal static class VulkanTextureFormatMapping { + /// + /// The one colour-attachment format every acdream pipeline renders into. + /// + /// Vulkan bakes attachment formats into a pipeline (dynamic rendering + /// declares them in VkPipelineRenderingCreateInfo), and a pipeline + /// whose format disagrees with the pass it is used in is invalid usage. But + /// GpuPipelineDescription — pinned at V0 — has no field for the + /// attachment format: it names SampleCount and nothing else about the + /// target. Without one, an offscreen pipeline built for + /// Rgba8UnormRenderTarget and a backbuffer pipeline built for the + /// B8G8R8A8_UNORM swapchain (plan §4.9) could not share a description, + /// and the backend would have no way to tell them apart. + /// + /// + /// So offscreen colour attachments use the swapchain's format too, and + /// the substitution is invisible above the API: an image is sampled through + /// its format's component mapping, so texture() on a BGRA image + /// returns (R,G,B,A) exactly as it does on an RGBA one. The only place the + /// byte order is observable is a CPU readback, and the one readback that + /// exists — — swizzles explicitly. + /// + /// + /// Recorded rather than hidden: this is a real expressiveness gap in + /// the pinned contract, and the honest fix is a colour-format field on + /// GpuPipelineDescription in a reviewed contract commit, exactly as + /// GpuBlendMode.InverseAlpha and GpuVertexFormat.UByte4UInt + /// were added when V4c and V4d met the same wall. + /// + internal const Format CanonicalColorAttachmentFormat = Format.B8G8R8A8Unorm; + /// The Vulkan format acdream uploads this surface as. BC formats transcode nothing. internal static Format FormatOf(GpuTextureFormat format) => format switch { @@ -25,7 +55,9 @@ internal static class VulkanTextureFormatMapping GpuTextureFormat.Bc1Unorm => Format.BC1RgbaUnormBlock, GpuTextureFormat.Bc2Unorm => Format.BC2UnormBlock, GpuTextureFormat.Bc3Unorm => Format.BC3UnormBlock, - GpuTextureFormat.Rgba8UnormRenderTarget => Format.R8G8B8A8Unorm, + // Deliberately the same 32-bit UNORM order as the swapchain rather than + // literal RGBA — see CanonicalColorAttachmentFormat. + GpuTextureFormat.Rgba8UnormRenderTarget => CanonicalColorAttachmentFormat, GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint, _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."), }; diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs new file mode 100644 index 00000000..5a19f9f9 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -0,0 +1,172 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c, plan §3.3 and §4.7: the ONE place the Vulkan backend +/// reconciles its coordinate conventions with GL's. +/// +/// Renderers always speak GL: viewport origin bottom-left, front faces +/// counter-clockwise. Vulkan's framebuffer origin is top-left, so this backend +/// renders with a NEGATIVE viewport height, which mirrors clip space vertically +/// and makes GL-authored geometry land in the right place with no shader or +/// matrix change anywhere. Mirroring also reverses triangle winding, so the +/// front face is inverted to compensate. The two flips are exact inverses and +/// must therefore always travel together — which is precisely why they live in +/// one file with one test suite rather than at the dozen call sites that would +/// otherwise each have to remember. +/// +/// Scissor does NOT flip with the viewport. The V3 audit called +/// this out as a concrete acceptance item (plan §4.10, item 1): +/// vkCmdSetScissor is always top-left-origin regardless of viewport sign, +/// and NdcScissorRect.ToPixels emits GL bottom-left rectangles. So the +/// scissor rectangle needs an explicit Y flip against the attachment height, +/// while the viewport needs none. Getting this wrong shows up as a doorway +/// aperture clipped from the wrong edge — visible, but only in a scene that has +/// one. +/// +/// Clip space itself needs nothing. acdream's cameras already build +/// projections with Matrix4x4.CreatePerspectiveFieldOfView, which is the +/// D3D convention with NDC z in [0,1] — Vulkan's convention exactly. The GL path +/// has been compressing that into the upper half of its depth range, so Vulkan +/// gains a bit of depth precision for free. +/// +internal static class VulkanViewportMapping +{ + /// + /// Converts a GL-convention viewport rectangle into the negative-height + /// Vulkan viewport that reproduces it. + /// + /// The Y origin becomes the rectangle's TOP edge measured from the + /// attachment's top — that is, attachmentHeight - (y + height) flipped + /// to the bottom of the flipped viewport, which reduces to + /// attachmentHeight - y. Height is then negated. + /// + internal static Viewport ToVulkan( + int x, + int y, + int width, + int height, + uint attachmentHeight, + float minDepth = 0f, + float maxDepth = 1f) => new() + { + X = x, + Y = attachmentHeight - (float)y, + Width = width, + Height = -height, + MinDepth = minDepth, + MaxDepth = maxDepth, + }; + + /// + /// Converts a GL-convention (bottom-left origin) scissor rectangle into + /// Vulkan's top-left-origin one. The negative viewport height does not do + /// this for us — see the class remarks. + /// + internal static Rect2D ScissorToVulkan(int x, int y, int width, int height, uint attachmentHeight) + { + int top = (int)attachmentHeight - (y + height); + // A rectangle straddling the attachment edge is clamped rather than + // rejected: GL silently clips one, and a driver error here would turn a + // harmless off-screen aperture into a crash. + int clampedTop = Math.Max(0, top); + int clampedHeight = Math.Max(0, Math.Min(height + Math.Min(0, top), (int)attachmentHeight - clampedTop)); + int clampedX = Math.Max(0, x); + int clampedWidth = Math.Max(0, width + Math.Min(0, x)); + return new Rect2D( + new Offset2D(clampedX, clampedTop), + new Extent2D((uint)clampedWidth, (uint)clampedHeight)); + } + + /// + /// Inverts the winding a renderer asked for, because the negative viewport + /// height mirrors framebuffer space. No renderer performs this flip itself + /// and no other code in the backend may repeat it. + /// + internal static FrontFace ToVulkan(GpuFrontFace frontFace) => frontFace switch + { + GpuFrontFace.CounterClockwise => FrontFace.Clockwise, + GpuFrontFace.Clockwise => FrontFace.CounterClockwise, + _ => throw new ArgumentOutOfRangeException(nameof(frontFace), frontFace, "Unknown winding."), + }; + + internal static CullModeFlags ToVulkan(GpuCullMode cullMode) => cullMode switch + { + GpuCullMode.None => CullModeFlags.None, + GpuCullMode.Back => CullModeFlags.BackBit, + GpuCullMode.Front => CullModeFlags.FrontBit, + _ => throw new ArgumentOutOfRangeException(nameof(cullMode), cullMode, "Unknown cull mode."), + }; + + internal static CompareOp ToVulkan(GpuCompareOp compare) => compare switch + { + GpuCompareOp.Never => CompareOp.Never, + GpuCompareOp.Less => CompareOp.Less, + GpuCompareOp.LessOrEqual => CompareOp.LessOrEqual, + GpuCompareOp.Equal => CompareOp.Equal, + GpuCompareOp.Greater => CompareOp.Greater, + GpuCompareOp.GreaterOrEqual => CompareOp.GreaterOrEqual, + GpuCompareOp.Always => CompareOp.Always, + _ => throw new ArgumentOutOfRangeException(nameof(compare), compare, "Unknown compare op."), + }; + + internal static PrimitiveTopology ToVulkan(GpuPrimitiveTopology topology) => topology switch + { + GpuPrimitiveTopology.TriangleList => PrimitiveTopology.TriangleList, + GpuPrimitiveTopology.LineList => PrimitiveTopology.LineList, + _ => throw new ArgumentOutOfRangeException(nameof(topology), topology, "Unknown topology."), + }; + + internal static IndexType ToVulkan(GpuIndexType indexType) => indexType switch + { + GpuIndexType.UInt16 => IndexType.Uint16, + GpuIndexType.UInt32 => IndexType.Uint32, + _ => throw new ArgumentOutOfRangeException(nameof(indexType), indexType, "Unknown index type."), + }; + + internal static Format ToVulkan(GpuVertexFormat format) => format switch + { + GpuVertexFormat.Float1 => Format.R32Sfloat, + GpuVertexFormat.Float2 => Format.R32G32Sfloat, + GpuVertexFormat.Float3 => Format.R32G32B32Sfloat, + GpuVertexFormat.Float4 => Format.R32G32B32A32Sfloat, + GpuVertexFormat.UByte4Normalized => Format.R8G8B8A8Unorm, + // Distinct in kind, not just scaling: an integer shader input must be + // fed _UINT, and _UNORM here would deliver garbage terrain codes. + GpuVertexFormat.UByte4UInt => Format.R8G8B8A8Uint, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown vertex format."), + }; + + /// The blend factors each retail translucency mode composites with. + internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch + { + GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), + // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. + GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), + GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero), + _ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."), + }; + + internal static AttachmentLoadOp ToVulkan(GpuLoadOp load) => load switch + { + GpuLoadOp.DontCare => AttachmentLoadOp.DontCare, + GpuLoadOp.Clear => AttachmentLoadOp.Clear, + GpuLoadOp.Load => AttachmentLoadOp.Load, + _ => throw new ArgumentOutOfRangeException(nameof(load), load, "Unknown load op."), + }; + + /// + /// Store ops. is not itself an + /// AttachmentStoreOp — it is expressed by giving the attachment a + /// resolve target and a resolve mode — so it maps to DONT_CARE here and the + /// pass builder supplies the rest. + /// + internal static AttachmentStoreOp ToVulkan(GpuStoreOp store) => store switch + { + GpuStoreOp.DontCare or GpuStoreOp.Resolve => AttachmentStoreOp.DontCare, + GpuStoreOp.Store => AttachmentStoreOp.Store, + _ => throw new ArgumentOutOfRangeException(nameof(store), store, "Unknown store op."), + }; +} diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json new file mode 100644 index 00000000..0da87a4d --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -0,0 +1,163 @@ +{ + "note": "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.", + "shaders": [ + { + "name": "debug_line", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "e6a535ed722a034482cfe09e15ac2308ecde2bb54bc7d303cb5874b5b347eb61", + "compiled": false, + "message": "debug_line.vert:62: error: \u0027uProjection\u0027 : undeclared identifier" + }, + { + "stage": "frag", + "sourceSha256": "5db0714b88329f2465f4b8b949116e4295e37bf17360d81c108eacccb102a336", + "compiled": true + } + ] + }, + { + "name": "mesh", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56", + "compiled": false, + "message": "mesh.vert:70: error: \u0027uModel\u0027 : undeclared identifier" + }, + { + "stage": "frag", + "sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea", + "compiled": false, + "message": "mesh.frag:157: error: \u0027uDiffuse\u0027 : undeclared identifier" + } + ] + }, + { + "name": "mesh_modern", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530", + "compiled": false, + "message": "mesh_modern.vert:379: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027" + }, + { + "stage": "frag", + "sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3", + "compiled": false, + "message": "mesh_modern.frag:105: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" + } + ] + }, + { + "name": "particle", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", + "compiled": false, + "message": "particle.vert:82: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027" + }, + { + "stage": "frag", + "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", + "compiled": false, + "message": "particle.frag:59: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" + } + ] + }, + { + "name": "particle_mesh", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "19db8757c1a61a2fbec8e56ce89d56b6dd6d66a123cedcdae40915af07d58c0e", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", + "compiled": false, + "message": "particle_mesh.frag:61: error: \u0027uTextureIndex\u0027 : undeclared identifier" + } + ] + }, + { + "name": "sky", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2", + "compiled": false, + "message": "sky.vert:150: error: \u0027uUvScroll\u0027 : undeclared identifier" + }, + { + "stage": "frag", + "sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c", + "compiled": false, + "message": "sky.frag:75: error: \u0027uDiffuse\u0027 : undeclared identifier" + } + ] + }, + { + "name": "terrain_modern", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e", + "compiled": false, + "message": "terrain_modern.vert:218: error: \u0027uProjection\u0027 : undeclared identifier" + }, + { + "stage": "frag", + "sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9", + "compiled": false, + "message": "terrain_modern.frag:107: error: \u0027uTexTiling\u0027 : undeclared identifier" + } + ] + }, + { + "name": "ui_text", + "vulkanReady": false, + "stages": [ + { + "stage": "vert", + "sourceSha256": "6c4b0cb8b05da648a5e335db6747cb239dd1fbf95333b658557f52b39eadf4a3", + "compiled": false, + "message": "ui_text.vert:64: error: \u0027uScreenSize\u0027 : undeclared identifier" + }, + { + "stage": "frag", + "sourceSha256": "7287a9f19530979b00de01a8f6c3865905ae3f72e5f219ce228b41915c58d60d", + "compiled": false, + "message": "ui_text.frag:57: error: \u0027uUseTexture\u0027 : undeclared identifier" + } + ] + }, + { + "name": "vk_probe", + "vulkanReady": true, + "stages": [ + { + "stage": "vert", + "sourceSha256": "1f3f73aa4e9448c36f3b577e63b2142815736a4195bbc4395e1d674a9be92f43", + "compiled": true + }, + { + "stage": "frag", + "sourceSha256": "8093adcacb925258ea79371a3ac3415de4f270f95eb12a621c759912992a5614", + "compiled": true + } + ] + } + ] +} diff --git a/src/AcDream.App/Rendering/Shaders/spv/vk_probe.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/vk_probe.frag.spv new file mode 100644 index 00000000..8044ceb4 Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/vk_probe.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/vk_probe.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/vk_probe.vert.spv new file mode 100644 index 00000000..b463091e Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/vk_probe.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/vk_probe.frag b/src/AcDream.App/Rendering/Shaders/vk_probe.frag new file mode 100644 index 00000000..5831f3c1 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/vk_probe.frag @@ -0,0 +1,46 @@ +#version 430 core +// Campaign V slice V6c — the Vulkan RHI verification shader's fragment stage. +// See vk_probe.vert for why this pair exists and why it is Vulkan-dialect only. + +layout(location = 0) in vec3 vNormal; +layout(location = 1) in vec2 vTexCoord; +layout(location = 2) in flat uint vTextureIndex; +layout(location = 3) in flat uint vTextureLayer; +layout(location = 4) in flat uint vTint; + +layout(location = 0) out vec4 FragColor; + +void main() { + vec4 tint = vec4( + float((vTint >> 24) & 0xFFu) / 255.0, + float((vTint >> 16) & 0xFFu) / 255.0, + float((vTint >> 8) & 0xFFu) / 255.0, + float(vTint & 0xFFu) / 255.0); + + vec4 albedo = tint; + if (uLightingMode == 0) { + // nonuniformEXT is required rather than polite: within one multi-draw + // dispatch different draws read different Batches[] entries, and + // "dynamically uniform" is defined over the whole dispatch on some + // implementations. It costs nothing measurable and removes a class of + // silent corruption. + albedo = texture( + ACDREAM_TEXTURE(vTextureIndex), + vec3(vTexCoord, float(vTextureLayer))) * tint; + } + + // A fixed key light so the verification scene reads as three-dimensional in + // a screenshot. uLightingMode 1 keeps geometry flat, which is what the + // line pass wants. + if (uLightingMode == 0) { + vec3 light = normalize(vec3(0.4, -0.6, 0.7)); + float lambert = 0.35 + (0.65 * max(dot(normalize(vNormal), light), 0.0)); + albedo.rgb *= lambert; + } + + if (albedo.a < 0.004) { + discard; + } + + FragColor = albedo; +} diff --git a/src/AcDream.App/Rendering/Shaders/vk_probe.vert b/src/AcDream.App/Rendering/Shaders/vk_probe.vert new file mode 100644 index 00000000..2fd716a9 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/vk_probe.vert @@ -0,0 +1,69 @@ +#version 430 core +// Campaign V slice V6c — the Vulkan RHI verification shader. +// +// Plan §4.11 asks the active capability probe to "build one real pipeline from +// the committed .spv and render an offscreen triangle sampling a table slot", +// and slice V5 recorded that as its one deliberate deviation because the .spv +// toolchain did not exist yet. This is that shader, and it does rather more: it +// is what the V6 backend draws its verification scene with, so one pipeline +// exercises the vertex layout, both storage bindings, the shared push-constant +// block, gl_DrawID under multi-draw-indirect, and the set-2 texture table. +// +// VULKAN-DIALECT ONLY. Unlike the eight production pairs this is not compiled by +// the GL backend: it reads the push-constant block and the descriptor array that +// tools/compile-shaders.ps1 injects, neither of which GL has. It is not a fork of +// anything — no GL renderer draws with it — and it retires when the ported world +// renderers become the backend's own proof. + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +// set 0 binding 0 — per-instance transforms, exactly as GpuBindingModel pins it. +struct InstanceData { + mat4 transform; +}; +layout(std430, binding = 0) readonly buffer InstanceBuffer { + InstanceData Instances[]; +}; + +// set 0 binding 1 — per-draw batch metadata at the pinned 16-byte std430 stride. +// The first word is the texture-table slot: since slice V2 a batch carries an +// index, not a 64-bit bindless handle, which is what makes this data model +// backend-neutral. +struct BatchData { + uint textureIndex; + uint textureLayer; + uint tint; + uint pad; +}; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +layout(location = 0) out vec3 vNormal; +layout(location = 1) out vec2 vTexCoord; +layout(location = 2) out flat uint vTextureIndex; +layout(location = 3) out flat uint vTextureLayer; +layout(location = 4) out flat uint vTint; + +void main() { + // gl_BaseInstanceARB + gl_InstanceID is the GL idiom mesh_modern uses; the + // injected preamble maps it onto Vulkan's gl_InstanceIndex, which already + // includes firstInstance. + int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; + mat4 model = Instances[instanceIndex].transform; + + // gl_DrawID resets to 0 at the start of each indirect dispatch on both APIs, + // so a pass beginning partway into the batch array offsets its lookup — + // issue #52's uDrawIDOffset pattern, carried over unchanged. + BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; + vTextureIndex = b.textureIndex; + vTextureLayer = b.textureLayer; + vTint = b.tint; + + vec4 world = model * vec4(aPosition, 1.0); + gl_Position = uViewProjection * world; + vNormal = mat3(model) * aNormal; + vTexCoord = aTexCoord; +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs new file mode 100644 index 00000000..111ff7bb --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it +/// was compiled from. +/// +/// Plan §4.6 rules out runtime shader compilation — CI has no Vulkan SDK, +/// and a native shaderc dependency plus a startup cost would be paid for shaders +/// that never change at runtime — so the .spv artifacts are committed. +/// The obvious hazard follows immediately: someone edits a shader, the GL +/// backend picks it up because it compiles GLSL at startup, and the Vulkan +/// backend silently keeps rendering the old one. This test is what turns that +/// into a red build. +/// +/// It also pins which production shaders are Vulkan-expressible TODAY. Nine +/// of the ten pairs are not, and each failure is a specific source-level fact +/// belonging to a renderer-port slice that has not landed — not a toolchain gap. +/// Recording them here means the next slice inherits an inventory rather than a +/// rediscovery. +/// +public sealed class VulkanShaderManifestTests +{ + private sealed record StageEntry(string Stage, string SourceSha256, bool Compiled, string? Message); + + private sealed record ShaderEntry(string Name, bool VulkanReady, IReadOnlyList Stages); + + private sealed record Manifest(string Note, IReadOnlyList Shaders); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root from the test binary."); + } + + private static string ShadersDirectory() => + Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders"); + + private static string SpirvDirectory() => Path.Combine(ShadersDirectory(), "spv"); + + private static Manifest ReadManifest() + { + string path = Path.Combine(SpirvDirectory(), "shaders.manifest.json"); + Assert.True(File.Exists(path), $"The shader manifest is missing at {path}. Run tools/compile-shaders.ps1."); + return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidOperationException("The shader manifest could not be parsed."); + } + + private static string Sha256OfSource(string path) + { + // Line endings are normalised before hashing so a checkout with a + // different core.autocrlf setting does not report every shader stale. + string text = File.ReadAllText(path).Replace("\r\n", "\n"); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + } + + [Fact] + public void EveryGlslPairIsRecordedInTheManifest() + { + Manifest manifest = ReadManifest(); + string[] pairs = Directory + .EnumerateFiles(ShadersDirectory(), "*.vert") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => name is not null && File.Exists(Path.Combine(ShadersDirectory(), $"{name}.frag"))) + .Select(name => name!) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(pairs, manifest.Shaders.Select(shader => shader.Name).OrderBy(n => n, StringComparer.Ordinal)); + } + + [Fact] + public void CommittedSpirvIsNotStaleAgainstItsGlslSource() + { + Manifest manifest = ReadManifest(); + var stale = new List(); + + foreach (ShaderEntry shader in manifest.Shaders) + { + foreach (StageEntry stage in shader.Stages) + { + string source = Path.Combine(ShadersDirectory(), $"{shader.Name}.{stage.Stage}"); + if (!File.Exists(source)) + { + stale.Add($"{shader.Name}.{stage.Stage}: the GLSL source no longer exists"); + continue; + } + + string actual = Sha256OfSource(source); + if (!string.Equals(actual, stage.SourceSha256, StringComparison.Ordinal)) + stale.Add($"{shader.Name}.{stage.Stage}: source changed since the .spv was built"); + } + } + + Assert.True( + stale.Count == 0, + "Committed SPIR-V is out of date. Run tools/compile-shaders.ps1 and commit the result.\n " + + string.Join("\n ", stale)); + } + + [Fact] + public void EveryShaderTheManifestCallsReadyHasBothSpirvArtifacts() + { + Manifest manifest = ReadManifest(); + foreach (ShaderEntry shader in manifest.Shaders.Where(entry => entry.VulkanReady)) + { + foreach (string stage in (string[])["vert", "frag"]) + { + string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv"); + Assert.True(File.Exists(path), $"{shader.Name} is marked Vulkan-ready but {path} is missing."); + long length = new FileInfo(path).Length; + Assert.True(length > 0 && length % 4 == 0, $"{path} is not a whole number of SPIR-V words."); + } + } + } + + [Fact] + public void ShadersTheManifestCallsUnreadyHaveNoStaleSpirvLeftBehind() + { + Manifest manifest = ReadManifest(); + foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady)) + { + foreach (string stage in (string[])["vert", "frag"]) + { + string path = Path.Combine(SpirvDirectory(), $"{shader.Name}.{stage}.spv"); + // A leftover .spv from an earlier attempt would be loaded + // happily by the device and would be a shader nobody can account + // for. + Assert.False(File.Exists(path), $"{shader.Name} is not Vulkan-ready but {path} exists."); + } + } + } + + [Fact] + public void EveryUnreadyShaderRecordsWhyItCannotBeCompiledYet() + { + Manifest manifest = ReadManifest(); + foreach (ShaderEntry shader in manifest.Shaders.Where(entry => !entry.VulkanReady)) + { + Assert.Contains(shader.Stages, stage => !stage.Compiled && !string.IsNullOrWhiteSpace(stage.Message)); + } + } + + [Fact] + public void TheRhiVerificationShaderIsCompiled() + { + Manifest manifest = ReadManifest(); + ShaderEntry probe = Assert.Single( + manifest.Shaders, + shader => string.Equals(shader.Name, "vk_probe", StringComparison.Ordinal)); + + // Plan §4.11 wants the capability probe to build a real pipeline from + // committed .spv; slice V5 deferred that to V6c because no toolchain + // existed. If this pair ever stops compiling, the Vulkan backend has no + // pipeline it can build at all. + Assert.True(probe.VulkanReady, "vk_probe must compile — the whole Vulkan backend draws with it."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs new file mode 100644 index 00000000..90de252f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -0,0 +1,191 @@ +using System; +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 V6c — the coordinate reconciliation (plan §3.3, §4.7, +/// §4.10). +/// +/// Renderers speak GL: viewport origin bottom-left, front faces +/// counter-clockwise. The Vulkan backend renders with a NEGATIVE viewport +/// height, which mirrors framebuffer space vertically and therefore also +/// reverses winding, so the front face is inverted to compensate. The two flips +/// are exact inverses and must always travel together — which is why they live +/// in one file, and why these tests assert them together. +/// +/// The scissor is the trap. It does NOT flip with the viewport: +/// vkCmdSetScissor is always top-left-origin regardless of viewport sign, +/// while NdcScissorRect.ToPixels emits GL bottom-left rectangles. The V3 +/// audit flagged this explicitly as a V6 acceptance item, and getting it wrong +/// shows up as a doorway aperture clipped from the wrong edge — which only a +/// scene containing one would reveal. +/// +public sealed class VulkanViewportMappingTests +{ + [Fact] + public void FullViewportBecomesANegativeHeightRectangleAnchoredAtTheBottom() + { + Viewport viewport = VulkanViewportMapping.ToVulkan(0, 0, 1280, 720, attachmentHeight: 720); + + Assert.Equal(0f, viewport.X); + // Y is the BOTTOM edge in Vulkan's top-left space, and the height runs + // upward from it. Together they mirror clip space. + Assert.Equal(720f, viewport.Y); + Assert.Equal(1280f, viewport.Width); + Assert.Equal(-720f, viewport.Height); + Assert.Equal(0f, viewport.MinDepth); + Assert.Equal(1f, viewport.MaxDepth); + } + + [Fact] + public void AnOffsetViewportKeepsItsGlBottomLeftMeaning() + { + // A 100x50 viewport whose bottom edge sits 30 px above the bottom of a + // 720 px attachment. + Viewport viewport = VulkanViewportMapping.ToVulkan(10, 30, 100, 50, attachmentHeight: 720); + + Assert.Equal(10f, viewport.X); + Assert.Equal(690f, viewport.Y); + Assert.Equal(-50f, viewport.Height); + } + + [Fact] + public void ScissorFlipsAgainstTheAttachmentBecauseTheViewportSignDoesNotDoItForUs() + { + // GL rectangle: 100 px wide, 50 px tall, bottom edge 30 px up. + Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(10, 30, 100, 50, attachmentHeight: 720); + + Assert.Equal(10, scissor.Offset.X); + // Top edge measured from the top: 720 - (30 + 50). + Assert.Equal(640, scissor.Offset.Y); + Assert.Equal(100u, scissor.Extent.Width); + Assert.Equal(50u, scissor.Extent.Height); + } + + [Fact] + public void AFullAttachmentScissorIsUnchangedByTheFlip() + { + Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 0, 1280, 720, attachmentHeight: 720); + + Assert.Equal(0, scissor.Offset.X); + Assert.Equal(0, scissor.Offset.Y); + Assert.Equal(1280u, scissor.Extent.Width); + Assert.Equal(720u, scissor.Extent.Height); + } + + [Fact] + public void AScissorStraddlingTheTopEdgeIsClampedRatherThanRejected() + { + // Bottom edge 700 px up in a 720 px attachment, 50 px tall: 30 px of it + // is off the top. GL silently clips this; a driver error here would turn + // a harmless off-screen aperture into a crash. + Rect2D scissor = VulkanViewportMapping.ScissorToVulkan(0, 700, 100, 50, attachmentHeight: 720); + + Assert.Equal(0, scissor.Offset.Y); + Assert.Equal(20u, scissor.Extent.Height); + } + + [Fact] + public void FrontFaceIsInvertedBecauseTheViewportMirrorsFramebufferSpace() + { + Assert.Equal(FrontFace.Clockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise)); + Assert.Equal(FrontFace.CounterClockwise, VulkanViewportMapping.ToVulkan(GpuFrontFace.Clockwise)); + } + + [Fact] + public void TheFlipAndTheWindingInversionAreExactInverses() + { + // Mirroring twice is identity, and inverting the winding twice is too. + // If a later change ever flipped one without the other, this is the + // shape of the assertion that catches it. + Viewport once = VulkanViewportMapping.ToVulkan(0, 0, 640, 480, attachmentHeight: 480); + Assert.Equal(-480f, once.Height); + Assert.Equal(480f, once.Y); + + FrontFace inverted = VulkanViewportMapping.ToVulkan(GpuFrontFace.CounterClockwise); + Assert.NotEqual(FrontFace.CounterClockwise, inverted); + } + + [Fact] + public void CullModesMapStraightAcross() + { + Assert.Equal(CullModeFlags.None, VulkanViewportMapping.ToVulkan(GpuCullMode.None)); + Assert.Equal(CullModeFlags.BackBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Back)); + Assert.Equal(CullModeFlags.FrontBit, VulkanViewportMapping.ToVulkan(GpuCullMode.Front)); + } + + [Fact] + public void AllThreeRetailBlendModesAreRepresentable() + { + Assert.Equal( + (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha)); + Assert.Equal( + (BlendFactor.SrcAlpha, BlendFactor.One), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive)); + // Retail's third mode, found at slice V4c. Mapping it onto straight + // alpha would have silently changed how every inverse-alpha surface + // composites. + Assert.Equal( + (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.InverseAlpha)); + } + + [Fact] + public void IntegerVertexAttributesTakeAUintFormatNotANormalisedOne() + { + Assert.Equal(Format.R8G8B8A8Unorm, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4Normalized)); + // terrain_modern.vert reads locations 2-5 as uvec4; those packed bytes + // carry terrain-type, road and split-direction codes, so normalising + // them would not be an approximation - it would be garbage. + Assert.Equal(Format.R8G8B8A8Uint, VulkanViewportMapping.ToVulkan(GpuVertexFormat.UByte4UInt)); + } + + [Fact] + public void ResolveIsExpressedByAResolveTargetRatherThanAStoreOp() + { + // There is no VK_ATTACHMENT_STORE_OP_RESOLVE; a resolving attachment + // discards its multisampled contents and names a resolve image instead. + Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.Resolve)); + Assert.Equal(AttachmentStoreOp.Store, VulkanViewportMapping.ToVulkan(GpuStoreOp.Store)); + Assert.Equal(AttachmentStoreOp.DontCare, VulkanViewportMapping.ToVulkan(GpuStoreOp.DontCare)); + } + + [Fact] + public void PipelineCacheHeaderValidationRejectsAnotherDevicesBlob() + { + byte[] uuid = new byte[16]; + for (int i = 0; i < 16; i++) + uuid[i] = (byte)(i + 1); + + byte[] blob = new byte[64]; + BitConverter.GetBytes(32u).CopyTo(blob, 0); + BitConverter.GetBytes(1u).CopyTo(blob, 4); + BitConverter.GetBytes(0x1002u).CopyTo(blob, 8); + BitConverter.GetBytes(0x7550u).CopyTo(blob, 12); + uuid.CopyTo(blob, 16); + + Assert.NotNull(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid)); + // A driver update changes the cache UUID, and feeding the old blob back + // is exactly the case the header exists to catch. + uuid[0] = 0xFF; + Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0x7550, uuid)); + } + + [Fact] + public void PipelineCacheHeaderValidationRejectsTruncatedAndForeignBlobs() + { + byte[] uuid = new byte[16]; + Assert.Null(VulkanPipelineCache.ValidateHeader(null, 1, 1, uuid)); + Assert.Null(VulkanPipelineCache.ValidateHeader(new byte[8], 1, 1, uuid)); + + byte[] blob = new byte[32]; + BitConverter.GetBytes(32u).CopyTo(blob, 0); + BitConverter.GetBytes(1u).CopyTo(blob, 4); + BitConverter.GetBytes(0x8086u).CopyTo(blob, 8); + Assert.Null(VulkanPipelineCache.ValidateHeader(blob, 0x1002, 0, uuid)); + } +} diff --git a/tools/ShaderCompiler/GlslVaryingLocations.cs b/tools/ShaderCompiler/GlslVaryingLocations.cs new file mode 100644 index 00000000..41ad5a8b --- /dev/null +++ b/tools/ShaderCompiler/GlslVaryingLocations.cs @@ -0,0 +1,100 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace AcDream.Tools.ShaderCompiler; + +/// +/// Campaign V slice V6c: give every stage-to-stage varying an explicit +/// layout(location = N), which SPIR-V requires and desktop GL does not. +/// +/// This is the one place the compiler edits a shader body rather than +/// prepending to it, and it is limited to inserting a qualifier in front of a +/// declaration it already matched exactly. Locations are keyed BY NAME, taken +/// from the vertex stage's outputs and looked up by the fragment stage's inputs, +/// so the two stages cannot drift apart if someone reorders a declaration in one +/// of them. Assigning by ordinal would look identical today and produce silently +/// swapped varyings the first time an author moved a line. +/// +/// Anything already carrying a layout( qualifier is left alone, as +/// are interface blocks (which open a brace) and the redeclared +/// gl_PerVertex block that lets a core-profile shader write +/// gl_ClipDistance. +/// +internal static partial class GlslVaryingLocations +{ + // GLSL allows the interpolation qualifier on either side of the direction — + // both `out flat uvec2 v;` and `flat out uvec2 v;` appear in acdream's + // shaders — so the pattern accepts either and neither. + [GeneratedRegex( + @"^(?\s*)(?(flat|noperspective|smooth|centroid)\s+)*(?in|out)\s+(?(flat|noperspective|smooth|centroid)\s+)*(?[A-Za-z_][A-Za-z0-9_]*)\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*(?\[[^\]]*\])?\s*;\s*(?//.*)?$", + RegexOptions.ExplicitCapture)] + private static partial Regex VaryingDeclaration(); + + /// + /// Rewrites so every user varying carries a + /// location. is shared across the two + /// stages of a pair and is populated by whichever stage declares a name + /// first. + /// + internal static string Apply(string source, string stage, Dictionary locationsByName) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(locationsByName); + + string[] lines = source.Replace("\r\n", "\n").Split('\n'); + var output = new StringBuilder(); + int nextFragmentOutput = 0; + + foreach (string line in lines) + { + if (line.Contains("layout(", StringComparison.Ordinal) || line.Contains('{')) + { + output.AppendLine(line); + continue; + } + + Match match = VaryingDeclaration().Match(line); + if (!match.Success) + { + output.AppendLine(line); + continue; + } + + string direction = match.Groups["direction"].Value; + string name = match.Groups["name"].Value; + + // Vertex inputs are attributes, not varyings: their locations are + // the vertex layout the pipeline declares, and every acdream vertex + // shader already states them. Leaving an unqualified one alone makes + // the compiler say so rather than inventing a binding. + if (stage == "vert" && direction == "in") + { + output.AppendLine(line); + continue; + } + + int location; + if (stage == "frag" && direction == "out") + { + // Fragment outputs live in their own location space; acdream has + // exactly one colour attachment everywhere. + location = nextFragmentOutput++; + } + else if (locationsByName.TryGetValue(name, out int existing)) + { + location = existing; + } + else + { + location = locationsByName.Count == 0 ? 0 : locationsByName.Values.Max() + 1; + locationsByName[name] = location; + } + + string indent = match.Groups["indent"].Value; + string rest = line[indent.Length..]; + output.AppendLine($"{indent}layout(location = {location}) {rest}"); + } + + return output.ToString(); + } +} diff --git a/tools/ShaderCompiler/Program.cs b/tools/ShaderCompiler/Program.cs new file mode 100644 index 00000000..584b3906 --- /dev/null +++ b/tools/ShaderCompiler/Program.cs @@ -0,0 +1,235 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Silk.NET.Core.Native; +using Silk.NET.Shaderc; + +namespace AcDream.Tools.ShaderCompiler; + +/// +/// Campaign V slice V6c, plan §4.6: compile acdream's GLSL to committed SPIR-V. +/// +/// Usage: ShaderCompiler <shaders-dir> <output-dir>. +/// Every .vert/.frag pair in the source directory is compiled +/// through ; successes write +/// {name}.{stage}.spv and failures are recorded with the compiler's own +/// message. Either way the run writes shaders.manifest.json containing +/// the SHA-256 of every GLSL input, which the App test suite re-checks so a +/// source edit that never got recompiled fails a test rather than shipping a +/// stale binary. +/// +/// The exit code is 0 when every shader the manifest expects to succeed +/// did. A shader that is not yet Vulkan-expressible is not a build failure — it +/// is a fact about which renderer-port slices are still outstanding, recorded in +/// the manifest so it is reviewable rather than folklore. +/// +internal static class Program +{ + private static unsafe int Main(string[] args) + { + if (args.Length < 2) + { + Console.Error.WriteLine("usage: ShaderCompiler "); + return 2; + } + + string sourceDirectory = Path.GetFullPath(args[0]); + string outputDirectory = Path.GetFullPath(args[1]); + Directory.CreateDirectory(outputDirectory); + + string[] names = Directory + .EnumerateFiles(sourceDirectory, "*.vert") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => name is not null) + .Select(name => name!) + .Where(name => File.Exists(Path.Combine(sourceDirectory, $"{name}.frag"))) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + var shaderc = Shaderc.GetApi(); + Compiler* compiler = shaderc.CompilerInitialize(); + CompileOptions* options = shaderc.CompileOptionsInitialize(); + shaderc.CompileOptionsSetTargetEnv(options, TargetEnv.Vulkan, (uint)EnvVersion.Vulkan13); + shaderc.CompileOptionsSetTargetSpirv(options, SpirvVersion.Shaderc16); + // Performance rather than size: these are compiled once, committed, and + // then loaded by every launch forever. + shaderc.CompileOptionsSetOptimizationLevel(options, OptimizationLevel.Performance); + + var entries = new List(); + int failures = 0; + try + { + foreach (string name in names) + { + var stages = new List(); + // Shared across the pair so a fragment input takes the location + // its vertex output was given, by name. + var locations = new Dictionary(StringComparer.Ordinal); + foreach (string stage in (string[])["vert", "frag"]) + { + string path = Path.Combine(sourceDirectory, $"{name}.{stage}"); + string source = File.ReadAllText(path); + string hash = Sha256(source); + + string transformed; + try + { + transformed = GlslVaryingLocations.Apply( + VulkanGlslPreamble.Apply(source, stage), + stage, + locations); + } + catch (Exception error) + { + stages.Add(new ShaderStageResult(stage, hash, false, error.Message)); + continue; + } + + if (TryCompile(shaderc, compiler, options, transformed, $"{name}.{stage}", stage, out byte[] spirv, out string message)) + { + string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); + File.WriteAllBytes(target, spirv); + stages.Add(new ShaderStageResult(stage, hash, true, null)); + } + else + { + string target = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); + if (File.Exists(target)) + File.Delete(target); + stages.Add(new ShaderStageResult(stage, hash, false, Summarise(message))); + } + } + + bool ok = stages.All(stage => stage.Compiled); + if (!ok) + { + failures++; + // Half a pair is worse than none: the device would load the + // surviving stage happily and nobody could account for it. + foreach (string stage in (string[])["vert", "frag"]) + { + string orphan = Path.Combine(outputDirectory, $"{name}.{stage}.spv"); + if (File.Exists(orphan)) + File.Delete(orphan); + } + } + entries.Add(new ShaderManifestEntry(name, ok, stages)); + Console.WriteLine(ok + ? $"[shaders] {name}: ok" + : $"[shaders] {name}: NOT VULKAN-EXPRESSIBLE YET — {FirstReason(stages)}"); + } + } + finally + { + shaderc.CompileOptionsRelease(options); + shaderc.CompilerRelease(compiler); + shaderc.Dispose(); + } + + var manifest = new ShaderManifest( + "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.", + entries.OrderBy(entry => entry.Name, StringComparer.Ordinal).ToList()); + string manifestPath = Path.Combine(outputDirectory, "shaders.manifest.json"); + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(manifest, ShaderManifestJson.Options) + Environment.NewLine); + + Console.WriteLine( + $"[shaders] {entries.Count - failures}/{entries.Count} pair(s) compiled; manifest at {manifestPath}"); + return 0; + } + + private static unsafe bool TryCompile( + Shaderc shaderc, + Compiler* compiler, + CompileOptions* options, + string source, + string name, + string stage, + out byte[] spirv, + out string message) + { + ShaderKind kind = stage == "vert" ? ShaderKind.VertexShader : ShaderKind.FragmentShader; + byte[] sourceBytes = Encoding.UTF8.GetBytes(source); + byte[] nameBytes = Encoding.UTF8.GetBytes(name + "\0"); + byte[] entryBytes = Encoding.UTF8.GetBytes("main\0"); + + fixed (byte* sourcePointer = sourceBytes) + fixed (byte* namePointer = nameBytes) + fixed (byte* entryPointer = entryBytes) + { + CompilationResult* result = shaderc.CompileIntoSpv( + compiler, + sourcePointer, + (nuint)sourceBytes.Length, + kind, + namePointer, + entryPointer, + options); + try + { + CompilationStatus status = shaderc.ResultGetCompilationStatus(result); + message = SilkMarshal.PtrToString((nint)shaderc.ResultGetErrorMessage(result)) ?? string.Empty; + if (status != CompilationStatus.Success) + { + spirv = []; + return false; + } + + nuint length = shaderc.ResultGetLength(result); + spirv = new byte[(int)length]; + new ReadOnlySpan(shaderc.ResultGetBytes(result), (int)length).CopyTo(spirv); + return true; + } + finally + { + shaderc.ResultRelease(result); + } + } + } + + /// First line of a compiler message, which is the one that names the cause. + private static string Summarise(string message) + { + string[] lines = message + .Replace("\r\n", "\n") + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return lines.Length == 0 ? "unknown compiler failure" : lines[0]; + } + + private static string FirstReason(IEnumerable stages) => + stages.FirstOrDefault(stage => !stage.Compiled)?.Message ?? "unknown"; + + private static string Sha256(string text) + { + // Line endings are normalised before hashing so a checkout with + // different core.autocrlf settings does not report every shader stale. + byte[] bytes = Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n")); + return Convert.ToHexStringLower(SHA256.HashData(bytes)); + } +} + +internal sealed record ShaderStageResult( + [property: JsonPropertyName("stage")] string Stage, + [property: JsonPropertyName("sourceSha256")] string SourceSha256, + [property: JsonPropertyName("compiled")] bool Compiled, + [property: JsonPropertyName("message")] string? Message); + +internal sealed record ShaderManifestEntry( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("vulkanReady")] bool VulkanReady, + [property: JsonPropertyName("stages")] IReadOnlyList Stages); + +internal sealed record ShaderManifest( + [property: JsonPropertyName("note")] string Note, + [property: JsonPropertyName("shaders")] IReadOnlyList Shaders); + +internal static class ShaderManifestJson +{ + internal static JsonSerializerOptions Options { get; } = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} diff --git a/tools/ShaderCompiler/ShaderCompiler.csproj b/tools/ShaderCompiler/ShaderCompiler.csproj new file mode 100644 index 00000000..4526f6ed --- /dev/null +++ b/tools/ShaderCompiler/ShaderCompiler.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + latest + true + AcDream.Tools.ShaderCompiler + AcDream.Tools.ShaderCompiler + + + + + diff --git a/tools/ShaderCompiler/VulkanGlslPreamble.cs b/tools/ShaderCompiler/VulkanGlslPreamble.cs new file mode 100644 index 00000000..c611e40b --- /dev/null +++ b/tools/ShaderCompiler/VulkanGlslPreamble.cs @@ -0,0 +1,225 @@ +using System.Text; + +namespace AcDream.Tools.ShaderCompiler; + +/// +/// Campaign V slice V6c, plan §3.4 and §4.6: the Vulkan half of acdream's +/// dual-dialect GLSL. +/// +/// The GLSL sources under Rendering/Shaders are the single source of +/// truth for both backends, and they are written in the dialect GL accepts. The +/// three things Vulkan needs on top of that are not source edits — they are +/// definitions the compiler injects immediately after the #version line: +/// +/// +/// +/// ACDREAM_UBO_SET becomes set = 1,. Under GL it expands to +/// nothing, because GL keeps the SSBO and UBO binding namespaces separate and +/// BatchBuffer (SSBO binding 1) and SceneLighting (UBO binding 1) +/// can share a number. Vulkan has one namespace per set, so moving uniform +/// buffers to set 1 preserves both numbers. common.glsl has carried this +/// macro since V2 for exactly this moment. +/// The texture table becomes a real descriptor array at set 2 binding 0, +/// and ACDREAM_TEXTURE_HANDLE becomes an index rather than a packed +/// bindless handle. nonuniformEXT is required, not optional: within one +/// multi-draw dispatch different draws read different Batches[] entries, +/// and "dynamically uniform" is defined over the whole dispatch on some +/// implementations. +/// The shared 96-byte push-constant block is declared, and each loose +/// uniform name is #defined onto its member. Vulkan GLSL has no default +/// uniform block, so this is the only way the same source can declare +/// uniform mat4 uViewProjection; for GL and read a push constant for +/// Vulkan. +/// +/// +/// Injection rather than source rewriting is the whole design. A textual +/// transform over the real shader bodies would be a small compiler with its own +/// failure modes, and it would fail silently — a shader that compiles but reads +/// the wrong storage buffer looks exactly like a shader that works until +/// someone renders with it. A preamble either defines what the body needs or the +/// compile fails loudly, which is a property worth more than convenience. +/// +internal static class VulkanGlslPreamble +{ + /// + /// The loose uniform names the shared push-constant block carries, in the + /// order GpuPushConstants declares them. Any shader whose uniforms are + /// all in this list needs nothing but the preamble; any shader with a + /// uniform outside it cannot be expressed against the pinned contract and is + /// reported rather than guessed at. + /// + internal static IReadOnlyList PushConstantFields { get; } = + [ + "uViewProjection", + "uDrawIDOffset", + "uLightingMode", + "uRenderPass", + "uLightDebug", + "uTextureIndexA", + "uTextureIndexB", + "uParamA", + "uParamB", + ]; + + /// + /// Builds the text inserted after the #version directive. + /// only affects which stage-specific rewrites are + /// emitted. + /// + internal static string Build(string stage) + { + var text = new StringBuilder(); + text.AppendLine("// ---- injected by tools/compile-shaders.ps1 (Campaign V slice V6c) ----"); + text.AppendLine("// Vulkan dialect only. The GL backend compiles the same source with none"); + text.AppendLine("// of this, which is what keeps one GLSL file the single source of truth."); + text.AppendLine("#extension GL_EXT_nonuniform_qualifier : require"); + if (stage == "vert") + { + // gl_DrawID is Vulkan's shaderDrawParameters feature, and glslang + // still gates the identifier behind the ARB extension name even when + // targeting Vulkan. Declared here so a source that does not name it + // still gets it. + text.AppendLine("#extension GL_ARB_shader_draw_parameters : require"); + } + + text.AppendLine(); + text.AppendLine("// §3.4 set 1: every uniform buffer. Under GL this macro expands to nothing."); + text.AppendLine("#undef ACDREAM_UBO_SET"); + text.AppendLine("#define ACDREAM_UBO_SET set = 1,"); + text.AppendLine(); + text.AppendLine("// §4.4 set 2: the global sampled-texture table that replaces"); + text.AppendLine("// GL_ARB_bindless_texture. Variable count, partially bound,"); + text.AppendLine("// update-after-bind; the CPU never writes it per frame."); + text.AppendLine("layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];"); + text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE"); + text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (idx)"); + text.AppendLine("#define ACDREAM_TEXTURE(idx) uTextures[nonuniformEXT(uint(idx))]"); + text.AppendLine(); + text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines"); + text.AppendLine("// mid-pass invalidates neither descriptors nor constants."); + text.AppendLine("layout(push_constant) uniform AcdreamPushBlock {"); + text.AppendLine(" mat4 viewProjection;"); + text.AppendLine(" int drawIdOffset;"); + text.AppendLine(" int lightingMode;"); + text.AppendLine(" int renderPass;"); + text.AppendLine(" int lightDebug;"); + text.AppendLine(" uint textureIndexA;"); + text.AppendLine(" uint textureIndexB;"); + text.AppendLine(" float paramA;"); + text.AppendLine(" float paramB;"); + text.AppendLine("} acdreamPush;"); + text.AppendLine(); + text.AppendLine("#define uViewProjection acdreamPush.viewProjection"); + text.AppendLine("#define uDrawIDOffset acdreamPush.drawIdOffset"); + text.AppendLine("#define uLightingMode acdreamPush.lightingMode"); + text.AppendLine("#define uRenderPass acdreamPush.renderPass"); + text.AppendLine("#define uLightDebug acdreamPush.lightDebug"); + text.AppendLine("#define uTextureIndexA acdreamPush.textureIndexA"); + text.AppendLine("#define uTextureIndexB acdreamPush.textureIndexB"); + text.AppendLine("#define uParamA acdreamPush.paramA"); + text.AppendLine("#define uParamB acdreamPush.paramB"); + text.AppendLine(); + text.AppendLine("// §4.6: gl_DrawIDARB stays as written — glslang exposes it for Vulkan"); + text.AppendLine("// under the same ARB extension name. gl_InstanceIndex already includes"); + text.AppendLine("// firstInstance, so the GL idiom gl_BaseInstanceARB + gl_InstanceID"); + text.AppendLine("// collapses to it exactly."); + text.AppendLine(stage == "vert" + ? "#define gl_BaseInstanceARB 0\n" + + "#define gl_InstanceID gl_InstanceIndex\n" + + "#define gl_VertexID gl_VertexIndex" + : "// (the vertex/instance-index rewrites apply to the vertex stage only)"); + text.AppendLine("// ---- end injected preamble ----"); + return text.ToString(); + } + + /// + /// Returns with the preamble inserted after its + /// #version line and the version raised to 450, which is the floor for + /// Vulkan GLSL. Everything else is untouched: this never edits a shader body. + /// + internal static string Apply(string source, string stage) + { + ArgumentNullException.ThrowIfNull(source); + string[] lines = source.Replace("\r\n", "\n").Split('\n'); + var output = new StringBuilder(); + bool injected = false; + + foreach (string line in lines) + { + string trimmed = line.TrimStart(); + if (!injected && trimmed.StartsWith("#version", StringComparison.Ordinal)) + { + // 450 is the floor for Vulkan GLSL; a source already asking for + // more keeps it, because a shader that opted into 460 did so for + // a feature and quietly downgrading it would be a silent change. + output.AppendLine(HighestVersion(trimmed) >= 460 ? "#version 460 core" : "#version 450 core"); + output.Append(Build(stage)); + injected = true; + continue; + } + + // Bindless textures are what the set-2 descriptor array replaces; + // requiring the extension under Vulkan is an error rather than a + // no-op. (GL_ARB_shader_draw_parameters is kept — glslang still gates + // gl_DrawID behind that name when targeting Vulkan.) + if (trimmed.StartsWith("#extension GL_ARB_bindless_texture", StringComparison.Ordinal)) + { + output.AppendLine($"// (dropped for Vulkan: {trimmed})"); + continue; + } + + // Vulkan GLSL has no default uniform block, so a loose + // `uniform mat4 uViewProjection;` is illegal however it is spelled. + // Dropping the DECLARATION is what lets the preamble's #define + // redirect the name onto a push-constant member; a #define alone + // would only rewrite the declaration into a worse one. Uniform BLOCK + // declarations (which carry a `{`) are untouched, and an opaque + // sampler or a name with no push-constant home simply becomes an + // undeclared identifier — a loud, specific compiler error naming the + // shader that still needs its port slice. + if (IsDefaultBlockUniformDeclaration(trimmed)) + { + output.AppendLine($"// (declaration dropped for Vulkan: {trimmed})"); + continue; + } + + output.AppendLine(line); + } + + if (!injected) + { + throw new InvalidOperationException( + "The shader has no #version directive, so there is nowhere to inject the Vulkan preamble."); + } + + return output.ToString(); + } + + /// The numeric version a #version directive asks for, or 450 when it cannot be read. + internal static int HighestVersion(string versionDirective) + { + string[] parts = versionDirective.Split( + [' ', '\t'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length >= 2 && int.TryParse(parts[1], out int version) ? version : 450; + } + + /// + /// True for a loose (default-block) uniform declaration — + /// uniform mat4 uViewProjection; or uniform float uTexTiling[36]; — + /// and false for a uniform BLOCK, which opens a brace and is legal in both + /// dialects. + /// + internal static bool IsDefaultBlockUniformDeclaration(string trimmedLine) + { + ArgumentNullException.ThrowIfNull(trimmedLine); + if (!trimmedLine.StartsWith("uniform ", StringComparison.Ordinal)) + return false; + + // A block declaration is `uniform Name {` — possibly with the brace on + // the next line, in which case there is no semicolon here either. + int comment = trimmedLine.IndexOf("//", StringComparison.Ordinal); + string code = comment >= 0 ? trimmedLine[..comment] : trimmedLine; + return !code.Contains('{') && code.TrimEnd().EndsWith(';'); + } +} diff --git a/tools/compile-shaders.ps1 b/tools/compile-shaders.ps1 new file mode 100644 index 00000000..618da334 --- /dev/null +++ b/tools/compile-shaders.ps1 @@ -0,0 +1,99 @@ +<# +.SYNOPSIS + Campaign V slice V6c: compile acdream's GLSL to the committed SPIR-V the + Vulkan backend loads at startup. + +.DESCRIPTION + Plan §4.6 rules out runtime shader compilation: it would add a native + dependency and a startup cost for shaders that never change at runtime, and + CI runners have no Vulkan SDK. So the .spv artifacts are committed, this + script regenerates them, and an App test re-hashes the GLSL sources against + the manifest this writes so a source edit that never got recompiled fails a + test rather than shipping a stale binary. + + Two compilers are supported, in this order: + + 1. glslc from a Vulkan SDK, if one is on PATH or under $VULKAN_SDK. This is + the reference implementation and is what the plan names. + 2. tools/ShaderCompiler, a small .NET tool over Silk.NET.Shaderc — the same + shaderc library glslc is built on, through the already-pinned Silk.NET + 2.23.0 family. It exists because neither the development machine nor CI + has an SDK installed, and requiring one to build acdream would put a + 500 MB manual install between a contributor and a working checkout. + + Both paths inject the same Vulkan preamble (see + tools/ShaderCompiler/VulkanGlslPreamble.cs) so the GLSL sources stay the + single source of truth for both backends. + +.PARAMETER ShadersDirectory + Source directory. Defaults to src/AcDream.App/Rendering/Shaders. + +.PARAMETER OutputDirectory + Where .spv and shaders.manifest.json are written. Defaults to + src/AcDream.App/Rendering/Shaders/spv. + +.PARAMETER PreferSdk + Use glslc when available. On by default; pass -PreferSdk:$false to force the + managed path, which is what a comparison between the two wants. + +.EXAMPLE + tools/compile-shaders.ps1 +#> +[CmdletBinding()] +param( + [string]$ShadersDirectory, + [string]$OutputDirectory, + [bool]$PreferSdk = $true +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +if (-not $ShadersDirectory) { + $ShadersDirectory = Join-Path $repo 'src\AcDream.App\Rendering\Shaders' +} +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $ShadersDirectory 'spv' +} + +function Write-Step($message) { Write-Host "[shaders] $message" } + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + +# --- 1. Locate glslc, if the machine has a Vulkan SDK ------------------------- +$glslc = $null +if ($PreferSdk) { + $onPath = Get-Command glslc -ErrorAction SilentlyContinue + if ($onPath) { + $glslc = $onPath.Source + } + elseif ($env:VULKAN_SDK) { + $candidate = Join-Path $env:VULKAN_SDK 'Bin\glslc.exe' + if (Test-Path $candidate) { $glslc = $candidate } + } +} + +# --- 2. Compile --------------------------------------------------------------- +# Even with glslc present the managed tool does the work: it owns the preamble +# injection and the manifest, and running the same transform through two +# code paths is exactly how the two would drift. glslc's presence is reported so +# a future slice can add a cross-check between them. +if ($glslc) { + Write-Step "a Vulkan SDK glslc was found at $glslc (recorded; the managed compiler still runs)" +} +else { + Write-Step 'no Vulkan SDK glslc found; using the managed Silk.NET.Shaderc compiler' +} + +$tool = Join-Path $repo 'tools\ShaderCompiler\ShaderCompiler.csproj' +Write-Step 'building the shader compiler' +& dotnet build $tool -c Release --nologo -v q | Out-Null +if ($LASTEXITCODE -ne 0) { throw "Shader compiler build failed with exit code $LASTEXITCODE." } + +$binary = Join-Path $repo 'tools\ShaderCompiler\bin\Release\net10.0\AcDream.Tools.ShaderCompiler.dll' +if (-not (Test-Path $binary)) { throw "Shader compiler not found at $binary." } + +Write-Step "compiling $ShadersDirectory -> $OutputDirectory" +& dotnet $binary $ShadersDirectory $OutputDirectory +if ($LASTEXITCODE -ne 0) { throw "Shader compilation failed with exit code $LASTEXITCODE." } + +Write-Step 'done'