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