acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
Erik 00e1b32177 perf(render): Campaign V slice V8 commit 1 - the Vulkan arm gets an instrument
V8 is the performance gate, and it opened on a plain fact: the Vulkan arm
emitted no [frame-prof] line at all. V6h wired it to
NullRenderFrameGpuMeasurement, whose BeginFrame does nothing - and that method
is the ONLY caller of FrameProfiler.FrameBoundary. So a Vulkan run produced no
CPU frame distribution, no allocation-per-frame column, no frame-history CSV
and no GPU sample. The campaign's own performance vehicle,
tools/run-connected-r6-soak.ps1, waits on [frame-prof] boundaries to time its
samples, so it could not be pointed at the backend V8 exists to judge. You
cannot measure what you have not instrumented, so the instrument lands first.

The bracket is deliberately identical on both arms. On GL,
FrameProfilerGpuMeasurement begins a TimeElapsed query at BeginFrame and ends
it at EndFrame, spanning resource preparation, the world scene and private
presentation, and NOT the swapchain present. VulkanFrameGpuMeasurement opens
and closes a Vulkan timestamp scope at exactly those two points. Two
differently-bracketed numbers in one comparison table would have been worse
than reporting none.

Vulkan timestamps resolve two or three frames late, so the sample carries the
profiler frame index that ISSUED it rather than being credited to the frame
that happened to read it - the pairing GpuFrameTimer already performs
internally on GL. VulkanGpuDevice opens the whole-frame scope tagged with that
index, reads the previous use of the slot back in BeginFrameResources BEFORE
the tag is overwritten, and hands completed (tag, microseconds) pairs to the
adapter through a bounded queue that never blocks.

VulkanGpuTimerPool gains TryTakeResolved, which consumes the value it reports.
TryResolve deliberately reports the last known measurement forever, which is
right for a diagnostic readout and wrong for a percentile: counting one
measurement into the distribution twice is how an instrument flatters itself.

FrameProfiler gains a GL-free FrameBoundary() overload and RecordGpuSample.
Every other line of its bookkeeping - the CPU delta, the per-thread allocation
delta, the stage buffers, the history row, the five-second report - is the same
code the GL arm runs. The GL path is byte-for-byte unchanged in behaviour:
FrameBoundary(GL) still owns and creates the query ring.

Gates: Release build green. App tests 4,152 passed / 3 skipped, exactly the
pre-slice baseline. Strict GL offline pixel gate against 13c8733d:
1.95e-05 (11 px of 563,200), inside the documented 9-31 px band, so GL did not
move. An offline Vulkan run now reports gpu_ms in [frame-prof] and fills gpu_us
in the frame-history CSV, where before this commit it reported neither.

No divergence-register row is owed: this is diagnostic apparatus and no
rendered pixel depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:35:59 +02:00

995 lines
41 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;
/// <summary>
/// Campaign V slice V8: name of the whole-frame GPU timer scope. One scope
/// spanning the frame's entire command buffer is the Vulkan equivalent of
/// GL's <c>TimeElapsed</c> query around the render transaction, and is what
/// makes the two backends' <c>gpu_ms</c> columns the same measurement.
/// </summary>
private const string FrameTimerScopeName = "frame";
/// <summary>
/// Profiler frame index the scope in each flight slot was opened under. The
/// timestamps resolve two or three frames later, so the sample has to carry
/// the index of the frame that ISSUED it rather than the one that read it.
/// </summary>
private int[] _frameTimerTags = [];
private readonly Queue<(int Tag, long ElapsedUs)> _frameGpuSamples = new();
private IDisposable? _openFrameTimerScope;
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);
_frameTimerTags = new int[_flights.SlotCount];
Array.Fill(_frameTimerTags, -1);
_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)
{
if (_timerPool is null)
return;
// BeginSlot reads back whatever this slot recorded the last time it was
// used. That measurement belongs to the frame whose tag the slot still
// holds — read it BEFORE BeginFrameTimerScope overwrites the tag.
int issuingTag = _frameTimerTags[slotIndex];
_timerPool.BeginSlot(slotIndex);
if (issuingTag >= 0
&& _timerPool.TryTakeResolved(FrameTimerScopeName, out double milliseconds))
{
_frameGpuSamples.Enqueue((issuingTag, (long)(milliseconds * 1000d)));
}
}
/// <summary>
/// Campaign V slice V8: opens the whole-frame timer scope on the open
/// frame's command buffer, tagged with the profiler frame index that will
/// own the delayed result. Called by the frame spine's measurement adapter
/// immediately after the frame opens and before any renderer records work,
/// which is exactly where GL's <c>BeginQuery</c> sits.
/// </summary>
internal void BeginFrameTimerScope(int frameIndex)
{
if (_openFrame is null || _timerPool is null || _openFrameTimerScope is not null)
return;
int slot = _openFrame.SlotIndex;
_frameTimerTags[slot] = frameIndex;
_openFrameTimerScope = _timerPool.BeginScope(_commandBuffers[slot], FrameTimerScopeName);
}
/// <summary>Closes the whole-frame timer scope. Idempotent.</summary>
internal void EndFrameTimerScope()
{
_openFrameTimerScope?.Dispose();
_openFrameTimerScope = null;
}
/// <summary>
/// Drains one completed whole-frame GPU measurement, with the profiler frame
/// index that issued it. Never blocks: a frame whose timestamps have not
/// landed yet simply is not in the queue.
/// </summary>
internal bool TryTakeFrameGpuSample(out int frameIndex, out long elapsedUs)
{
if (_frameGpuSamples.Count == 0)
{
frameIndex = -1;
elapsedUs = 0;
return false;
}
(frameIndex, elapsedUs) = _frameGpuSamples.Dequeue();
return true;
}
/// <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;
// Normally already closed by the measurement adapter, which brackets the
// same phases GL's TimeElapsed query does. This is the safety net: a
// scope left open would write its closing timestamp into a pool the next
// frame has already reset.
EndFrameTimerScope();
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()
{
_openFrameTimerScope = null;
_frameGpuSamples.Clear();
_frameTimerTags = [];
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;
}