feat(render): Campaign V slice V6c - SPIR-V, pipelines, passes, and a Vulkan frame that draws

The last of V6's three commits, and the one that makes the backend render.
Plan sections: 4.5 (pipelines and the persisted cache), 4.6 (shaders and the
committed .spv), 4.7 and 3.3 (clip space, the Y flip and winding), 4.9 and 4.10
(swapchain format and the scissor convention), 4.11 (the probe shader V5
deferred), 5.4 (Target: null means the swapchain image, literally).

WHAT RUNS. ACDREAM_RENDER_BACKEND=vulkan now renders a real scene through the
whole RHI on the RX 9070 XT: 60,000-plus frames per twelve-second run, 4x MSAA
resolving into a B8G8R8A8_UNORM swapchain, GPU timer scopes resolving, a
screenshot taken through IGpuDevice.CaptureBackbuffer, and a clean
CloseMainWindow exit with the allocator reporting three device-memory objects.

WHAT IT DRAWS, AND WHY IT IS NOT THE GAME. 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 by 5.5.5 so the world renderers are still raw GL; and the two
renderers that DO speak the RHI - TextRenderer and DebugLineRenderer, ported at
V4a - both throw for any device that is not a GlGpuDevice, because their loose
uniforms and their classic texture-unit sprite binding have no home in the
pinned contract yet. Converting them is a V4-class change with its own GL pixel
gate, outside this slice's file list.

So the backend is exercised through the contract by a scene of our own, and it
is not a toy. It uses a device-local mesh arena filled through the staging ring,
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, a BC1 texture with a CPU-built mip chain beside an
uncompressed one with a vkCmdBlitImage chain, one multi-draw-indirect covering
five quads 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, timer scopes, and an MSAA colour attachment resolving into the
swapchain image.

ORIENTATION, BY INSPECTION. Slice V5's screenshot was a uniform clear and its
orientation was right "by construction" - which a uniform clear cannot show. The
scene is therefore deliberately asymmetric in both axes: a quadrant card that is
red top-left, green top-right, blue bottom-left and white bottom-right, four
differently tinted markers at four different corners, and an open L of lines
whose short stub rises at its right end. The captured PNG reads correctly in
every one of those, including a miniature of the same card in the bottom-right
whose own quadrants are also the right way up. The negative viewport height, the
front-face inversion and the capture path agree.

THE SHADER TOOLCHAIN, AND WHAT IT FOUND. tools/compile-shaders.ps1 drives
tools/ShaderCompiler, a small out-of-solution .NET tool over Silk.NET.Shaderc -
the same shaderc glslc is built on, through the already-pinned Silk 2.23.0
family. glslc is preferred when a Vulkan SDK is present and reported when it is;
neither this machine nor CI has one, and requiring a 500 MB manual install
between a contributor and a working checkout is not a reasonable price for a
build step. The GLSL sources stay the single source of truth: the Vulkan dialect
arrives as a preamble injected after the #version line - ACDREAM_UBO_SET becomes
"set = 1,", the texture table becomes a set-2 descriptor array with a required
nonuniformEXT accessor, and the shared 96-byte push block is declared with each
loose uniform name defined onto its member. The only edits to a shader BODY are
mechanical and dialect-level: dropping default-block uniform declarations, which
Vulkan GLSL has no such thing as, and assigning explicit varying locations BY
NAME across a pair, because ordinal assignment would look identical today and
silently swap varyings the first time an author reordered a line.

Run over the eight production pairs, exactly one thing happened: none of them
compiled, and every failure is a specific source-level fact belonging to a
renderer-port slice that has not landed. debug_line needs uView/uProjection
converged into one uViewProjection - two matrices are 128 bytes and the shared
block is 96. mesh_modern and particle still pass a uvec2 bindless handle as a
varying, which is V4t's GpuTextureSlot retype. sky has ten loose uniforms and
wants a UBO. ui_text needs uScreenSize/uUseTexture/uTex. particle_mesh needs
uTextureIndex to become uTextureIndexA. terrain_modern needs V4d-1's matrix
convergence. mesh is the legacy pair with no RHI consumer at all. That inventory
is committed as shaders.manifest.json, with each source's SHA-256 and the
compiler's own message, and a test re-hashes it so an edited shader that never
got recompiled fails a build rather than shipping a stale binary.

vk_probe is the pair that does compile, and it is the shader 4.11 already asked
for: V5 recorded "build one real pipeline from the committed .spv" as its single
deliberate deviation because no toolchain existed. It is Vulkan-dialect only and
no GL renderer draws with it, so it forks nothing; it retires when the ported
world renderers become the backend's own proof.

DESCRIPTORS. Sets 0 and 1 are DYNAMIC buffer descriptors bound per flight slot,
so a per-draw range change costs a dynamic offset in vkCmdBindDescriptorSets
rather than a vkUpdateDescriptorSets in the hot path - which is what keeps 4.4's
zero-writes-per-frame property true for buffers as well as for textures. Ten
dynamic storage descriptors is above Vulkan's guaranteed minimum of four, so it
is a real requirement rather than a free choice, it fails loudly at layout
creation on a device that cannot serve it, and V9's lavapipe row must confirm
it. Unused bindings point at a shared dummy range so there is ONE set layout and
one pipeline layout; that is why binding a second pipeline mid-pass costs
nothing and disturbs neither the descriptors nor the push constants.

