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'