From 9312810f0cf26ff9bad3f5d4b81241452c3e8c9b Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Tue, 16 Jun 2026 20:09:57 +0300 Subject: [PATCH] feat: AI screenshot capture for visual analysis - Add ScreenshotCapture class to Engine.Graphics using Vulkan image readback. - Integrate screenshot capture into MeshRenderer; request triggers readback on next frame. - Add SixLabors.ImageSharp 3.1.11 for PNG encoding (patched for CVE-2025-54575). - Add capture_screenshot AI command and MCP tool. - Wire screenshot request into Program.cs with demo command. - Expose swapchain surface format and image accessor for readback. - Add Screenshots/ to .gitignore. - Update CORTEX_ENGINE_ARCHITECTURE.md with MCP, Silk.NET.Vulkan, ImageSharp, and screenshot capture. --- .gitignore | 3 + CORTEX_ENGINE_ARCHITECTURE.md | 89 ++++-- src/CortexEngine.App/Program.cs | 7 +- src/Engine.AI/AiCommandProcessor.cs | 12 +- src/Engine.AI/Commands/AiCommand.cs | 1 + .../Commands/CaptureScreenshotCommand.cs | 12 + src/Engine.AI/Mcp/EngineMcpTools.cs | 7 + src/Engine.Graphics/Engine.Graphics.csproj | 1 + src/Engine.Graphics/MeshRenderer.cs | 20 ++ src/Engine.Graphics/ScreenshotCapture.cs | 287 ++++++++++++++++++ src/Engine.Graphics/Swapchain.cs | 3 + 11 files changed, 405 insertions(+), 37 deletions(-) create mode 100644 src/Engine.AI/Commands/CaptureScreenshotCommand.cs create mode 100644 src/Engine.Graphics/ScreenshotCapture.cs diff --git a/.gitignore b/.gitignore index 5367373..bd2c410 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ obj/ .DS_Store Thumbs.db +# Generated screenshots +Screenshots/ + # dotnet *.dll *.exe diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index cd41212..4f51fe1 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -11,11 +11,11 @@ Cortex Engine is a 3D game engine built from scratch to provide a Unity-like development experience (GameObject/Component paradigm, Inspector, Hierarchy, Scene View) while being deeply integrated with Multimodal Large Language Models (MMLMs). The engine allows an AI to: -- **See** the engine state via virtual cameras, semantic segmentation maps, and profiler screenshots. +- **See** the engine state via rendered-frame screenshots, virtual cameras, semantic segmentation maps, and profiler screenshots. - **Read** the complete ECS world state through native JSON serialization. - **Modify** the running engine via declarative JSON commands and, in Development Mode, via hot-reloaded C# scripts. -The architecture prioritizes **production maturity** over experimental technologies: Vulkan (via Silk.NET.Vulkan), Flecs.NET (C# bindings for the C-based Flecs ECS), SDL3-cs (ppy.SDL3-CS), and ImGui.NET/Hexa.NET.ImGui with a native Vulkan backend. +The architecture prioritizes **production maturity** over experimental technologies: Vulkan (via Silk.NET.Vulkan), Flecs.NET (C# bindings for the C-based Flecs ECS), SDL3-cs (ppy.SDL3-CS), and Hexa.NET.ImGui with a native Vulkan backend. --- @@ -112,12 +112,14 @@ All Roslyn and `AssemblyLoadContext` code is wrapped in `#if DEV_MODE`. ### 3.3 Graphics HAL -**Vulkan via `Vortice.Vulkan`** +**Vulkan via `Silk.NET.Vulkan`** -- NuGet: `Vortice.Vulkan` 3.2.3 +- NuGet: `Silk.NET.Vulkan` 2.21.0 - .NET 9/10 low-level bindings -- Includes VulkanMemoryAllocator, SPIRV-Cross, shaderc -- Mature, MIT licensed, listed on vulkan.org +- Mature, used by Silk.NET ecosystem +- MoltenVK provides macOS/iOS support + +**Note:** Initial prototype used Vortice.Vulkan, but its loader segfaulted on the Kubuntu development setup. Silk.NET.Vulkan is the verified working binding. **Why Vulkan over WebGPU:** @@ -426,19 +428,30 @@ Non-critical captures can be spread across multiple frames to avoid stuttering. The `AiGateway` is the only allowed path for the AI to modify the running engine. It prevents memory corruption, invalid state, and unsafe code execution. -### 6.2 Command Pattern (Works in Both Dev and Release) +### 6.2 MCP Server (Dev / Release) -The AI issues declarative JSON commands: +In Debug and Release configurations, the engine hosts an in-process **Model Context Protocol (MCP)** HTTP server. AI clients (Claude Desktop, Cursor, VS Code Copilot) can connect to it and call tools: + +- `spawn_model` — spawn a named entity from a model file. +- `set_transform` — update entity position, rotation, scale. +- `delete_entity` — delete an entity by name. +- `list_entities` — list all named entities with a `Transform`. +- `capture_screenshot` — save a PNG of the current frame. + +Commands are queued and executed on the main engine thread so the Flecs world is never touched from a background thread. + +### 6.3 Command Pattern (Works in Both Dev and Release) + +The AI can also issue declarative JSON commands directly: ```json { - "command": "spawn", - "type": "enemy", - "at": [10, 0, 5], - "components": { - "Transform": { "position": [10, 0, 5], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }, - "SemanticClass": { "classId": 1 } - } + "type": "spawn_model", + "name": "Enemy", + "modelPath": "Models/enemy.obj", + "position": [10, 0, 5], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] } ``` @@ -447,10 +460,10 @@ Validation steps: 1. JSON schema validation 2. Type existence check via Flecs reflection 3. Coordinate sanity check (e.g., no NaN, no extreme values) -4. Safe-name check (no `..` in prefab paths) -5. Queue operation via `world.Defer()` for execution at the next frame boundary +4. Safe-name check (no `..` in model paths) +5. Queue operation for execution at the next frame boundary -### 6.3 Scripting Validation (Dev Mode Only) +### 6.4 Scripting Validation (Dev Mode Only) Before Roslyn compilation: @@ -460,9 +473,9 @@ Before Roslyn compilation: 4. **Reference validation**: Ensure all referenced types exist in the engine API surface. 5. **Sandboxed compilation**: Compile into isolated `AssemblyLoadContext`. -### 6.4 Release Mode Limitation +### 6.5 Release Mode Limitation -In Release (NativeAOT), the AI cannot compile new C# code. It can only send JSON commands. This is a deliberate security and stability choice. +In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI cannot compile new C# code. It can only send JSON commands via `AiCommandProcessor`. This is a deliberate security and stability choice. --- @@ -488,10 +501,12 @@ In Release (NativeAOT), the AI cannot compile new C# code. It can only send JSON │ ├── Engine.Graphics/ │ │ ├── VulkanContext.cs # Device, instance, queues │ │ ├── Swapchain.cs # Swapchain management -│ │ ├── RenderPassManager.cs # Dynamic rendering helpers -│ │ ├── SemanticRenderer.cs # Flat-color segmentation pass -│ │ ├── RttCamera.cs # Virtual camera + off-screen framebuffer -│ │ └── TextureReadback.cs # Image → staging buffer → JPEG +│ │ ├── MeshRenderer.cs # ECS mesh rendering +│ │ ├── ScreenshotCapture.cs # Vulkan readback → PNG +│ │ ├── VulkanPipeline.cs # Graphics pipeline +│ │ ├── VertexBuffer.cs # Vertex buffer helpers +│ │ ├── IndexBuffer.cs # Index buffer helpers +│ │ └── Loaders/ # ObjLoader, GltfLoader │ │ │ ├── Engine.Diagnostics/ │ │ ├── DiagnosticsManager.cs # Orchestrator @@ -500,12 +515,14 @@ In Release (NativeAOT), the AI cannot compile new C# code. It can only send JSON │ │ ├── Payload.cs # DiagnosticPayload class │ │ └── LogBuffer.cs # Circular console log buffer │ │ -│ ├── Engine.AiGateway/ -│ │ ├── AiGateway.cs # Command parser + dispatcher -│ │ ├── CommandValidator.cs # JSON command validation -│ │ ├── RoslynCompilerService.cs # Dev-Mode C# compilation -│ │ ├── ScriptingSandbox.cs # AssemblyLoadContext isolation -│ │ └── TypeMigration.cs # Component type migration helper +│ ├── Engine.AI/ +│ │ ├── AiCommandProcessor.cs # Parses and executes JSON commands +│ │ ├── AiCommandQueue.cs # Thread-safe command queue +│ │ ├── Mcp/ +│ │ │ ├── EngineMcpTools.cs # MCP tool definitions +│ │ │ └── McpEngineServerHost.cs # In-process MCP HTTP server +│ │ ├── Commands/ # AI command DTOs +│ │ └── Serialization/ # JSON converters for Vector3/Quaternion │ │ │ ├── Engine.Editor/ │ │ ├── ImGuiController.cs # Hexa.NET.ImGui initialization @@ -595,14 +612,19 @@ In Release (NativeAOT), the AI cannot compile new C# code. It can only send JSON | Component | Package | Version | .NET | AOT | WASM | Mobile | Status | |-----------|---------|---------|------|-----|------|--------|--------| | SDL3 | `ppy.SDL3-CS` | 2026.520.0 | 9/10 | ✅ | ✅ | ✅ | Production | -| Vulkan | `Vortice.Vulkan` | 3.2.3 | 9/10 | ✅ | ❌ | MoltenVK | Production | +| Vulkan | `Silk.NET.Vulkan` | 2.21.0 | 9/10 | ✅ | ❌ | MoltenVK | Production | | ImGui | `Hexa.NET.ImGui` | latest | 9/10 | ✅ | ✅ | ✅ | Production | -| ECS | `Flecs.NET.Release` | 4.0.3 | 8/9 | ✅* | ✅ | ✅ | Production | +| ECS | `Flecs.NET.Release` | 4.0.4-build.546 | 8/9 | ✅* | ✅ | ✅ | Production | +| Model loading | `SharpGLTF.Core` | 1.0.6 | 9/10 | ✅ | ✅ | ✅ | Production | +| AI bridge | `ModelContextProtocol` | 1.4.0 | 8/9 | ❌† | ✅ | ✅ | Production | +| Screenshot | `SixLabors.ImageSharp` | 3.1.11 | 9/10 | ✅ | ✅ | ✅ | Production | | Physics | `JoltPhysicsSharp` | 2.21.0 | 9/10 | ✅ | ❌ | ✅ | Production | | JPEG | `SixLabors.ImageSharp` | latest | 9/10 | ✅ | ✅ | ✅ | Production | \* Via `true` +† MCP server is excluded from `ReleaseAOT` because it depends on ASP.NET Core. JSON-only AI commands still work in AOT via `AiCommandProcessor`. + --- ## 10. KNOWN RISKS & MITIGATIONS @@ -628,10 +650,11 @@ You are coding for Cortex Engine, a C# (.NET 9) AI-Native multiplatform 3D game Stack: - C# .NET 9 with dual-runtime: JIT (Debug) for Roslyn hot-reload, NativeAOT (ReleaseAOT) for JSON-only AI commands - SDL3-cs (ppy.SDL3-CS) for windowing and input -- Vortice.Vulkan for graphics +- Silk.NET.Vulkan for graphics - Flecs.NET for ECS - Hexa.NET.ImGui for editor UI - JoltPhysicsSharp for physics +- ModelContextProtocol for AI tool integration Rules: 1. All state lives in ECS components. GameObject is a struct facade. diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index 79c297f..ec6f338 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -29,12 +29,12 @@ class Program using var swapchain = new Swapchain(vulkan); using var renderer = new MeshRenderer(vulkan, swapchain); - var processor = new AiCommandProcessor(world, LoadModel); - var queue = new AiCommandQueue(processor); - var (modelPath, mcpPort) = ParseArgs(args); var mesh = LoadModel(modelPath); + var processor = new AiCommandProcessor(world, LoadModel, path => renderer.RequestScreenshot(path)); + var queue = new AiCommandQueue(processor); + var model = world.Entity("Model") .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, new Vector3(0.5f))) .Set(mesh); @@ -55,6 +55,7 @@ class Program Console.WriteLine(processor.Process("""{ "type": "spawn_model", "name": "SecondCube", "modelPath": "Content/cube.obj", "position": [0.8, 0, 0], "scale": [0.3, 0.3, 0.3] }""").Message); Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); Console.WriteLine(processor.Process("""{ "type": "set_transform", "name": "SecondCube", "position": [0.8, 0.5, 0], "rotation": [0, 0, 0, 1], "scale": [0.3, 0.3, 0.3] }""").Message); + Console.WriteLine(processor.Process("""{ "type": "capture_screenshot", "outputPath": "Screenshots/demo.png" }""").Message); #if !RELEASE_AOT // Start the MCP server in the background so AI agents can connect via HTTP. diff --git a/src/Engine.AI/AiCommandProcessor.cs b/src/Engine.AI/AiCommandProcessor.cs index 84a81ec..7679661 100644 --- a/src/Engine.AI/AiCommandProcessor.cs +++ b/src/Engine.AI/AiCommandProcessor.cs @@ -16,13 +16,15 @@ public sealed class AiCommandProcessor { private readonly World _world; private readonly Func _modelLoader; + private readonly Action _requestScreenshot; public JsonSerializerOptions JsonOptions { get; } - public AiCommandProcessor(World world, Func modelLoader) + public AiCommandProcessor(World world, Func modelLoader, Action requestScreenshot) { _world = world; _modelLoader = modelLoader; + _requestScreenshot = requestScreenshot; JsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -52,6 +54,7 @@ public sealed class AiCommandProcessor SetTransformCommand c => SetTransform(c), DeleteEntityCommand c => DeleteEntity(c), ListEntitiesCommand => ListEntities(), + CaptureScreenshotCommand c => CaptureScreenshot(c), _ => AiCommandResult.Error($"Unknown command type: {command.Type}") }; } @@ -122,4 +125,11 @@ public sealed class AiCommandProcessor var json = JsonSerializer.Serialize(names, JsonOptions); return AiCommandResult.Ok(json); } + + private AiCommandResult CaptureScreenshot(CaptureScreenshotCommand command) + { + var path = command.OutputPath ?? $"screenshot_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}.png"; + _requestScreenshot(path); + return AiCommandResult.Ok($"Screenshot requested: {path}"); + } } diff --git a/src/Engine.AI/Commands/AiCommand.cs b/src/Engine.AI/Commands/AiCommand.cs index ecd0778..59e6972 100644 --- a/src/Engine.AI/Commands/AiCommand.cs +++ b/src/Engine.AI/Commands/AiCommand.cs @@ -11,6 +11,7 @@ namespace Engine.AI.Commands; [JsonDerivedType(typeof(SetTransformCommand), "set_transform")] [JsonDerivedType(typeof(DeleteEntityCommand), "delete_entity")] [JsonDerivedType(typeof(ListEntitiesCommand), "list_entities")] +[JsonDerivedType(typeof(CaptureScreenshotCommand), "capture_screenshot")] public abstract record AiCommand { public string Type => GetType().Name.Replace("Command", "").ToLowerInvariant(); diff --git a/src/Engine.AI/Commands/CaptureScreenshotCommand.cs b/src/Engine.AI/Commands/CaptureScreenshotCommand.cs new file mode 100644 index 0000000..b1eca6f --- /dev/null +++ b/src/Engine.AI/Commands/CaptureScreenshotCommand.cs @@ -0,0 +1,12 @@ +namespace Engine.AI.Commands; + +/// +/// Request a screenshot of the current rendered frame for AI visual analysis. +/// +public sealed record CaptureScreenshotCommand : AiCommand +{ + /// + /// Output file path. If omitted, the engine chooses a default path. + /// + public string? OutputPath { get; init; } +} diff --git a/src/Engine.AI/Mcp/EngineMcpTools.cs b/src/Engine.AI/Mcp/EngineMcpTools.cs index a3ce397..74724e5 100644 --- a/src/Engine.AI/Mcp/EngineMcpTools.cs +++ b/src/Engine.AI/Mcp/EngineMcpTools.cs @@ -70,6 +70,13 @@ public sealed class EngineMcpTools return EnqueueAndReturnMessage(cmd); } + [McpServerTool, Description("Capture a screenshot of the current rendered frame and save it to disk.")] + public Task CaptureScreenshot([Description("Optional output file path (default: screenshot_.png)")] string? outputPath = null) + { + var cmd = new CaptureScreenshotCommand { OutputPath = outputPath }; + return EnqueueAndReturnMessage(cmd); + } + private async Task EnqueueAndReturnMessage(AiCommand command) { var result = await _queue.EnqueueAsync(command).ConfigureAwait(false); diff --git a/src/Engine.Graphics/Engine.Graphics.csproj b/src/Engine.Graphics/Engine.Graphics.csproj index a137b9d..52c1038 100644 --- a/src/Engine.Graphics/Engine.Graphics.csproj +++ b/src/Engine.Graphics/Engine.Graphics.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics/MeshRenderer.cs index c034084..20ca208 100644 --- a/src/Engine.Graphics/MeshRenderer.cs +++ b/src/Engine.Graphics/MeshRenderer.cs @@ -18,6 +18,7 @@ public sealed unsafe class MeshRenderer : IDisposable private readonly VulkanContext _context; private readonly Swapchain _swapchain; private readonly VulkanPipeline _pipeline; + private readonly ScreenshotCapture _screenshot; private readonly Dictionary _buffers = new(); private CommandPool _commandPool; private CommandBuffer[] _commandBuffers = null!; @@ -48,6 +49,7 @@ public sealed unsafe class MeshRenderer : IDisposable { _context = context; _swapchain = swapchain; + _screenshot = new ScreenshotCapture(context, swapchain); _pipeline = new VulkanPipeline(context, swapchain); CreateCommandPool(); @@ -118,6 +120,10 @@ public sealed unsafe class MeshRenderer : IDisposable } } + public void RequestScreenshot(string outputPath) => _screenshot.Request(outputPath); + + public bool IsScreenshotRequested => _screenshot.IsRequested; + public void RenderWorld(World world) { var frame = _currentFrame % 2; @@ -198,6 +204,10 @@ public sealed unsafe class MeshRenderer : IDisposable }); _context.Vk.CmdEndRenderPass(cmd); + + var swapchainImage = _swapchain.GetImage(imageIndex); + _screenshot.RecordReadback(cmd, swapchainImage, _swapchain.Extent.Width, _swapchain.Extent.Height, _swapchain.SurfaceFormat); + _context.Vk.EndCommandBuffer(cmd); var waitSemaphore = _imageAvailableSemaphores[frame]; @@ -229,6 +239,14 @@ public sealed unsafe class MeshRenderer : IDisposable }; _context.KhrSwapchain!.QueuePresent(_context.PresentQueue, &presentInfo); + + // If a screenshot was requested, wait for the GPU to finish the readback and save the file. + if (_screenshot.IsRequested) + { + _context.Vk.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue); + _screenshot.Save(_swapchain.Extent.Width, _swapchain.Extent.Height, _swapchain.SurfaceFormat); + } + _currentFrame++; } @@ -308,6 +326,8 @@ public sealed unsafe class MeshRenderer : IDisposable { _context.Vk.DeviceWaitIdle(_context.Device); + _screenshot.Dispose(); + foreach (var buffers in _buffers.Values) buffers.Dispose(); _buffers.Clear(); diff --git a/src/Engine.Graphics/ScreenshotCapture.cs b/src/Engine.Graphics/ScreenshotCapture.cs new file mode 100644 index 0000000..2a7f606 --- /dev/null +++ b/src/Engine.Graphics/ScreenshotCapture.cs @@ -0,0 +1,287 @@ +using System; +using System.IO; +using Silk.NET.Core; +using Silk.NET.Vulkan; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Engine.Graphics; + +/// +/// Captures the current swapchain image to a PNG file on disk. +/// Used by AI agents to visually inspect the running engine. +/// +public sealed unsafe class ScreenshotCapture : IDisposable +{ + private readonly VulkanContext _context; + private readonly Swapchain _swapchain; + private Silk.NET.Vulkan.Buffer _stagingBuffer; + private DeviceMemory _stagingMemory; + private ulong _stagingSize; + private bool _requested; + private string _outputPath = string.Empty; + private bool _ready; + + public ScreenshotCapture(VulkanContext context, Swapchain swapchain) + { + _context = context; + _swapchain = swapchain; + } + + /// + /// Request a screenshot to be captured on the next frame. + /// + public void Request(string outputPath) + { + _outputPath = outputPath; + _requested = true; + _ready = false; + } + + /// + /// True if a screenshot has been requested but not yet saved. + /// + public bool IsRequested => _requested; + + /// + /// Records the image readback commands into the given command buffer. + /// Must be called after the render pass has ended and before the image is presented. + /// + public void RecordReadback(CommandBuffer cmd, Silk.NET.Vulkan.Image sourceImage, uint width, uint height, Format format) + { + if (!_requested) + return; + + var pixelSize = GetPixelSize(format); + var rowPitch = width * pixelSize; + var imageSize = rowPitch * height; + + EnsureStagingBuffer(imageSize); + + // Transition from present layout to transfer source. + var barrier = new ImageMemoryBarrier + { + SType = StructureType.ImageMemoryBarrier, + OldLayout = ImageLayout.PresentSrcKhr, + NewLayout = ImageLayout.TransferSrcOptimal, + SrcAccessMask = AccessFlags.None, + DstAccessMask = AccessFlags.TransferReadBit, + Image = sourceImage, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1) + }; + + _context.Vk.CmdPipelineBarrier(cmd, PipelineStageFlags.TransferBit, PipelineStageFlags.TransferBit, 0, 0, null, 0, null, 1, &barrier); + + var copyRegion = new BufferImageCopy + { + BufferOffset = 0, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(width, height, 1) + }; + + _context.Vk.CmdCopyImageToBuffer(cmd, sourceImage, ImageLayout.TransferSrcOptimal, _stagingBuffer, 1, ©Region); + + // Transition back to present layout. + barrier.OldLayout = ImageLayout.TransferSrcOptimal; + barrier.NewLayout = ImageLayout.PresentSrcKhr; + barrier.SrcAccessMask = AccessFlags.TransferReadBit; + barrier.DstAccessMask = AccessFlags.None; + + _context.Vk.CmdPipelineBarrier(cmd, PipelineStageFlags.TransferBit, PipelineStageFlags.TransferBit, 0, 0, null, 0, null, 1, &barrier); + + _ready = true; + } + + /// + /// Save the captured pixels to disk. Must be called after the command buffer containing the readback has finished. + /// + public void Save(uint width, uint height, Format format) + { + if (!_ready) + return; + + var pixelSize = GetPixelSize(format); + var rowPitch = width * pixelSize; + var imageSize = rowPitch * height; + + void* mappedData; + var result = _context.Vk.MapMemory(_context.Device, _stagingMemory, 0, imageSize, MemoryMapFlags.None, &mappedData); + if (result != Result.Success) + throw new InvalidOperationException($"vkMapMemory failed: {result}"); + + try + { + var directory = Path.GetDirectoryName(_outputPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + SavePixels(mappedData, width, height, rowPitch, format); + } + finally + { + _context.Vk.UnmapMemory(_context.Device, _stagingMemory); + } + + Console.WriteLine($"Screenshot saved: {_outputPath}"); + _requested = false; + _ready = false; + } + + private void SavePixels(void* mappedData, uint width, uint height, uint rowPitch, Format format) + { + if (format == Format.B8G8R8A8Unorm || format == Format.B8G8R8A8Srgb) + { + SaveBgra(mappedData, width, height, rowPitch); + return; + } + + if (format == Format.R8G8B8A8Unorm || format == Format.R8G8B8A8Srgb) + { + SaveRgba(mappedData, width, height, rowPitch); + return; + } + + throw new NotSupportedException($"Screenshot format {format} is not supported."); + } + + private void SaveBgra(void* mappedData, uint width, uint height, uint rowPitch) + { + using var image = new SixLabors.ImageSharp.Image((int)width, (int)height); + var src = (byte*)mappedData; + + for (var y = 0; y < height; y++) + { + var rowStart = src + y * rowPitch; + for (var x = 0; x < width; x++) + { + var b = rowStart[x * 4 + 0]; + var g = rowStart[x * 4 + 1]; + var r = rowStart[x * 4 + 2]; + var a = rowStart[x * 4 + 3]; + image[x, y] = new Rgba32(r, g, b, a); + } + } + + image.SaveAsPng(_outputPath); + } + + private void SaveRgba(void* mappedData, uint width, uint height, uint rowPitch) + { + using var image = new SixLabors.ImageSharp.Image((int)width, (int)height); + var src = (byte*)mappedData; + + for (var y = 0; y < height; y++) + { + var rowStart = src + y * rowPitch; + for (var x = 0; x < width; x++) + { + var r = rowStart[x * 4 + 0]; + var g = rowStart[x * 4 + 1]; + var b = rowStart[x * 4 + 2]; + var a = rowStart[x * 4 + 3]; + image[x, y] = new Rgba32(r, g, b, a); + } + } + + image.SaveAsPng(_outputPath); + } + + private void EnsureStagingBuffer(ulong size) + { + if (_stagingSize >= size) + return; + + if (_stagingBuffer.Handle != 0) + { + _context.Vk.DestroyBuffer(_context.Device, _stagingBuffer, null); + _context.Vk.FreeMemory(_context.Device, _stagingMemory, null); + } + + _stagingSize = size; + _stagingBuffer = CreateBuffer(size, BufferUsageFlags.TransferDstBit); + var requirements = GetMemoryRequirements(_stagingBuffer); + _stagingMemory = AllocateMemory(requirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + var result = _context.Vk.BindBufferMemory(_context.Device, _stagingBuffer, _stagingMemory, 0); + if (result != Result.Success) + throw new InvalidOperationException($"vkBindBufferMemory failed: {result}"); + } + + private Silk.NET.Vulkan.Buffer CreateBuffer(ulong size, BufferUsageFlags usage) + { + var createInfo = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = size, + Usage = usage, + SharingMode = SharingMode.Exclusive + }; + + Silk.NET.Vulkan.Buffer buffer; + var result = _context.Vk.CreateBuffer(_context.Device, &createInfo, null, &buffer); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateBuffer failed: {result}"); + return buffer; + } + + private MemoryRequirements GetMemoryRequirements(Silk.NET.Vulkan.Buffer buffer) + { + MemoryRequirements requirements; + _context.Vk.GetBufferMemoryRequirements(_context.Device, buffer, &requirements); + return requirements; + } + + private DeviceMemory AllocateMemory(MemoryRequirements requirements, MemoryPropertyFlags properties) + { + var memoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, properties); + var allocInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = memoryTypeIndex + }; + + DeviceMemory memory; + var result = _context.Vk.AllocateMemory(_context.Device, &allocInfo, null, &memory); + if (result != Result.Success) + throw new InvalidOperationException($"vkAllocateMemory failed: {result}"); + return memory; + } + + private uint FindMemoryType(uint typeFilter, MemoryPropertyFlags properties) + { + PhysicalDeviceMemoryProperties memoryProperties; + _context.Vk.GetPhysicalDeviceMemoryProperties(_context.PhysicalDevice, &memoryProperties); + for (var i = 0; i < memoryProperties.MemoryTypeCount; i++) + { + if ((typeFilter & (1u << i)) != 0 && + (memoryProperties.MemoryTypes[i].PropertyFlags & properties) == properties) + { + return (uint)i; + } + } + throw new InvalidOperationException("Failed to find suitable memory type."); + } + + private static uint GetPixelSize(Format format) + { + return format switch + { + Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb or Format.R8G8B8A8Unorm or Format.R8G8B8A8Srgb => 4, + _ => throw new NotSupportedException($"Format {format} is not supported for screenshots.") + }; + } + + public void Dispose() + { + if (_stagingBuffer.Handle != 0) + { + _context.Vk.DeviceWaitIdle(_context.Device); + _context.Vk.DestroyBuffer(_context.Device, _stagingBuffer, null); + _context.Vk.FreeMemory(_context.Device, _stagingMemory, null); + } + } +} diff --git a/src/Engine.Graphics/Swapchain.cs b/src/Engine.Graphics/Swapchain.cs index b16111e..08924b0 100644 --- a/src/Engine.Graphics/Swapchain.cs +++ b/src/Engine.Graphics/Swapchain.cs @@ -30,6 +30,9 @@ public sealed unsafe class Swapchain : IDisposable public Extent2D Extent => _extent; public SwapchainKHR Handle => _swapchain; public uint ImageCount => (uint)_images.Length; + public Format SurfaceFormat => _surfaceFormat.Format; + + public Image GetImage(uint index) => _images[index]; public Swapchain(VulkanContext context) {