THE ONE MAPPING FUNCTION. VulkanViewportMapping holds the whole coordinate
reconciliation: negative viewport height, the front-face inversion that pairs
with it, and - separately - the scissor flip, which the viewport sign does NOT
perform. The V3 audit flagged that as a concrete V6 acceptance item and it is
the subtle one: vkCmdSetScissor is always top-left-origin, NdcScissorRect emits
GL bottom-left rectangles, and getting it wrong clips a doorway aperture from
the wrong edge in a scene that has one. Clip space needs nothing, as 4.7
concluded: the cameras already build [0,1]-convention projections.

CONTRACT GAP, RECORDED NOT PAPERED OVER. GpuPipelineDescription cannot name its
colour-attachment format, and Vulkan bakes that into a pipeline. Offscreen
targets therefore adopt the swapchain's B8G8R8A8_UNORM rather than a literal
RGBA order - invisible above the API, because an image is sampled through its
format's component mapping and the one CPU readback swizzles explicitly. The
honest fix is a colour-format field added in a reviewed contract commit, exactly
as GpuBlendMode.InverseAlpha and GpuVertexFormat.UByte4UInt were added when V4c
and V4d met the same wall. It is documented at
VulkanTextureFormatMapping.CanonicalColorAttachmentFormat.

The pipeline cache is persisted to the cache directory and validated by its
32-byte header against this device's vendor, device and cache UUID before use.
Drivers are required to ignore incompatible blobs, but "required to" is a poor
foundation for something that runs before anything else in the process, and the
check costs 32 bytes of comparison. Two consecutive launches report "cold" then
"reused".

Gates: Release build clean; App suite 4056 passed / 3 skipped (4037 at V6b plus
19 new); offline pixel gate PASS at a differing fraction of 5.15e-05 with a
same-commit control immediately after it at 2.84e-05 - 29 and 16 pixels of
563,200, the same class of ambient variation the campaign's 15-23 band records,
and roughly 19x under the 0.001 threshold on a commit that changes no GL code
path.

Validation layers could not be run: this machine has no Vulkan SDK, no
HKLM\SOFTWARE\Khronos\Vulkan\ExplicitLayers key, no VK_LAYER_PATH and no
VkLayer_khronos_validation.json anywhere on disk. Plan 7 already requires one
validation-clean run at V7; it needs the SDK installed first and is reported
rather than assumed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 08:17:24 +02:00
parent 9eae496301
commit 234fe91d3b
24 changed files with 3937 additions and 319 deletions

View file

@ -58,6 +58,12 @@
<None Update="Rendering\Shaders\*.*"> <None Update="Rendering\Shaders\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<!-- Campaign V slice V6c: the committed SPIR-V the Vulkan backend loads at
startup, plus the manifest the freshness test re-hashes. Regenerated by
tools/compile-shaders.ps1; never compiled at runtime (plan §4.6). -->
<None Update="Rendering\Shaders\spv\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship <!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. --> next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml"> <None Include="UI\assets\**\*.xml">

View file

