using System.Numerics;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
///
/// The half of that owns sampled resources,
/// pipelines and passes.
///
/// 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 VulkanGpuDevice.cs — 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.
///
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 _samplers = [];
private readonly Dictionary _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);
/// Slice V6i: the descriptor-set arena for one flight slot.
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;
}
/// The three shared descriptor set layouts and the one pipeline layout.
internal VulkanPipelineLayouts.Created Layouts =>
_layouts ?? throw new InvalidOperationException("The device's pipeline layouts have not been created.");
/// The global sampled-texture table (plan §4.4).
internal VulkanTextureTable TextureTable =>
_textureTable ?? throw new InvalidOperationException("The device's texture table has not been created.");
/// MSAA colour scratch and transient depth for the backbuffer pass.
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.");
/// True when a compatible pipeline cache blob was reused from disk.
internal bool PipelineCacheLoadedFromDisk => _pipelineCache?.LoadedFromDisk ?? false;
public GpuTextureSlot DefaultTextureSlot { get; private set; } = GpuTextureSlot.Unassigned;
public IGpuTimerPool Timers => TimerPool;
///
/// 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.
///
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));
}
///
/// Builds a pipeline from the committed SPIR-V for
/// . 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.
///
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;
}
}
///
/// 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
/// mean what they say even though the
/// state itself is dynamic.
///
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);
}
///
/// Opens a dynamic-rendering block for .
///
/// 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.
///
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;
}
///
/// Prepares the acquired swapchain image for a backbuffer pass.
///
/// The FIRST pass of a frame acquires it: undefined contents, no prior
/// access to wait on, layout moved to colour-attachment.
///
/// 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.
///
///
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);
}
///
/// Campaign V slice V6g: moves the multisampled colour scratch into
/// COLOR_ATTACHMENT_OPTIMAL before the pass that names it there.
///
/// 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.
///
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);
}
///
/// 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.
///
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);
}
///
/// Reads the last presented frame back as tightly packed top-left-origin
/// RGBA8.
///
/// The swapchain is B8G8R8A8_UNORM (plan §4.9), so the channels
/// are swizzled on the CPU to preserve FrameScreenshotController's
/// RGBA byte contract — the same seam every automated screenshot gate already
/// uses, so the comparison tooling is unaffected by the backend swap.
///
/// Campaign V slice V6g: it reads a device-owned copy, not the
/// swapchain image. V6c transitioned the LAST PRESENTED swapchain image
/// to TRANSFER_SRC and copied out of it, which the validation layer
/// rejects as UNASSIGNED-non-acquired-swapchain-image-used: once
/// vkQueuePresentKHR 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.
///
/// 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.
///
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);
}
///
/// 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.
///
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,
®ion);
return ImageLayout.TransferSrcOptimal;
}
///
/// Sizes the retained capture buffer with the swapchain. Called from
/// , which the host already
/// drives behind a vkDeviceWaitIdle on resize.
///
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;
}