acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
Erik 2e8b8b91ad feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan
Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.

Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.

Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.

Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.

Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.

The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.

Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the
documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness
and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings.

And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:05:24 +02:00

911 lines
38 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);
/// <summary>Slice V6i: the descriptor-set arena for one flight slot.</summary>
private VulkanFrameBindings FrameBindingsAt(int slotIndex) => _frameBindings[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,
DepthStencilFormat);
}
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));
// Campaign V slice V6k made this a loud refusal, and V6l is the slice
// that serves it. A render-target image is viewed as
// VK_IMAGE_VIEW_TYPE_2D because that is what an ATTACHMENT needs, while
// the table's descriptor array is declared sampler2DArray — so the
// attachment view is invalid usage here rather than a mismatch that
// samples oddly (plan §5.5.7). VulkanGpuTexture now creates a SECOND,
// layered view over the same image for exactly this, and every texture
// that is not an attachment has always had one; SampledView is that view
// in both cases, so the question disappears rather than being answered.
return TextureTable.Register(vulkanTexture.SampledView, 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);
// Slice V6l: a stencil pipeline declares four dynamic stencil states, and
// a declared dynamic state must be set before any draw uses it. Setting
// the pipeline's own declared default here is both what makes the draw
// legal without a renderer call and the exact mirror of the three lines
// above — bind restores the pipeline's defaults, the encoder overrides.
if (!description.StencilTest)
return;
GpuStencilState stencil = description.Stencil;
const StencilFaceFlags BothFaces = StencilFaceFlags.FaceFrontAndBack;
_vk.CmdSetStencilOp(
commands,
BothFaces,
VulkanViewportMapping.ToVulkan(stencil.Fail),
VulkanViewportMapping.ToVulkan(stencil.Pass),
VulkanViewportMapping.ToVulkan(stencil.DepthFail),
VulkanViewportMapping.ToVulkan(stencil.Compare));
_vk.CmdSetStencilCompareMask(commands, BothFaces, stencil.CompareMask);
_vk.CmdSetStencilWriteMask(commands, BothFaces, stencil.WriteMask);
_vk.CmdSetStencilReference(commands, BothFaces, stencil.Reference);
}
/// <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;
}