@ -66,14 +66,19 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
private VulkanQueueFamilyChoice? _families; private VulkanQueueFamilyChoice? _families;
private VulkanSwapchain? _swapchain; private VulkanSwapchain? _swapchain;
private CommandPool[] _commandPools = [];
private CommandBuffer[] _commandBuffers = [];
private Semaphore[] _imageAcquired = [];
private Semaphore _timeline;
private ulong _frameSerial; private ulong _frameSerial;
private bool _recreateAtFrameBoundary; private bool _recreateAtFrameBoundary;
private bool _disposed; 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( internal VulkanBringUpHost(
RuntimeOptions options, RuntimeOptions options,
GraphicalHostPlatformServices platform, GraphicalHostPlatformServices platform,
@ -253,6 +258,13 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
_graphicsQueue, _graphicsQueue,
families.GraphicsFamily); families.GraphicsFamily);
_features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
_limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice);
_formats = VulkanPhysicalDeviceInspector.ReadFormats(
vk,
_physicalDevice,
VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats));
var record = new VulkanCapabilityRecord( var record = new VulkanCapabilityRecord(
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
_platform.RuntimeIdentifier, _platform.RuntimeIdentifier,
@ -278,12 +290,9 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
created.EnabledExtensions, created.EnabledExtensions,
families.GraphicsFamily, families.GraphicsFamily,
families.PresentFamily, families.PresentFamily,
VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice), _features,
VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice), _limits,
VulkanPhysicalDeviceInspector.ReadFormats( _formats,
vk,
_physicalDevice,
VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)),
surfaceSupport, surfaceSupport,
probe, probe,
SupportFailures: []); SupportFailures: []);
@ -310,66 +319,6 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
_log($"vulkan: device selection — {choice.Reason}"); _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() private bool RecreateSwapchain()
{ {
Vector2D<int> framebuffer = _window!.FramebufferSize; Vector2D<int> framebuffer = _window!.FramebufferSize;
@ -386,11 +335,118 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
return _swapchain!.Recreate(_pacing, width, height); return _swapchain!.Recreate(_pacing, width, height);
} }
/// <summary>
/// Campaign V slice V6c: build the RHI backend and the scene that proves it.
///
/// <para>The host's own command pools, acquire semaphores and timeline are
/// gone — <see cref="VulkanGpuDevice"/> 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.</para>
/// </summary>
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")}");
}
/// <summary>Where the committed SPIR-V lives beside the binary.</summary>
private static string ShaderSpirvDirectory() =>
Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv");
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// The frame loop: record the verification scene through the RHI, present,
/// and capture one screenshot once the scene has settled.
/// </summary>
private void Present() private void Present()
{ {
IWindow window = _window!; IWindow window = _window!;
VulkanGpuDevice device = _gpuDevice!;
VulkanRhiScene scene = _scene!;
FrameScreenshotController? screenshots = CreateScreenshotController(); FrameScreenshotController? screenshots = CreateScreenshotController();
bool screenshotRequested = false; bool screenshotRequested = false;
DateTimeOffset started = DateTimeOffset.UtcNow;
while (!window.IsClosing) while (!window.IsClosing)
{ {
@ -407,21 +463,44 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
Thread.Sleep(16); Thread.Sleep(16);
continue; 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; 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; screenshotRequested = true;
if (screenshots.TryRequest(ScreenshotName, out string error)) if (screenshots.TryRequest(ScreenshotName, out string error))
{ {
VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; screenshots.CapturePending((int)configuration.Width, (int)configuration.Height);
_lastPresentedImage = imageIndex; ReportTimings(device);
screenshots.CapturePending(
(int)configuration.Width,
(int)configuration.Height);
} }
else else
{ {
@ -431,10 +510,25 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
} }
VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)"); 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() private FrameScreenshotController? CreateScreenshotController()
{ {
@ -442,205 +536,20 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
return null; return null;
return new FrameScreenshotController( return new FrameScreenshotController(
(_, _) => _swapchain!.CaptureImage( // IGpuDevice.CaptureBackbuffer is documented top-left-origin and a
_graphicsQueue, // Vulkan image already is; FrameScreenshotController flips what it
_families!.GraphicsFamily, // receives because glReadPixels hands back bottom-up rows. Flipping
_lastPresentedImage), // 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, _options.AutomationArtifactDirectory,
_log); _log);
} }
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary> /// <summary>
/// Teardown in strict reverse-construction order. Every handle is checked /// Teardown in strict reverse-construction order. Every handle is checked
/// before destruction because <see cref="Run"/> can throw at any stage — a /// before destruction because <see cref="Run"/> can throw at any stage — a
@ -658,40 +567,33 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
{ {
vk.DeviceWaitIdle(_device); 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?.Dispose();
_swapchain = null; _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); vk.DestroyDevice(_device, null);
_device = default; _device = default;
} }
else else
{ {
_scene?.Dispose();
_scene = null;
_gpuDevice?.Dispose();
_gpuDevice = null;
_swapchain?.Dispose(); _swapchain?.Dispose();
_swapchain = null; _swapchain = null;
} }
_debugNames.Dispose();
_debugNames = VulkanDebugNames.Disabled;
if (vk is not null && _surfaceApi is not null && _surface.Handle != 0) if (vk is not null && _surfaceApi is not null && _surface.Handle != 0)
{ {
_surfaceApi.DestroySurface(_instance, _surface, null); _surfaceApi.DestroySurface(_instance, _surface, null);

View file

@ -0,0 +1,233 @@
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// 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.
///
/// <para>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
/// <c>vkUpdateDescriptorSets</c> in the hot path and reintroduce the exact cost
/// the texture table was designed to remove. So each binding is a
/// <c>*_BUFFER_DYNAMIC</c> descriptor pointing at the whole ring, and the
/// per-draw offset travels in <c>vkCmdBindDescriptorSets</c>'s dynamic-offset
/// array, which is free.</para>
///
/// <para><b>Every binding is always bound, whether a renderer uses it or
/// not.</b> 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.</para>
/// </summary>
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;
/// <summary>Bindings 0..3 of set 1; only 1 (SceneLighting) and 3 (terrain tiling) are used.</summary>
internal const int UniformBindingCount = 4;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Points a storage binding at a range, re-writing the descriptor only when the BUFFER changes.</summary>
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;
}
/// <summary>Binds all three sets with the current dynamic offsets.</summary>
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);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using Silk.NET.Vulkan; using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk; namespace AcDream.App.Rendering.Gpu.Vk;
@ -8,11 +9,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// ///
/// <para>Split into its own file because the three V6 commits divide along /// <para>Split into its own file because the three V6 commits divide along
/// exactly this line: V6a landed memory, buffers, rings and the frame timeline — /// exactly this line: V6a landed memory, buffers, rings and the frame timeline —
/// everything in <c>VulkanGpuDevice.cs</c> — V6b lands textures, samplers, the /// everything in <c>VulkanGpuDevice.cs</c> — V6b landed textures, samplers, the
/// descriptor table and render targets here, and pipelines, passes and readback /// descriptor table and render targets, and V6c completes it with pipelines from
/// arrive at V6c. Until each lands, the corresponding contract member throws /// committed SPIR-V, dynamic-rendering passes, timestamps and readback.</para>
/// with the slice named, rather than returning something that would fail later
/// and further away.</para>
/// </summary> /// </summary>
internal sealed unsafe partial class VulkanGpuDevice internal sealed unsafe partial class VulkanGpuDevice
{ {
@ -20,19 +19,34 @@ internal sealed unsafe partial class VulkanGpuDevice
private VulkanTextureTable? _textureTable; private VulkanTextureTable? _textureTable;
private VulkanBackbufferAttachments? _backbufferAttachments; private VulkanBackbufferAttachments? _backbufferAttachments;
private VulkanGpuTexture? _defaultTexture; private VulkanGpuTexture? _defaultTexture;
private VulkanPipelineCache? _pipelineCache;
private VulkanGpuTimerPool? _timerPool;
private VulkanGpuBuffer? _bindingDummy;
private VulkanFrameBindings[] _frameBindings = [];
private readonly Dictionary<GpuSamplerDescription, VulkanGpuSampler> _samplers = []; private readonly Dictionary<GpuSamplerDescription, VulkanGpuSampler> _samplers = [];
private readonly Dictionary<string, (ShaderModule Vertex, ShaderModule Fragment)> _shaderModules = [];
private string _shaderSpirvDirectory = string.Empty;
private float _maxSamplerAnisotropy = 1f; private float _maxSamplerAnisotropy = 1f;
private VulkanGpuPassEncoder? _openPass;
private bool _openPassIsBackbuffer;
private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory) private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory)
{ {
_ = shaderSpirvDirectory; _shaderSpirvDirectory = shaderSpirvDirectory ?? string.Empty;
_ = pipelineCacheDirectory;
_vk.GetPhysicalDeviceProperties(_physicalDevice, out PhysicalDeviceProperties properties); _vk.GetPhysicalDeviceProperties(_physicalDevice, out PhysicalDeviceProperties properties);
_maxSamplerAnisotropy = properties.Limits.MaxSamplerAnisotropy; _maxSamplerAnisotropy = properties.Limits.MaxSamplerAnisotropy;
_layouts = VulkanPipelineLayouts.Create(_vk, _device); _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( _textureTable = new VulkanTextureTable(
_vk, _vk,
_device, _device,
@ -59,7 +73,7 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames, _debugNames,
new GpuTextureDescription( new GpuTextureDescription(
"vk-default-white", "vk-default-white",
GpuTextureKind.Texture2D, GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm, GpuTextureFormat.Rgba8Unorm,
Width: 1, Width: 1,
Height: 1, Height: 1,
@ -70,27 +84,79 @@ internal sealed unsafe partial class VulkanGpuDevice
var defaultSampler = (VulkanGpuSampler)CreateSampler(GpuSamplerDescription.UiNearest); var defaultSampler = (VulkanGpuSampler)CreateSampler(GpuSamplerDescription.UiNearest);
_textureTable.SetScrubTarget(_defaultTexture.View, defaultSampler.Handle); _textureTable.SetScrubTarget(_defaultTexture.View, defaultSampler.Handle);
DefaultTextureSlot = _textureTable.Register(_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) private void EndFrameResources(int slotIndex, CommandBuffer commands)
{ {
_ = slotIndex; _ = slotIndex;
_ = commands; _ = 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() 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) foreach (VulkanGpuSampler sampler in _samplers.Values)
sampler.Dispose(); sampler.Dispose();
_samplers.Clear(); _samplers.Clear();
_bindingDummy?.Dispose();
_bindingDummy = null;
_defaultTexture?.Dispose(); _defaultTexture?.Dispose();
_defaultTexture = null; _defaultTexture = null;
_flights.DrainAll(); _flights.DrainAll();
_timerPool?.Dispose();
_timerPool = null;
_pipelineCache?.Dispose();
_pipelineCache = null;
_backbufferAttachments?.Dispose(); _backbufferAttachments?.Dispose();
_backbufferAttachments = null; _backbufferAttachments = null;
_textureTable?.Dispose(); _textureTable?.Dispose();
@ -111,8 +177,16 @@ internal sealed unsafe partial class VulkanGpuDevice
internal VulkanBackbufferAttachments BackbufferAttachments => internal VulkanBackbufferAttachments BackbufferAttachments =>
_backbufferAttachments ?? throw new InvalidOperationException("The backbuffer attachments have not been created."); _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.");
/// <summary>True when a compatible pipeline cache blob was reused from disk.</summary>
internal bool PipelineCacheLoadedFromDisk => _pipelineCache?.LoadedFromDisk ?? false;
public GpuTextureSlot DefaultTextureSlot { get; private set; } = GpuTextureSlot.Unassigned; public GpuTextureSlot DefaultTextureSlot { get; private set; } = GpuTextureSlot.Unassigned;
public IGpuTimerPool Timers => TimerPool;
/// <summary> /// <summary>
/// Matches the backbuffer pass's attachments to the swapchain's current /// Matches the backbuffer pass's attachments to the swapchain's current
/// extent and the requested sample count. Called by the host after a /// 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)); _flights.Retire(() => table.ReleaseNow(slot));
} }
public IGpuTimerPool Timers => throw NotYet("GPU timer scopes", "V6c"); /// <summary>
/// Builds a pipeline from the committed SPIR-V for
/// <see cref="GpuShaderSet.Name"/>. 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.
/// </summary>
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(description);
public IGpuPipeline CreatePipeline(GpuPipelineDescription description) => (ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name);
throw NotYet($"pipelines ('{description?.Name}')", "V6c"); // 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) => private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name)
throw NotYet($"render passes ('{description.Name}')", "V6c"); {
if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing))
return existing;
public byte[] CaptureBackbuffer(int width, int height) => ShaderModule vertex = CreateShaderModule(name, "vert");
throw NotYet("backbuffer capture", "V6c"); ShaderModule fragment = CreateShaderModule(name, "frag");
_shaderModules[name] = (vertex, fragment);
return (vertex, fragment);
}
private static NotSupportedException NotYet(string what, string slice) => private ShaderModule CreateShaderModule(string name, string stage)
new($"The Vulkan backend does not implement {what} yet; it lands at Campaign V slice {slice}."); {
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;
}
}
/// <summary>
/// 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
/// <see cref="GpuPipelineDescription"/> mean what they say even though the
/// state itself is dynamic.
/// </summary>
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);
}
/// <summary>
/// Opens a dynamic-rendering block for <paramref name="description"/>.
///
/// <para>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.</para>
/// </summary>
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);
}
/// <summary>
/// Reads the presented image back as tightly packed top-left-origin RGBA8.
///
/// <para>The swapchain is <c>B8G8R8A8_UNORM</c> (plan §4.9), so the channels
/// are swizzled on the CPU to preserve <c>FrameScreenshotController</c>'s
/// RGBA byte contract — the same seam every automated screenshot gate already
/// uses, so the comparison tooling is unaffected by the backend swap.</para>
/// </summary>
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,
&region);
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();
}
}
} }

