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\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</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
next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml">

View file

@ -66,14 +66,19 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
private VulkanQueueFamilyChoice? _families;
private VulkanSwapchain? _swapchain;
private CommandPool[] _commandPools = [];
private CommandBuffer[] _commandBuffers = [];
private Semaphore[] _imageAcquired = [];
private Semaphore _timeline;
private ulong _frameSerial;
private bool _recreateAtFrameBoundary;
private bool _disposed;
// ── Campaign V slice V6c: the RHI backend and the scene that proves it ──
private VulkanGpuDevice? _gpuDevice;
private VulkanRhiScene? _scene;
private VulkanDebugNames _debugNames = VulkanDebugNames.Disabled;
private VulkanDeviceFeatureSupport? _features;
private VulkanDeviceLimitSupport? _limits;
private VulkanFormatSupport? _formats;
internal VulkanBringUpHost(
RuntimeOptions options,
GraphicalHostPlatformServices platform,
@ -253,6 +258,13 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
_graphicsQueue,
families.GraphicsFamily);
_features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
_limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice);
_formats = VulkanPhysicalDeviceInspector.ReadFormats(
vk,
_physicalDevice,
VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats));
var record = new VulkanCapabilityRecord(
DateTimeOffset.UtcNow,
_platform.RuntimeIdentifier,
@ -278,12 +290,9 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
created.EnabledExtensions,
families.GraphicsFamily,
families.PresentFamily,
VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice),
VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice),
VulkanPhysicalDeviceInspector.ReadFormats(
vk,
_physicalDevice,
VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)),
_features,
_limits,
_formats,
surfaceSupport,
probe,
SupportFailures: []);
@ -310,66 +319,6 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
_log($"vulkan: device selection — {choice.Reason}");
}
private void CreateFrameResources()
{
Silk.NET.Vulkan.Vk vk = _vk!;
VulkanQueueFamilyChoice families = _families!;
_commandPools = new CommandPool[FlightCount];
_commandBuffers = new CommandBuffer[FlightCount];
_imageAcquired = new Semaphore[FlightCount];
for (int i = 0; i < FlightCount; i++)
{
var poolCreate = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = families.GraphicsFamily,
};
VulkanInterop.Check(
vk.CreateCommandPool(_device, &poolCreate, null, out CommandPool pool),
"vkCreateCommandPool (flight slot)");
_commandPools[i] = pool;
var allocate = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = pool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
VulkanInterop.Check(
vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands),
"vkAllocateCommandBuffers (flight slot)");
_commandBuffers[i] = commands;
var semaphoreCreate = new SemaphoreCreateInfo
{
SType = StructureType.SemaphoreCreateInfo,
};
VulkanInterop.Check(
vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore acquired),
"vkCreateSemaphore (image acquired)");
_imageAcquired[i] = acquired;
}
var timelineType = new SemaphoreTypeCreateInfo
{
SType = StructureType.SemaphoreTypeCreateInfo,
SemaphoreType = SemaphoreType.Timeline,
InitialValue = 0,
};
var timelineCreate = new SemaphoreCreateInfo
{
SType = StructureType.SemaphoreCreateInfo,
PNext = &timelineType,
};
VulkanInterop.Check(
vk.CreateSemaphore(_device, &timelineCreate, null, out _timeline),
"vkCreateSemaphore (frame timeline)");
RecreateSwapchain();
}
private bool RecreateSwapchain()
{
Vector2D<int> framebuffer = _window!.FramebufferSize;
@ -386,11 +335,118 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
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()
{
IWindow window = _window!;
VulkanGpuDevice device = _gpuDevice!;
VulkanRhiScene scene = _scene!;
FrameScreenshotController? screenshots = CreateScreenshotController();
bool screenshotRequested = false;
DateTimeOffset started = DateTimeOffset.UtcNow;
while (!window.IsClosing)
{
@ -407,21 +463,44 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
Thread.Sleep(16);
continue;
}
VulkanSwapchainConfiguration resized = _swapchain!.Configuration!;
device.ConfigureBackbufferAttachments(
resized.Width,
resized.Height,
resized.ImageFormat,
scene.SampleCount);
}
if (!RenderClearFrame(out uint imageIndex))
if (!device.TryBeginFrame(out IGpuFrame? frame) || frame is null)
{
_recreateAtFrameBoundary = true;
continue;
}
if (screenshots is not null && !screenshotRequested)
VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!;
using (frame)
{
scene.Render(
frame,
configuration.Width,
configuration.Height,
(DateTimeOffset.UtcNow - started).TotalSeconds);
}
_frameSerial = (ulong)frame.Serial;
if (!device.PresentSucceeded)
_recreateAtFrameBoundary = true;
// Capture after a few frames so the timer pool has resolved and the
// ring has cycled through both flight slots at least once.
if (screenshots is not null && !screenshotRequested && _frameSerial >= 4)
{
screenshotRequested = true;
if (screenshots.TryRequest(ScreenshotName, out string error))
{
VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!;
_lastPresentedImage = imageIndex;
screenshots.CapturePending(
(int)configuration.Width,
(int)configuration.Height);
screenshots.CapturePending((int)configuration.Width, (int)configuration.Height);
ReportTimings(device);
}
else
{
@ -431,10 +510,25 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
}
VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)");
_log($"vulkan: presented {_frameSerial} clear-colour frame(s); shutting down.");
_log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down.");
}
private uint _lastPresentedImage;
private void ReportTimings(VulkanGpuDevice device)
{
if (!device.Timers.IsSupported)
{
_log("vulkan: GPU timestamps are unsupported on this device");
return;
}
string offscreen = device.Timers.TryResolve("offscreen", out double offscreenMs)
? $"{offscreenMs:F3} ms"
: "pending";
string main = device.Timers.TryResolve("main", out double mainMs)
? $"{mainMs:F3} ms"
: "pending";
_log($"vulkan: GPU timer scopes — offscreen {offscreen}, main {main}");
}
private FrameScreenshotController? CreateScreenshotController()
{
@ -442,205 +536,20 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
return null;
return new FrameScreenshotController(
(_, _) => _swapchain!.CaptureImage(
_graphicsQueue,
_families!.GraphicsFamily,
_lastPresentedImage),
// IGpuDevice.CaptureBackbuffer is documented top-left-origin and a
// Vulkan image already is; FrameScreenshotController flips what it
// receives because glReadPixels hands back bottom-up rows. Flipping
// here makes the two cancel, so the PNG is right-side-up — and it
// routes the screenshot through the RHI capture path, which is the
// thing slice V6c has to prove rather than assume.
(width, height) => FrameScreenshotController.FlipRows(
_gpuDevice!.CaptureBackbuffer(width, height),
width,
height),
_options.AutomationArtifactDirectory,
_log);
}
/// <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>
/// Teardown in strict reverse-construction order. Every handle is checked
/// 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);
// Scene before device: the scene owns buffers, textures, render
// targets and pipelines whose release routes through the device's
// retirement queue, so the device has to still be alive to drain it.
_scene?.Dispose();
_scene = null;
_gpuDevice?.Dispose();
_gpuDevice = null;
_swapchain?.Dispose();
_swapchain = null;
if (_timeline.Handle != 0)
{
vk.DestroySemaphore(_device, _timeline, null);
_timeline = default;
}
foreach (Semaphore semaphore in _imageAcquired)
{
if (semaphore.Handle != 0)
vk.DestroySemaphore(_device, semaphore, null);
}
foreach (CommandPool pool in _commandPools)
{
if (pool.Handle != 0)
vk.DestroyCommandPool(_device, pool, null);
}
_imageAcquired = [];
_commandBuffers = [];
_commandPools = [];
vk.DestroyDevice(_device, null);
_device = default;
}
else
{
_scene?.Dispose();
_scene = null;
_gpuDevice?.Dispose();
_gpuDevice = null;
_swapchain?.Dispose();
_swapchain = null;
}
_debugNames.Dispose();
_debugNames = VulkanDebugNames.Disabled;
if (vk is not null && _surfaceApi is not null && _surface.Handle != 0)
{
_surfaceApi.DestroySurface(_instance, _surface, null);

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;
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
/// exactly this line: V6a landed memory, buffers, rings and the frame timeline —
/// everything in <c>VulkanGpuDevice.cs</c> — V6b lands textures, samplers, the
/// descriptor table and render targets here, and pipelines, passes and readback
/// arrive at V6c. Until each lands, the corresponding contract member throws
/// with the slice named, rather than returning something that would fail later
/// and further away.</para>
/// everything in <c>VulkanGpuDevice.cs</c> — V6b landed textures, samplers, the
/// descriptor table and render targets, and V6c completes it with pipelines from
/// committed SPIR-V, dynamic-rendering passes, timestamps and readback.</para>
/// </summary>
internal sealed unsafe partial class VulkanGpuDevice
{
@ -20,19 +19,34 @@ internal sealed unsafe partial class VulkanGpuDevice
private VulkanTextureTable? _textureTable;
private VulkanBackbufferAttachments? _backbufferAttachments;
private VulkanGpuTexture? _defaultTexture;
private VulkanPipelineCache? _pipelineCache;
private VulkanGpuTimerPool? _timerPool;
private VulkanGpuBuffer? _bindingDummy;
private VulkanFrameBindings[] _frameBindings = [];
private readonly Dictionary<GpuSamplerDescription, VulkanGpuSampler> _samplers = [];
private readonly Dictionary<string, (ShaderModule Vertex, ShaderModule Fragment)> _shaderModules = [];
private string _shaderSpirvDirectory = string.Empty;
private float _maxSamplerAnisotropy = 1f;
private VulkanGpuPassEncoder? _openPass;
private bool _openPassIsBackbuffer;
private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory)
{
_ = shaderSpirvDirectory;
_ = pipelineCacheDirectory;
_shaderSpirvDirectory = shaderSpirvDirectory ?? string.Empty;
_vk.GetPhysicalDeviceProperties(_physicalDevice, out PhysicalDeviceProperties properties);
_maxSamplerAnisotropy = properties.Limits.MaxSamplerAnisotropy;
_layouts = VulkanPipelineLayouts.Create(_vk, _device);
_pipelineCache = new VulkanPipelineCache(_vk, _physicalDevice, _device, pipelineCacheDirectory);
_timerPool = new VulkanGpuTimerPool(
_vk,
_physicalDevice,
_device,
_flights.SlotCount,
Capabilities.SupportsTimestampQueries);
_textureTable = new VulkanTextureTable(
_vk,
_device,
@ -59,7 +73,7 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames,
new GpuTextureDescription(
"vk-default-white",
GpuTextureKind.Texture2D,
GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm,
Width: 1,
Height: 1,
@ -70,27 +84,79 @@ internal sealed unsafe partial class VulkanGpuDevice
var defaultSampler = (VulkanGpuSampler)CreateSampler(GpuSamplerDescription.UiNearest);
_textureTable.SetScrubTarget(_defaultTexture.View, defaultSampler.Handle);
DefaultTextureSlot = _textureTable.Register(_defaultTexture.View, defaultSampler.Handle);
// One dummy range every unused binding points at, so there is a single
// descriptor set layout rather than a permutation per renderer.
_bindingDummy = new VulkanGpuBuffer(
_vk,
_device,
_allocator,
_uploads,
_flights,
_debugNames,
new GpuBufferDescription(
"vk-binding-dummy",
65536,
GpuBufferUsage.Storage | GpuBufferUsage.Uniform,
GpuMemoryResidency.HostWritable));
_frameBindings = new VulkanFrameBindings[_flights.SlotCount];
for (int slot = 0; slot < _flights.SlotCount; slot++)
{
_frameBindings[slot] = new VulkanFrameBindings(
_vk,
_device,
_layouts,
_ringBuffers[slot],
_bindingDummy);
}
}
private void BeginFrameResources(int slotIndex) => _ = slotIndex;
private void BeginFrameResources(int slotIndex) => _timerPool?.BeginSlot(slotIndex);
private void EndFrameResources(int slotIndex, CommandBuffer commands)
{
_ = slotIndex;
_ = commands;
if (_openPass is not null)
{
throw new InvalidOperationException(
"A pass is still open at frame end. Dispose the encoder before ending the frame — " +
"a dynamic-rendering block left open makes the whole command buffer invalid.");
}
}
private void DisposeResources()
{
foreach (VulkanFrameBindings bindings in _frameBindings)
bindings.Dispose();
_frameBindings = [];
foreach ((ShaderModule vertex, ShaderModule fragment) in _shaderModules.Values)
{
if (vertex.Handle != 0)
_vk.DestroyShaderModule(_device, vertex, null);
if (fragment.Handle != 0)
_vk.DestroyShaderModule(_device, fragment, null);
}
_shaderModules.Clear();
foreach (VulkanGpuSampler sampler in _samplers.Values)
sampler.Dispose();
_samplers.Clear();
_bindingDummy?.Dispose();
_bindingDummy = null;
_defaultTexture?.Dispose();
_defaultTexture = null;
_flights.DrainAll();
_timerPool?.Dispose();
_timerPool = null;
_pipelineCache?.Dispose();
_pipelineCache = null;
_backbufferAttachments?.Dispose();
_backbufferAttachments = null;
_textureTable?.Dispose();
@ -111,8 +177,16 @@ internal sealed unsafe partial class VulkanGpuDevice
internal VulkanBackbufferAttachments BackbufferAttachments =>
_backbufferAttachments ?? throw new InvalidOperationException("The backbuffer attachments have not been created.");
internal VulkanGpuTimerPool TimerPool =>
_timerPool ?? throw new InvalidOperationException("The device's timer pool has not been created.");
/// <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 IGpuTimerPool Timers => TimerPool;
/// <summary>
/// Matches the backbuffer pass's attachments to the swapchain's current
/// extent and the requested sample count. Called by the host after a
@ -192,17 +266,524 @@ internal sealed unsafe partial class VulkanGpuDevice
_flights.Retire(() => table.ReleaseNow(slot));
}
public IGpuTimerPool Timers => throw NotYet("GPU timer scopes", "V6c");
/// <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) =>
throw NotYet($"pipelines ('{description?.Name}')", "V6c");
(ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name);
// One colour format for every pipeline; see
// VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for why the
// offscreen targets adopt the swapchain`s format rather than the other
// way round.
Format colorFormat = VulkanTextureFormatMapping.CanonicalColorAttachmentFormat;
return new VulkanGpuPipeline(
_vk,
_device,
_flights,
_debugNames,
Layouts.PipelineLayout,
_pipelineCache?.Handle ?? default,
vertex,
fragment,
description,
colorFormat,
DepthStencilFormat);
}
internal IGpuPassEncoder BeginPass(VulkanGpuFrame frame, GpuPassDescription description) =>
throw NotYet($"render passes ('{description.Name}')", "V6c");
private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name)
{
if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing))
return existing;
public byte[] CaptureBackbuffer(int width, int height) =>
throw NotYet("backbuffer capture", "V6c");
ShaderModule vertex = CreateShaderModule(name, "vert");
ShaderModule fragment = CreateShaderModule(name, "frag");
_shaderModules[name] = (vertex, fragment);
return (vertex, fragment);
}
private static NotSupportedException NotYet(string what, string slice) =>
new($"The Vulkan backend does not implement {what} yet; it lands at Campaign V slice {slice}.");
private ShaderModule CreateShaderModule(string name, string stage)
{
string path = Path.Combine(_shaderSpirvDirectory, $"{name}.{stage}.spv");
if (!File.Exists(path))
{
throw new FileNotFoundException(
$"No committed SPIR-V for '{name}.{stage}'. Run tools/compile-shaders.ps1; if that " +
"reports the shader as not yet Vulkan-expressible, its renderer-port slice has not " +
"landed and no Vulkan pipeline can be built from it.",
path);
}
byte[] code = File.ReadAllBytes(path);
if (code.Length % 4 != 0)
throw new InvalidDataException($"'{path}' is {code.Length} bytes, which is not a whole number of SPIR-V words.");
fixed (byte* first = code)
{
var create = new ShaderModuleCreateInfo
{
SType = StructureType.ShaderModuleCreateInfo,
CodeSize = (nuint)code.Length,
PCode = (uint*)first,
};
VulkanInterop.Check(
_vk.CreateShaderModule(_device, &create, null, out ShaderModule module),
$"vkCreateShaderModule ('{name}.{stage}')");
return module;
}
}
/// <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),
"vkBeginCommandBuffer (frame)");
_backbufferRenderingReady = false;
BeginFrameResources(slot);
var opened = new VulkanGpuFrame(this, slot, serial);
@ -459,6 +461,7 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
if (_acquiredImageIndex is { } toPresent && _backbuffer is not null)
{
_lastPresentedImageIndex = toPresent;
PresentSucceeded = _backbuffer.Present(toPresent);
_acquiredImageIndex = null;
}

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)
{
int count = (int)GpuBindingModel.StorageBindingCount;
@ -91,7 +105,7 @@ internal static unsafe class VulkanPipelineLayouts
bindings[i] = new DescriptorSetLayoutBinding
{
Binding = (uint)i,
DescriptorType = DescriptorType.StorageBuffer,
DescriptorType = DescriptorType.StorageBufferDynamic,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
};
@ -116,14 +130,14 @@ internal static unsafe class VulkanPipelineLayouts
bindings[0] = new DescriptorSetLayoutBinding
{
Binding = GpuBindingModel.UniformSceneLighting,
DescriptorType = DescriptorType.UniformBuffer,
DescriptorType = DescriptorType.UniformBufferDynamic,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
};
bindings[1] = new DescriptorSetLayoutBinding
{
Binding = GpuBindingModel.UniformTerrainTiling,
DescriptorType = DescriptorType.UniformBuffer,
DescriptorType = DescriptorType.UniformBufferDynamic,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
};

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>
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>
internal static Format FormatOf(GpuTextureFormat format) => format switch
{
@ -25,7 +55,9 @@ internal static class VulkanTextureFormatMapping
GpuTextureFormat.Bc1Unorm => Format.BC1RgbaUnormBlock,
GpuTextureFormat.Bc2Unorm => Format.BC2UnormBlock,
GpuTextureFormat.Bc3Unorm => Format.BC3UnormBlock,
GpuTextureFormat.Rgba8UnormRenderTarget => Format.R8G8B8A8Unorm,
// Deliberately the same 32-bit UNORM order as the swapchain rather than
// literal RGBA — see CanonicalColorAttachmentFormat.
GpuTextureFormat.Rgba8UnormRenderTarget => CanonicalColorAttachmentFormat,
GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."),
};

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