acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
Erik 24834a6478 fix(render): Campaign V slice V6g — the Vulkan frame stops lying to the driver
V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.

Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).

The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.

That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.

Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.

vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.

The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.

Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.

Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:47:52 +02:00

878 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// The half of <see cref="VulkanGpuDevice"/> that owns sampled resources,
/// pipelines and passes.
///
/// <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 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
{
private VulkanPipelineLayouts.Created? _layouts;
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 = 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,
_layouts.TextureTable,
Math.Min(GpuBindingModel.TextureTableCapacity, Capabilities.MaxTextureTableSlots));
_backbufferAttachments = new VulkanBackbufferAttachments(
_vk,
_device,
_allocator,
_debugNames,
DepthStencilFormat);
// The default slot is registered first so it is slot 0 and so the table
// has something defined to scrub evicted slots with. GpuTextureSlot
// documents Unassigned as a loud sentinel precisely so nothing silently
// resolves to slot 0 — this texture exists for the renderers that
// legitimately need a fallback and ask for it by name.
_defaultTexture = new VulkanGpuTexture(
_vk,
_device,
_allocator,
_uploads,
_flights,
_debugNames,
new GpuTextureDescription(
"vk-default-white",
GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm,
Width: 1,
Height: 1,
LayerCount: 1,
MipLevelCount: 1));
_defaultTexture.Upload(0, 0, [255, 255, 255, 255]);
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) => _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;
_captureBuffer?.Dispose();
_captureBuffer = null;
_defaultTexture?.Dispose();
_defaultTexture = null;
_flights.DrainAll();
_timerPool?.Dispose();
_timerPool = null;
_pipelineCache?.Dispose();
_pipelineCache = null;
_backbufferAttachments?.Dispose();
_backbufferAttachments = null;
_textureTable?.Dispose();
_textureTable = null;
_layouts?.Destroy(_vk, _device);
_layouts = null;
}
/// <summary>The three shared descriptor set layouts and the one pipeline layout.</summary>
internal VulkanPipelineLayouts.Created Layouts =>
_layouts ?? throw new InvalidOperationException("The device's pipeline layouts have not been created.");
/// <summary>The global sampled-texture table (plan §4.4).</summary>
internal VulkanTextureTable TextureTable =>
_textureTable ?? throw new InvalidOperationException("The device's texture table has not been created.");
/// <summary>MSAA colour scratch and transient depth for the backbuffer pass.</summary>
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
/// swapchain create or recreate, behind a device-idle wait.
/// </summary>
internal void ConfigureBackbufferAttachments(uint width, uint height, Format colorFormat, int sampleCount)
{
BackbufferAttachments.Configure(width, height, colorFormat, sampleCount);
ConfigureBackbufferCapture(width, height);
}
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
ThrowIfDisposed();
return new VulkanGpuTexture(
_vk,
_device,
_allocator,
_uploads,
_flights,
_debugNames,
description,
sampleCount: 1,
renderTarget: VulkanTextureFormatMapping.IsRenderTarget(description.Format));
}
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{
ThrowIfDisposed();
if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing))
return existing;
var created = new VulkanGpuSampler(
_vk,
_device,
_flights,
_debugNames,
description,
_maxSamplerAnisotropy);
_samplers.Add(description, created);
return created;
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ThrowIfDisposed();
return new VulkanGpuRenderTarget(
_vk,
_device,
_allocator,
_uploads,
_flights,
_debugNames,
description);
}
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(texture);
ArgumentNullException.ThrowIfNull(sampler);
if (texture is not VulkanGpuTexture vulkanTexture)
throw new ArgumentException("The Vulkan backend can only register a Vulkan texture.", nameof(texture));
if (sampler is not VulkanGpuSampler vulkanSampler)
throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler));
return TextureTable.Register(vulkanTexture.View, vulkanSampler.Handle);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
{
ThrowIfDisposed();
if (!slot.IsAssigned)
throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot));
// Deferred, and scrubbed to the default texture on the way out: a
// submitted-but-unretired frame may still sample this slot, so reusing
// it now would alias a live draw onto whatever texture claims it next.
VulkanTextureTable table = TextureTable;
_flights.Retire(() => table.ReleaseNow(slot));
}
/// <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);
(ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name);
// Slice V6d: the pipeline names the format it renders into, rather than
// every pipeline being hard-coded to one. Rgba8UnormRenderTarget — the
// default — still maps to the swapchain's format; see
// VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for why the
// offscreen targets adopt the swapchain's format rather than the other
// way round.
Format colorFormat = VulkanTextureFormatMapping.FormatOf(description.ColorFormat);
return new VulkanGpuPipeline(
_vk,
_device,
_flights,
_debugNames,
Layouts.PipelineLayout,
_pipelineCache?.Handle ?? default,
vertex,
fragment,
description,
colorFormat,
DepthStencilFormat);
}
private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name)
{
if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing))
return existing;
ShaderModule vertex = CreateShaderModule(name, "vert");
ShaderModule fragment = CreateShaderModule(name, "frag");
_shaderModules[name] = (vertex, fragment);
return (vertex, fragment);
}
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);
TransitionBackbufferScratchColor(commands, attachments);
}
else
{
colorView = _backbuffer.ViewAt(imageIndex);
}
if (description.Depth is not null && attachments.HasDepth)
{
depthView = attachments.DepthView;
TransitionBackbufferDepth(commands, attachments);
}
}
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,
hasDepthAttachment: depthView.Handle != 0);
_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;
}
/// <summary>
/// Prepares the acquired swapchain image for a backbuffer pass.
///
/// <para>The FIRST pass of a frame acquires it: undefined contents, no prior
/// access to wait on, layout moved to colour-attachment.</para>
///
/// <para>Every pass AFTER that needs a dependency instead, and slice V6d is
/// where that started to matter. Vulkan's rasterization-order guarantees are
/// scoped to one render-pass instance; between two instances writing the same
/// attachment there is no implicit ordering at all, so the second one's draws
/// can land before or interleaved with the first one's colour writes — and
/// with the first one's multisample RESOLVE, which is part of the render pass
/// and therefore also unordered against what follows. V6c's frame had exactly
/// one backbuffer pass and could not see this. V6d's has three (world scene,
/// debug lines, retained UI), and the symptom was unmistakable once looked
/// at: whole runs of the debug-line figure missing where the earlier pass's
/// resolve had overwritten them, while the last pass's output survived
/// intact.
/// </para>
/// </summary>
private void TransitionBackbufferForRendering(CommandBuffer commands, Image image)
{
bool first = !_backbufferRenderingReady;
_backbufferRenderingReady = true;
var barrier = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
SrcStageMask = first
? PipelineStageFlags2.TopOfPipeBit
: PipelineStageFlags2.ColorAttachmentOutputBit,
SrcAccessMask = first
? AccessFlags2.None
: AccessFlags2.ColorAttachmentWriteBit,
DstStageMask = PipelineStageFlags2.ColorAttachmentOutputBit,
DstAccessMask = first
? AccessFlags2.ColorAttachmentWriteBit
: AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit,
// Undefined for the acquire — the contents are genuinely undefined
// and saying so lets the driver skip a decompress. A later pass in
// the same frame must NOT say Undefined: that would license
// discarding everything drawn so far.
OldLayout = first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal,
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);
}
/// <summary>
/// Campaign V slice V6g: moves the multisampled colour scratch into
/// <c>COLOR_ATTACHMENT_OPTIMAL</c> before the pass that names it there.
///
/// <para>The first use after (re)creation starts from UNDEFINED — the image
/// genuinely has no contents, and saying so lets the driver skip a
/// decompress. Every later use starts from the layout the previous pass left
/// and needs the barrier for its write-after-write dependency instead: two
/// passes in one frame both write this image, and so does the next frame,
/// with no implicit ordering between render-pass instances.</para>
/// </summary>
private void TransitionBackbufferScratchColor(
CommandBuffer commands,
VulkanBackbufferAttachments attachments)
{
bool first = !attachments.ColorLayoutInitialized;
attachments.MarkColorLayoutInitialized();
TransitionImage(
commands,
attachments.ColorImage,
ImageAspectFlags.ColorBit,
first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal,
ImageLayout.ColorAttachmentOptimal,
first ? PipelineStageFlags2.TopOfPipeBit : PipelineStageFlags2.ColorAttachmentOutputBit,
first ? AccessFlags2.None : AccessFlags2.ColorAttachmentWriteBit,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit);
}
/// <summary>
/// The same, for the transient depth/stencil buffer. Both aspects move
/// together because the image carries both and the pass names it as both a
/// depth and a stencil attachment.
/// </summary>
private void TransitionBackbufferDepth(
CommandBuffer commands,
VulkanBackbufferAttachments attachments)
{
bool first = !attachments.DepthLayoutInitialized;
attachments.MarkDepthLayoutInitialized();
const PipelineStageFlags2 DepthStages =
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit;
TransitionImage(
commands,
attachments.DepthImage,
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
first ? ImageLayout.Undefined : ImageLayout.DepthStencilAttachmentOptimal,
ImageLayout.DepthStencilAttachmentOptimal,
first ? PipelineStageFlags2.TopOfPipeBit : DepthStages,
first ? AccessFlags2.None : AccessFlags2.DepthStencilAttachmentWriteBit,
DepthStages,
AccessFlags2.DepthStencilAttachmentWriteBit | AccessFlags2.DepthStencilAttachmentReadBit);
}
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 last presented frame 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>
///
/// <para><b>Campaign V slice V6g: it reads a device-owned copy, not the
/// swapchain image.</b> V6c transitioned the LAST PRESENTED swapchain image
/// to <c>TRANSFER_SRC</c> and copied out of it, which the validation layer
/// rejects as <c>UNASSIGNED-non-acquired-swapchain-image-used</c>: once
/// <c>vkQueuePresentKHR</c> has taken an image, the presentation engine owns
/// it and its contents are not the application's to read. The pixels were
/// usually right, which is precisely what makes it dangerous — this campaign
/// spent three sections (§5.5.1§5.5.3) discovering how much a capture
/// instrument that is "usually right" can cost. So the frame copies its own
/// output into a host-readable buffer while it still owns the image, and this
/// method reads that.</para>
///
/// <para>Retention is opt-in and off in production: it costs one full-res
/// image-to-buffer copy per frame, which is worth nothing to a player and is
/// the entire instrument to a gate.</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.");
if (_captureBuffer is null)
{
throw new InvalidOperationException(
"Backbuffer capture was not retained by this device. Construct it with " +
"retainBackbufferCapture: true — reading the presented swapchain image " +
"instead is a Vulkan usage error (see this method's remarks).");
}
if (width != _captureWidth || height != _captureHeight)
{
throw new ArgumentException(
$"The retained capture is {_captureWidth}x{_captureHeight}; {width}x{height} was requested. " +
"The capture buffer is sized with the swapchain, so a mismatch means the caller " +
"and the backbuffer disagree about the frame that was just presented.");
}
// Everything that could still be writing the buffer is a submitted frame.
VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (capture)");
var pixels = new byte[(long)_captureWidth * _captureHeight * 4];
_captureBuffer.Read(0, pixels);
// ToRgba, NOT ToGlOriginRgba: IGpuDevice.CaptureBackbuffer is documented
// as top-left-origin, and a Vulkan image already is.
return VulkanBackbufferSwizzle.ToRgba(pixels, width, height, width * 4);
}
/// <summary>
/// Records the acquired image's contents into the retained capture buffer,
/// while the frame still owns the image. Returns the layout the image is
/// left in, which the present barrier has to start from.
/// </summary>
internal ImageLayout RecordBackbufferCapture(CommandBuffer commands, Image image)
{
if (_captureBuffer is null || _backbuffer is null)
return ImageLayout.ColorAttachmentOptimal;
if (_captureWidth != _backbuffer.Width || _captureHeight != _backbuffer.Height)
return ImageLayout.ColorAttachmentOptimal;
TransitionImage(
commands,
image,
ImageAspectFlags.ColorBit,
ImageLayout.ColorAttachmentOptimal,
ImageLayout.TransferSrcOptimal,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit,
PipelineStageFlags2.CopyBit,
AccessFlags2.TransferReadBit);
var region = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
MipLevel = 0,
BaseArrayLayer = 0,
LayerCount = 1,
},
ImageOffset = new Offset3D(0, 0, 0),
ImageExtent = new Extent3D(_captureWidth, _captureHeight, 1),
};
_vk.CmdCopyImageToBuffer(
commands,
image,
ImageLayout.TransferSrcOptimal,
_captureBuffer.Handle,
1,
&region);
return ImageLayout.TransferSrcOptimal;
}
/// <summary>
/// Sizes the retained capture buffer with the swapchain. Called from
/// <see cref="ConfigureBackbufferAttachments"/>, which the host already
/// drives behind a <c>vkDeviceWaitIdle</c> on resize.
/// </summary>
private void ConfigureBackbufferCapture(uint width, uint height)
{
if (!_retainBackbufferCapture)
return;
if (_captureBuffer is not null && _captureWidth == width && _captureHeight == height)
return;
_captureBuffer?.Dispose();
_captureBuffer = null;
_captureWidth = width;
_captureHeight = height;
if (width == 0 || height == 0)
return;
_captureBuffer = new VulkanGpuBuffer(
_vk,
_device,
_allocator,
_uploads,
ImmediateGpuResourceRetirementQueue.Instance,
_debugNames,
new GpuBufferDescription(
"vk-backbuffer-capture",
width * height * 4,
GpuBufferUsage.TransferDestination,
GpuMemoryResidency.HostReadable));
}
private readonly bool _retainBackbufferCapture;
private VulkanGpuBuffer? _captureBuffer;
private uint _captureWidth;
private uint _captureHeight;
private bool _backbufferRenderingReady;
}