View file

@ -345,6 +345,8 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
_vk.BeginCommandBuffer(_commandBuffers[slot], &begin), _vk.BeginCommandBuffer(_commandBuffers[slot], &begin),
"vkBeginCommandBuffer (frame)"); "vkBeginCommandBuffer (frame)");
_backbufferRenderingReady = false;
BeginFrameResources(slot); BeginFrameResources(slot);
var opened = new VulkanGpuFrame(this, slot, serial); 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) if (_acquiredImageIndex is { } toPresent && _backbuffer is not null)
{ {
_lastPresentedImageIndex = toPresent;
PresentSucceeded = _backbuffer.Present(toPresent); PresentSucceeded = _backbuffer.Present(toPresent);
_acquiredImageIndex = null; _acquiredImageIndex = null;
} }

View file

@ -0,0 +1,220 @@
using System.Runtime.CompilerServices;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c: <see cref="IGpuPassEncoder"/> on Vulkan.
///
/// <para>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
/// <see cref="VulkanViewportMapping"/>), the descriptor binding (three sets,
/// bound once, never rewritten per draw) and the ring-backed storage bindings.
/// </para>
///
/// <para><b>Storage and uniform bindings go through a dynamic descriptor
/// set.</b> 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.</para>
/// </summary>
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);
}

