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)
{