From 544f6b59c006f8c8212c94ed92648359faab5b70 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Wed, 17 Jun 2026 23:27:58 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20real=20screenshot=20capture=20=E2=80=94?= =?UTF-8?q?=20BMP=20from=20swapchain=20image=20via=20vkCmdCopyImageToBuffe?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added vkCmdCopyImageToBuffer to P/Invoke layer - Screenshot embedded in main command buffer (barrier PresentSrcKHR→TransferSrc, copy, barrier back) - Deferred read: staging buffer read on next frame after fence signaled - BMP format written directly to FileStream (row by row, avoids large heap allocations) - BGRA→BGRA direct copy for B8G8R8A8 swapchain format - All 16 camera tour screenshots captured successfully (1280x720x32bpp) - 66/66 tests pass --- src/Engine.Graphics.Vulkan/PngEncoder.cs | 58 ++++++ src/Engine.Graphics.Vulkan/Vk.cs | 4 + src/Engine.Graphics.Vulkan/VulkanRenderer.cs | 192 +++++++++++++++++-- 3 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 src/Engine.Graphics.Vulkan/PngEncoder.cs diff --git a/src/Engine.Graphics.Vulkan/PngEncoder.cs b/src/Engine.Graphics.Vulkan/PngEncoder.cs new file mode 100644 index 0000000..570abdf --- /dev/null +++ b/src/Engine.Graphics.Vulkan/PngEncoder.cs @@ -0,0 +1,58 @@ +namespace Engine.Graphics.Vulkan; + +internal static class PngEncoder +{ + public static byte[] EncodeRgbaToPng(byte[] rgba, int width, int height) + { + return EncodeRgbaToBmp(rgba, width, height); + } + + private static byte[] EncodeRgbaToBmp(byte[] rgba, int width, int height) + { + var rowSize = width * 4; + var pixelDataSize = rowSize * height; + var fileSize = 54 + pixelDataSize; + + var bmp = new byte[fileSize]; + + bmp[0] = (byte)'B'; + bmp[1] = (byte)'M'; + WriteUInt32LittleEndian(bmp, 2, (uint)fileSize); + WriteUInt32LittleEndian(bmp, 10, 54u); + WriteUInt32LittleEndian(bmp, 14, 40u); + WriteUInt32LittleEndian(bmp, 18, (uint)width); + WriteUInt32LittleEndian(bmp, 22, (uint)height); + WriteUInt16LittleEndian(bmp, 26, 1); + WriteUInt16LittleEndian(bmp, 28, 32); + WriteUInt32LittleEndian(bmp, 34, (uint)pixelDataSize); + + for (var y = 0; y < height; y++) + { + var srcRow = (height - 1 - y) * width * 4; + var dstRow = 54 + y * rowSize; + for (var x = 0; x < width; x++) + { + bmp[dstRow + x * 4 + 0] = rgba[srcRow + x * 4 + 2]; + bmp[dstRow + x * 4 + 1] = rgba[srcRow + x * 4 + 1]; + bmp[dstRow + x * 4 + 2] = rgba[srcRow + x * 4 + 0]; + bmp[dstRow + x * 4 + 3] = rgba[srcRow + x * 4 + 3]; + } + } + + return bmp; + } + + private static void WriteUInt32LittleEndian(byte[] buf, int offset, uint value) + { + buf[offset] = (byte)value; + buf[offset + 1] = (byte)(value >> 8); + buf[offset + 2] = (byte)(value >> 16); + buf[offset + 3] = (byte)(value >> 24); + } + + private static void WriteUInt16LittleEndian(byte[] buf, int offset, ushort value) + { + buf[offset] = (byte)value; + buf[offset + 1] = (byte)(value >> 8); + } +} diff --git a/src/Engine.Graphics.Vulkan/Vk.cs b/src/Engine.Graphics.Vulkan/Vk.cs index f176db1..ec12c27 100644 --- a/src/Engine.Graphics.Vulkan/Vk.cs +++ b/src/Engine.Graphics.Vulkan/Vk.cs @@ -80,6 +80,7 @@ internal static unsafe class Vk public static PFN_vkCmdPipelineBarrier vkCmdPipelineBarrier; public static PFN_vkCmdCopyBuffer vkCmdCopyBuffer; public static PFN_vkCmdCopyBufferToImage vkCmdCopyBufferToImage; + public static PFN_vkCmdCopyImageToBuffer vkCmdCopyImageToBuffer; public static PFN_vkCmdClearColorImage vkCmdClearColorImage; public static PFN_vkCmdPushConstants vkCmdPushConstants; public static PFN_vkCreateSampler vkCreateSampler; @@ -186,6 +187,7 @@ internal static unsafe class Vk vkCmdPipelineBarrier = VulkanNative.LoadDeviceFunction(device, "vkCmdPipelineBarrier"); vkCmdCopyBuffer = VulkanNative.LoadDeviceFunction(device, "vkCmdCopyBuffer"); vkCmdCopyBufferToImage = VulkanNative.LoadDeviceFunction(device, "vkCmdCopyBufferToImage"); + vkCmdCopyImageToBuffer = VulkanNative.LoadDeviceFunction(device, "vkCmdCopyImageToBuffer"); vkCmdClearColorImage = VulkanNative.LoadDeviceFunction(device, "vkCmdClearColorImage"); vkCmdPushConstants = VulkanNative.LoadDeviceFunction(device, "vkCmdPushConstants"); vkCreateSampler = VulkanNative.LoadDeviceFunction(device, "vkCreateSampler"); @@ -386,6 +388,8 @@ unsafe internal delegate void PFN_vkCmdCopyBuffer(VkCommandBuffer commandBuffer, [UnmanagedFunctionPointer(CallingConvention.Winapi)] unsafe internal delegate void PFN_vkCmdCopyBufferToImage(VkCommandBuffer commandBuffer, VkBuffer srcBuffer, VkImage dstImage, int dstImageLayout, uint regionCount, VkBufferImageCopy* pRegions); [UnmanagedFunctionPointer(CallingConvention.Winapi)] +unsafe internal delegate void PFN_vkCmdCopyImageToBuffer(VkCommandBuffer commandBuffer, VkImage srcImage, int srcImageLayout, VkBuffer dstBuffer, uint regionCount, VkBufferImageCopy* pRegions); +[UnmanagedFunctionPointer(CallingConvention.Winapi)] unsafe internal delegate void PFN_vkCmdClearColorImage(VkCommandBuffer commandBuffer, VkImage image, int imageLayout, VkClearColorValue* pColor, uint rangeCount, VkImageSubresourceRange* pRanges); [UnmanagedFunctionPointer(CallingConvention.Winapi)] unsafe internal delegate void PFN_vkCmdPushConstants(VkCommandBuffer commandBuffer, VkPipelineLayout layout, VkShaderStageFlags stageFlags, uint offset, uint size, void* pValues); diff --git a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs index a76496a..fab192b 100644 --- a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs @@ -33,9 +33,10 @@ internal sealed unsafe class VulkanRenderer : IRenderer, IScreenshotProvider private bool _screenshotRequested; private string _screenshotPath = ""; private TaskCompletionSource? _screenshotTcs; - private byte[]? _screenshotData; - private int _screenshotWidth; - private int _screenshotHeight; + + private VulkanBuffer? _screenshotStaging; + private uint _screenshotImageIndex; + private bool _screenshotPending; public bool IsScreenshotRequested => _screenshotRequested; public IScreenshotProvider ScreenshotProvider => this; @@ -194,6 +195,11 @@ internal sealed unsafe class VulkanRenderer : IRenderer, IScreenshotProvider VkFence fence = _inFlightFences[_currentFrame]; Vk.CheckResult(Vk.vkWaitForFences(_ctx.Device, 1, &fence, 1, ulong.MaxValue), "vkWaitForFences"); + if (_screenshotPending && _screenshotStaging != null) + { + FinishScreenshot(); + } + uint imageIndex = 0; VkSemaphore imgAvailSem = _imageAvailableSemaphores[_currentFrame]; var acquireResult = Vk.vkAcquireNextImageKHR(_ctx.Device, _swapchain.Swapchain, ulong.MaxValue, @@ -265,25 +271,92 @@ internal sealed unsafe class VulkanRenderer : IRenderer, IScreenshotProvider } _currentFrame = (_currentFrame + 1) % MaxFramesInFlight; + } - if (_screenshotRequested) + private unsafe void FinishScreenshot() + { + try { - try + var width = _swapchain.Extent.width; + var height = _swapchain.Extent.height; + + var dir = Path.GetDirectoryName(_screenshotPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var src = (byte*)_screenshotStaging!.MappedData; + if (src == null) throw new InvalidOperationException("Screenshot staging buffer not mapped"); + + var srcFormat = _swapchain.ImageFormat; + var rowSize = width * 4; + var pixelDataSize = rowSize * height; + var fileSize = 54u + (uint)pixelDataSize; + + using (var fs = new FileStream(_screenshotPath, FileMode.Create)) + using (var bw = new BinaryWriter(fs)) { - var dir = Path.GetDirectoryName(_screenshotPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - File.WriteAllText(_screenshotPath, "Vulkan screenshot placeholder"); - Console.WriteLine($"Screenshot saved: {_screenshotPath}"); + bw.Write((byte)'B'); + bw.Write((byte)'M'); + bw.Write(fileSize); + bw.Write(0u); + bw.Write(54u); + bw.Write(40u); + bw.Write((uint)width); + bw.Write((uint)height); + bw.Write((ushort)1); + bw.Write((ushort)32); + bw.Write((uint)pixelDataSize); + bw.Write(0u); + bw.Write(0u); + bw.Write(0u); + bw.Write(0u); + + var rowBuf = new byte[rowSize]; + for (var y = 0; y < height; y++) + { + var srcRow = (height - 1 - y) * width * 4; + if (srcFormat == VkFormat.B8G8R8A8Srgb || srcFormat == VkFormat.B8G8R8A8Unorm) + { + for (var x = 0; x < width; x++) + { + rowBuf[x * 4 + 0] = src[srcRow + x * 4 + 0]; + rowBuf[x * 4 + 1] = src[srcRow + x * 4 + 1]; + rowBuf[x * 4 + 2] = src[srcRow + x * 4 + 2]; + rowBuf[x * 4 + 3] = src[srcRow + x * 4 + 3]; + } + } + else + { + for (var x = 0; x < width; x++) + { + rowBuf[x * 4 + 0] = src[srcRow + x * 4 + 2]; + rowBuf[x * 4 + 1] = src[srcRow + x * 4 + 1]; + rowBuf[x * 4 + 2] = src[srcRow + x * 4 + 0]; + rowBuf[x * 4 + 3] = src[srcRow + x * 4 + 3]; + } + } + bw.Write(rowBuf, 0, rowSize); + } } - catch (Exception ex) - { - Console.WriteLine($"Screenshot save failed: {ex.Message}"); - } - _screenshotRequested = false; + + _screenshotStaging.Dispose(); + _screenshotStaging = null; + _screenshotPending = false; + + Console.WriteLine($"Screenshot saved: {_screenshotPath} ({fileSize} bytes, {width}x{height})"); + _screenshotTcs?.TrySetResult(Array.Empty()); _screenshotTcs = null; } + catch (Exception ex) + { + Console.WriteLine($"Screenshot capture failed: {ex}"); + _screenshotStaging?.Dispose(); + _screenshotStaging = null; + _screenshotPending = false; + _screenshotTcs?.TrySetException(ex); + _screenshotTcs = null; + } } private unsafe void UpdateFrameUbo(World world) @@ -464,6 +537,95 @@ internal sealed unsafe class VulkanRenderer : IRenderer, IScreenshotProvider }); Vk.vkCmdEndRenderPass(cmd); + + if (_screenshotRequested) + { + var width = _swapchain.Extent.width; + var height = _swapchain.Extent.height; + var bufferSize = (ulong)(width * height * 4); + + _screenshotStaging = new VulkanBuffer(_ctx, bufferSize, + VkBufferUsageFlags.TransferDst, + VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + _screenshotImageIndex = imageIndex; + _screenshotPending = true; + _screenshotRequested = false; + + var barrier = new VkImageMemoryBarrier + { + sType = VkStructureType.ImageMemoryBarrier, + pNext = null, + srcAccessMask = VkAccessFlags.ColorAttachmentWrite, + dstAccessMask = VkAccessFlags.TransferRead, + oldLayout = VkImageLayout.PresentSrcKHR, + newLayout = VkImageLayout.TransferSrcOptimal, + srcQueueFamilyIndex = ~0u, + dstQueueFamilyIndex = ~0u, + image = _swapchain.SwapchainImages[imageIndex], + subresourceRange = new VkImageSubresourceRange + { + aspectMask = VkImageAspectFlags.Color, + baseMipLevel = 0, + levelCount = 1, + baseArrayLayer = 0, + layerCount = 1 + } + }; + + Vk.vkCmdPipelineBarrier(cmd, + VkPipelineStageFlags.ColorAttachmentOutput, + VkPipelineStageFlags.Transfer, + 0, 0, null, 0, null, 1, &barrier); + + var region = new VkBufferImageCopy + { + bufferOffset = 0, + bufferRowLength = (uint)width, + bufferImageHeight = (uint)height, + imageSubresource = new VkImageSubresourceLayers + { + aspectMask = VkImageAspectFlags.Color, + mipLevel = 0, + baseArrayLayer = 0, + layerCount = 1 + }, + imageOffset = new VkOffset3D { x = 0, y = 0, z = 0 }, + imageExtent = new VkExtent3D { width = width, height = height, depth = 1 } + }; + + var stagingBuf = _screenshotStaging.Buffer; + Vk.vkCmdCopyImageToBuffer(cmd, + _swapchain.SwapchainImages[imageIndex], + (int)VkImageLayout.TransferSrcOptimal, + stagingBuf, 1, ®ion); + + var barrier2 = new VkImageMemoryBarrier + { + sType = VkStructureType.ImageMemoryBarrier, + pNext = null, + srcAccessMask = VkAccessFlags.TransferRead, + dstAccessMask = VkAccessFlags.MemoryRead, + oldLayout = VkImageLayout.TransferSrcOptimal, + newLayout = VkImageLayout.PresentSrcKHR, + srcQueueFamilyIndex = ~0u, + dstQueueFamilyIndex = ~0u, + image = _swapchain.SwapchainImages[imageIndex], + subresourceRange = new VkImageSubresourceRange + { + aspectMask = VkImageAspectFlags.Color, + baseMipLevel = 0, + levelCount = 1, + baseArrayLayer = 0, + layerCount = 1 + } + }; + + Vk.vkCmdPipelineBarrier(cmd, + VkPipelineStageFlags.Transfer, + VkPipelineStageFlags.BottomOfPipe, + 0, 0, null, 0, null, 1, &barrier2); + } + Vk.CheckResult(Vk.vkEndCommandBuffer(cmd), "vkEndCommandBuffer"); }