View file

@ -0,0 +1,401 @@
using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c, plan §4.5: <see cref="IGpuPipeline"/> on Vulkan.
///
/// <para>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
/// <em>not</em> dynamic, so they are what actually define the pipeline list —
/// roughly a dozen objects, all known statically and all built at startup.</para>
///
/// <para>No render pass or framebuffer object appears anywhere: the attachment
/// formats are declared inline through <c>VK_KHR_dynamic_rendering</c>, which is
/// core in 1.3. That is what lets a pass be described by
/// <see cref="GpuPassDescription"/> alone rather than by an object that has to be
/// created, cached and matched.</para>
/// </summary>
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));
}
}
/// <summary>
/// Campaign V slice V6c, plan §4.5: the persisted <c>VkPipelineCache</c>.
///
/// <para>Every pipeline is built at startup, which on a cold cache costs a few
/// hundred milliseconds once. Persisting the cache to
/// <c>ApplicationPathSet.CacheDirectory</c> turns every later launch into
/// milliseconds — and, unlike GL, no frame ever pays a hidden first-draw driver
/// recompile.</para>
///
/// <para>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 <c>vkCreatePipelineCache</c>. 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.</para>
/// </summary>
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; }
/// <summary>True when a compatible cache blob was found and reused.</summary>
internal bool LoadedFromDisk { get; }
/// <summary>
/// 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.
/// </summary>
internal static byte[]? ValidateHeader(
byte[]? blob,
uint vendorId,
uint deviceId,
ReadOnlySpan<byte> 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<byte> pipelineCacheUuid)
{
try
{
if (!File.Exists(path))
return null;
return ValidateHeader(File.ReadAllBytes(path), vendorId, deviceId, pipelineCacheUuid);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -0,0 +1,210 @@
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c: <see cref="IGpuTimerPool"/> on Vulkan timestamps.
///
/// <para>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 <see cref="TryResolve"/> 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.</para>
///
/// <para><c>hostQueryReset</c> is why the pool is reset from the CPU rather than
/// with <c>vkCmdResetQueryPool</c>: the reset is free and costs no command-buffer
/// space in a frame that measures nothing.</para>
///
/// <para>Unlike GL's <c>TIME_ELAPSED</c> 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.</para>
/// </summary>
internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable
{
/// <summary>Distinct named scopes measurable per frame. Two queries each.</summary>
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<string>[] _scopeNames;
private readonly Dictionary<string, double> _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<string>[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; }
/// <summary>
/// Reads back the scopes recorded into <paramref name="slotIndex"/> the last
/// time it was used and clears it for reuse. Called at frame start, after
/// the timeline has proved that frame complete.
/// </summary>
internal void BeginSlot(int slotIndex)
{
if (!IsSupported || _disposed)
return;
_currentSlot = slotIndex;
List<string> 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);
}
/// <summary>Opens a scope. Dispose the result to write the closing timestamp.</summary>
internal IDisposable BeginScope(CommandBuffer commands, string scopeName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scopeName);
if (!IsSupported || _disposed)
return NullScope.Instance;
List<string> 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<string> names)
{
int queryCount = names.Count * 2;
Span<ulong> 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()
{
}
}
}

View file

@ -81,7 +81,21 @@ internal static unsafe class VulkanPipelineLayouts
} }
} }
/// <summary>Set 0 — the ten storage bindings <see cref="GpuBindingModel"/> pins.</summary> /// <summary>
/// Set 0 — the ten storage bindings <see cref="GpuBindingModel"/> pins.
///
/// <para>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.</para>
///
/// <para>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.</para>
/// </summary>
internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device) internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
{ {
int count = (int)GpuBindingModel.StorageBindingCount; int count = (int)GpuBindingModel.StorageBindingCount;
@ -91,7 +105,7 @@ internal static unsafe class VulkanPipelineLayouts
bindings[i] = new DescriptorSetLayoutBinding bindings[i] = new DescriptorSetLayoutBinding
{ {
Binding = (uint)i, Binding = (uint)i,
DescriptorType = DescriptorType.StorageBuffer, DescriptorType = DescriptorType.StorageBufferDynamic,
DescriptorCount = 1, DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
}; };
@ -116,14 +130,14 @@ internal static unsafe class VulkanPipelineLayouts
bindings[0] = new DescriptorSetLayoutBinding bindings[0] = new DescriptorSetLayoutBinding
{ {
Binding = GpuBindingModel.UniformSceneLighting, Binding = GpuBindingModel.UniformSceneLighting,
DescriptorType = DescriptorType.UniformBuffer, DescriptorType = DescriptorType.UniformBufferDynamic,
DescriptorCount = 1, DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
}; };
bindings[1] = new DescriptorSetLayoutBinding bindings[1] = new DescriptorSetLayoutBinding
{ {
Binding = GpuBindingModel.UniformTerrainTiling, Binding = GpuBindingModel.UniformTerrainTiling,
DescriptorType = DescriptorType.UniformBuffer, DescriptorType = DescriptorType.UniformBufferDynamic,
DescriptorCount = 1, DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
}; };

View file

@ -0,0 +1,514 @@
using System.Numerics;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c: the scene that proves the Vulkan RHI end to end.
///
/// <para><b>Why this exists.</b> 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
/// <c>TextRenderer</c>/<c>DebugLineRenderer</c> — the two renderers that DO
/// speak the RHI — currently require a <c>GlGpuDevice</c> 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.</para>
///
/// <para>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
/// <c>gl_DrawID</c> 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.</para>
///
/// <para><b>Deliberately asymmetric.</b> 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.</para>
/// </summary>
internal sealed class VulkanRhiScene : IDisposable
{
/// <summary>The quadrant marker colours, in the order the layout places them.</summary>
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<IDisposable> _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;
}
/// <summary>std430 <c>BatchData</c> at the pinned 16-byte stride.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
public uint TextureIndex;
public uint TextureLayer;
public uint Tint;
public uint Pad;
}
/// <summary>The indirect command layout <c>vkCmdDrawIndexedIndirect</c> reads.</summary>
[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<Vertex>(),
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<Vertex>(vertices));
_indexArena.Upload(0, MemoryMarshal.AsBytes<ushort>(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; }
/// <summary>Records one complete frame: offscreen pass, then the backbuffer pass.</summary>
internal void Render(IGpuFrame frame, uint width, uint height, double seconds)
{
ArgumentNullException.ThrowIfNull(frame);
RenderOffscreen(frame);
RenderMain(frame, width, height, seconds);
}
/// <summary>
/// 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.
/// </summary>
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<DrawIndexedIndirectCommand>(),
GpuRingUsage.Indirect);
Span<DrawIndexedIndirectCommand> span = commands.AsSpan<DrawIndexedIndirectCommand>();
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<DrawIndexedIndirectCommand>());
// 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<Matrix4x4> transforms)
{
GpuRingAllocation allocation = frame.AllocateRing(
transforms.Length * Marshal.SizeOf<Matrix4x4>(),
GpuRingUsage.Storage);
transforms.CopyTo(allocation.AsSpan<Matrix4x4>());
encoder.BindStorageBuffer(
GpuBindingModel.StorageInstances,
allocation.Buffer,
allocation.OffsetBytes,
(uint)allocation.Data.Length);
}
private static void WriteBatches(
IGpuFrame frame,
IGpuPassEncoder encoder,
ReadOnlySpan<BatchData> batches)
{
GpuRingAllocation allocation = frame.AllocateRing(
batches.Length * GpuBindingModel.GpuBatchDataStrideBytes,
GpuRingUsage.Storage);
batches.CopyTo(allocation.AsSpan<BatchData>());
encoder.BindStorageBuffer(
GpuBindingModel.StorageBatches,
allocation.Buffer,
allocation.OffsetBytes,
(uint)allocation.Data.Length);
}
/// <summary>
/// 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.
/// </summary>
private static Vertex[] BuildVertices(
out ushort[] indices,
out uint quadIndexCount,
out uint lineFirstVertex,
out uint lineVertexCount)
{
var vertices = new List<Vertex>
{
// 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];
}
/// <summary>
/// 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 <c>vkCmdBlitImage</c>, which is the path only uncompressed
/// formats can take.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
}

View file

@ -17,6 +17,36 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// </summary> /// </summary>
internal static class VulkanTextureFormatMapping internal static class VulkanTextureFormatMapping
{ {
/// <summary>
/// The one colour-attachment format every acdream pipeline renders into.
///
/// <para>Vulkan bakes attachment formats into a pipeline (dynamic rendering
/// declares them in <c>VkPipelineRenderingCreateInfo</c>), and a pipeline
/// whose format disagrees with the pass it is used in is invalid usage. But
/// <c>GpuPipelineDescription</c> — pinned at V0 — has no field for the
/// attachment format: it names <c>SampleCount</c> and nothing else about the
/// target. Without one, an offscreen pipeline built for
/// <c>Rgba8UnormRenderTarget</c> and a backbuffer pipeline built for the
/// <c>B8G8R8A8_UNORM</c> swapchain (plan §4.9) could not share a description,
/// and the backend would have no way to tell them apart.
/// </para>
///
/// <para>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 <c>texture()</c> 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 — <see cref="IGpuDevice.CaptureBackbuffer"/> — swizzles explicitly.
/// </para>
///
/// <para>Recorded rather than hidden: this is a real expressiveness gap in
/// the pinned contract, and the honest fix is a colour-format field on
/// <c>GpuPipelineDescription</c> in a reviewed contract commit, exactly as
/// <c>GpuBlendMode.InverseAlpha</c> and <c>GpuVertexFormat.UByte4UInt</c>
/// were added when V4c and V4d met the same wall.</para>
/// </summary>
internal const Format CanonicalColorAttachmentFormat = Format.B8G8R8A8Unorm;
/// <summary>The Vulkan format acdream uploads this surface as. BC formats transcode nothing.</summary> /// <summary>The Vulkan format acdream uploads this surface as. BC formats transcode nothing.</summary>
internal static Format FormatOf(GpuTextureFormat format) => format switch internal static Format FormatOf(GpuTextureFormat format) => format switch
{ {
@ -25,7 +55,9 @@ internal static class VulkanTextureFormatMapping
GpuTextureFormat.Bc1Unorm => Format.BC1RgbaUnormBlock, GpuTextureFormat.Bc1Unorm => Format.BC1RgbaUnormBlock,
GpuTextureFormat.Bc2Unorm => Format.BC2UnormBlock, GpuTextureFormat.Bc2Unorm => Format.BC2UnormBlock,
GpuTextureFormat.Bc3Unorm => Format.BC3UnormBlock, 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, GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."),
}; };

View file

@ -0,0 +1,172 @@
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6c, plan §3.3 and §4.7: the ONE place the Vulkan backend
/// reconciles its coordinate conventions with GL's.
///
/// <para>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.</para>
///
/// <para><b>Scissor does NOT flip with the viewport.</b> The V3 audit called
/// this out as a concrete acceptance item (plan §4.10, item 1):
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// and <c>NdcScissorRect.ToPixels</c> 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.</para>
///
/// <para>Clip space itself needs nothing. acdream's cameras already build
/// projections with <c>Matrix4x4.CreatePerspectiveFieldOfView</c>, 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.</para>
/// </summary>
internal static class VulkanViewportMapping
{
/// <summary>
/// Converts a GL-convention viewport rectangle into the negative-height
/// Vulkan viewport that reproduces it.
///
/// <para>The Y origin becomes the rectangle's TOP edge measured from the
/// attachment's top — that is, <c>attachmentHeight - (y + height)</c> flipped
/// to the bottom of the flipped viewport, which reduces to
/// <c>attachmentHeight - y</c>. Height is then negated.</para>
/// </summary>
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,
};
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// 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.
/// </summary>
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."),
};
/// <summary>The blend factors each retail translucency mode composites with.</summary>
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."),
};
/// <summary>
/// Store ops. <see cref="GpuStoreOp.Resolve"/> is not itself an
/// <c>AttachmentStoreOp</c> — 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.
/// </summary>
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."),
};
}

View file

@ -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
}
]
}
]
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
/// <summary>
/// Campaign V slice V6c, plan §4.6: the committed SPIR-V must match the GLSL it
/// was compiled from.
///
/// <para>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 <c>.spv</c> 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.</para>
///
/// <para>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.</para>
/// </summary>
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<StageEntry> Stages);
private sealed record Manifest(string Note, IReadOnlyList<ShaderEntry> 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<Manifest>(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<string>();
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.");
}
}

View file

@ -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;
/// <summary>
/// Campaign V slice V6c — the coordinate reconciliation (plan §3.3, §4.7,
/// §4.10).
///
/// <para>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.</para>
///
/// <para>The scissor is the trap. It does NOT flip with the viewport:
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// while <c>NdcScissorRect.ToPixels</c> 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.</para>
/// </summary>
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));
}
}

View file

@ -0,0 +1,100 @@
using System.Text;
using System.Text.RegularExpressions;
namespace AcDream.Tools.ShaderCompiler;
/// <summary>
/// Campaign V slice V6c: give every stage-to-stage varying an explicit
/// <c>layout(location = N)</c>, which SPIR-V requires and desktop GL does not.
///
/// <para>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.</para>
///
/// <para>Anything already carrying a <c>layout(</c> qualifier is left alone, as
/// are interface blocks (which open a brace) and the redeclared
/// <c>gl_PerVertex</c> block that lets a core-profile shader write
/// <c>gl_ClipDistance</c>.</para>
/// </summary>
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(
@"^(?<indent>\s*)(?<leading>(flat|noperspective|smooth|centroid)\s+)*(?<direction>in|out)\s+(?<interp>(flat|noperspective|smooth|centroid)\s+)*(?<type>[A-Za-z_][A-Za-z0-9_]*)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?<array>\[[^\]]*\])?\s*;\s*(?<trailing>//.*)?$",
RegexOptions.ExplicitCapture)]
private static partial Regex VaryingDeclaration();
/// <summary>
/// Rewrites <paramref name="source"/> so every user varying carries a
/// location. <paramref name="locationsByName"/> is shared across the two
/// stages of a pair and is populated by whichever stage declares a name
/// first.
/// </summary>
internal static string Apply(string source, string stage, Dictionary<string, int> 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();
}
}

View file

@ -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;
/// <summary>
/// Campaign V slice V6c, plan §4.6: compile acdream's GLSL to committed SPIR-V.
///
/// <para>Usage: <c>ShaderCompiler &lt;shaders-dir&gt; &lt;output-dir&gt;</c>.
/// Every <c>.vert</c>/<c>.frag</c> pair in the source directory is compiled
/// through <see cref="VulkanGlslPreamble"/>; successes write
/// <c>{name}.{stage}.spv</c> and failures are recorded with the compiler's own
/// message. Either way the run writes <c>shaders.manifest.json</c> 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.</para>
///
/// <para>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.</para>
/// </summary>
internal static class Program
{
private static unsafe int Main(string[] args)
{
if (args.Length < 2)
{
Console.Error.WriteLine("usage: ShaderCompiler <shaders-dir> <output-dir>");
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<ShaderManifestEntry>();
int failures = 0;
try
{
foreach (string name in names)
{
var stages = new List<ShaderStageResult>();
// Shared across the pair so a fragment input takes the location
// its vertex output was given, by name.
var locations = new Dictionary<string, int>(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<byte>(shaderc.ResultGetBytes(result), (int)length).CopyTo(spirv);
return true;
}
finally
{
shaderc.ResultRelease(result);
}
}
}
/// <summary>First line of a compiler message, which is the one that names the cause.</summary>
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<ShaderStageResult> 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<ShaderStageResult> Stages);
internal sealed record ShaderManifest(
[property: JsonPropertyName("note")] string Note,
[property: JsonPropertyName("shaders")] IReadOnlyList<ShaderManifestEntry> Shaders);
internal static class ShaderManifestJson
{
internal static JsonSerializerOptions Options { get; } = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Campaign V slice V6c: the GLSL -> SPIR-V compiler behind
tools/compile-shaders.ps1.
Deliberately OUTSIDE AcDream.slnx and never referenced by AcDream.App. 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 - so this is a
build-time tool whose only output is committed .spv artifacts.
It uses Silk.NET.Shaderc rather than shelling out to glslc because CI
runners and this development machine have no Vulkan SDK installed, and
Silk.NET is already the pinned graphics binding family at 2.23.0.
tools/compile-shaders.ps1 still prefers a real glslc when one is present.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>AcDream.Tools.ShaderCompiler</RootNamespace>
<AssemblyName>AcDream.Tools.ShaderCompiler</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Shaderc" Version="2.23.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,225 @@
using System.Text;
namespace AcDream.Tools.ShaderCompiler;
/// <summary>
/// Campaign V slice V6c, plan §3.4 and §4.6: the Vulkan half of acdream's
/// dual-dialect GLSL.
///
/// <para>The GLSL sources under <c>Rendering/Shaders</c> 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 <c>#version</c> line:
/// </para>
///
/// <list type="number">
/// <item><c>ACDREAM_UBO_SET</c> becomes <c>set = 1,</c>. Under GL it expands to
/// nothing, because GL keeps the SSBO and UBO binding namespaces separate and
/// <c>BatchBuffer</c> (SSBO binding 1) and <c>SceneLighting</c> (UBO binding 1)
/// can share a number. Vulkan has one namespace per set, so moving uniform
/// buffers to set 1 preserves both numbers. <c>common.glsl</c> has carried this
/// macro since V2 for exactly this moment.</item>
/// <item>The texture table becomes a real descriptor array at set 2 binding 0,
/// and <c>ACDREAM_TEXTURE_HANDLE</c> becomes an index rather than a packed
/// bindless handle. <c>nonuniformEXT</c> is required, not optional: within one
/// multi-draw dispatch different draws read different <c>Batches[]</c> entries,
/// and "dynamically uniform" is defined over the whole dispatch on some
/// implementations.</item>
/// <item>The shared 96-byte push-constant block is declared, and each loose
/// uniform name is <c>#define</c>d onto its member. Vulkan GLSL has no default
/// uniform block, so this is the only way the same source can declare
/// <c>uniform mat4 uViewProjection;</c> for GL and read a push constant for
/// Vulkan.</item>
/// </list>
///
/// <para>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.</para>
/// </summary>
internal static class VulkanGlslPreamble
{
/// <summary>
/// The loose uniform names the shared push-constant block carries, in the
/// order <c>GpuPushConstants</c> 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.
/// </summary>
internal static IReadOnlyList<string> PushConstantFields { get; } =
[
"uViewProjection",
"uDrawIDOffset",
"uLightingMode",
"uRenderPass",
"uLightDebug",
"uTextureIndexA",
"uTextureIndexB",
"uParamA",
"uParamB",
];
/// <summary>
/// Builds the text inserted after the <c>#version</c> directive.
/// <paramref name="stage"/> only affects which stage-specific rewrites are
/// emitted.
/// </summary>
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();
}
/// <summary>
/// Returns <paramref name="source"/> with the preamble inserted after its
/// <c>#version</c> line and the version raised to 450, which is the floor for
/// Vulkan GLSL. Everything else is untouched: this never edits a shader body.
/// </summary>
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();
}
/// <summary>The numeric version a <c>#version</c> directive asks for, or 450 when it cannot be read.</summary>
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;
}
/// <summary>
/// True for a loose (default-block) uniform declaration —
/// <c>uniform mat4 uViewProjection;</c> or <c>uniform float uTexTiling[36];</c> —
/// and false for a uniform BLOCK, which opens a brace and is legal in both
/// dialects.
/// </summary>
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(';');
}
}

99
tools/compile-shaders.ps1 Normal file
View file

@ -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'