diff --git a/.gitignore b/.gitignore index bd2c410..10ece78 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ Screenshots/ !*.csproj !*.sln !launchSettings.json +!claude_desktop_config.json .playwright-mcp/ diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index d083551..ba11496 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -256,9 +256,13 @@ public struct MeshRef : IComponent public struct Camera : IComponent { - public float Fov; - public float Near; - public float Far; + public Vector3 Position; + public Vector3 Target; + public Vector3 Up; + public float FieldOfView; + public float AspectRatio; + public float NearPlane; + public float FarPlane; } public struct Material : IComponent @@ -266,6 +270,14 @@ public struct Material : IComponent public Vector3 Albedo; public float Roughness; public float Metallic; + public string? TexturePath; +} + +public struct Light : IComponent +{ + public Vector3 Direction; + public Vector3 Color; + public float Intensity; } public struct SemanticClass : IComponent @@ -296,9 +308,11 @@ The renderer uses a simple forward-lit pipeline: - **Vertex format**: position, color, normal. - **Per-entity**: Mesh + Transform + optional Material. -- **Per-frame constants** via push constants: MVP matrix, light direction/color, ambient color, camera position. -- **Lighting model**: directional light with ambient + diffuse + Blinn-Phong specular. -- **Material**: CPU-side tint; `Material.Albedo` multiplies vertex color, `Roughness` and `Metallic` reserved for future PBR extension. +- **Per-frame constants** via a Vulkan uniform buffer (descriptor set 0): camera position, up to 4 directional lights, ambient color. +- **Per-entity constants** via push constants: MVP matrix, material albedo/roughness/metallic, texture use flag. +- **Lighting model**: multiple directional lights with ambient + diffuse + Blinn-Phong specular. +- **Material**: `Material.Albedo` tints vertex color, `Roughness` and `Metallic` control specular falloff and intensity; an optional `TexturePath` enables albedo texture sampling. +- **Textures**: PNG files are loaded into Vulkan images with a combined image sampler (descriptor set 1). UVs are derived from vertex position XZ for the floor plane; other meshes use world-space XZ as a simple mapping. ### 4.6 SystemSlotRegistry @@ -447,13 +461,20 @@ The `AiGateway` is the only allowed path for the AI to modify the running engine ### 6.2 MCP Server (Dev / Release) -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: +The engine can expose its AI commands through two MCP transports: + +1. **HTTP MCP server** (Debug/Release): an in-process ASP.NET Core server using `ModelContextProtocol.AspNetCore` with SSE on `http://localhost:/`. Enable with `--mcp-port `. +2. **Stdio MCP server** (Debug/Release): a minimal JSON-RPC server that reads from stdin and writes to stdout. Enable with `--mcp-stdio`. This is the format expected by Claude Desktop and other stdio MCP clients. + +Available tools: - `spawn_model` — spawn a named entity from a model file. - `set_transform` — update entity position, rotation, scale. +- `set_material` — update entity albedo, roughness, metallic, and texture path. - `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. +- `get_world_state` — dump the ECS world as JSON (Transform, Camera, Material, Light, Mesh). +- `capture_screenshot` — save a PNG of the current frame (HTTP/render mode only). Commands are queued and executed on the main engine thread so the Flecs world is never touched from a background thread. @@ -509,7 +530,7 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ ├── Timing.cs # DeltaTime, fixed timestep │ │ ├── InputMapping.cs # Keyboard, mouse, gamepad input │ │ ├── OrbitCameraController.cs # Mouse orbit camera -│ │ └── Components/ # Transform, Camera, Material, Mesh +│ │ └── Components/ # Transform, Camera, Light, Material, Mesh │ │ │ ├── Engine.Data/ │ │ ├── GameObject.cs # Thin struct facade @@ -518,11 +539,13 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ └── SystemSlotRegistry.cs # Named system hot-swap registry │ │ │ ├── Engine.Graphics/ -│ │ ├── VulkanContext.cs # Device, instance, queues -│ │ ├── Swapchain.cs # Swapchain management +│ │ ├── VulkanContext.cs # Device, instance, queues, command pool +│ │ ├── Swapchain.cs # Swapchain + depth buffer │ │ ├── MeshRenderer.cs # ECS mesh rendering │ │ ├── ScreenshotCapture.cs # Vulkan readback → PNG -│ │ ├── VulkanPipeline.cs # Graphics pipeline +│ │ ├── VulkanPipeline.cs # Graphics pipeline + descriptor layouts +│ │ ├── UniformBuffer.cs # Per-frame uniform buffer +│ │ ├── Texture.cs # Vulkan texture (image, view, sampler) │ │ ├── VertexBuffer.cs # Vertex buffer helpers │ │ ├── IndexBuffer.cs # Index buffer helpers │ │ └── Loaders/ # ObjLoader, GltfLoader @@ -538,8 +561,10 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ ├── AiCommandProcessor.cs # Parses and executes JSON commands │ │ ├── AiCommandQueue.cs # Thread-safe command queue │ │ ├── Mcp/ -│ │ │ ├── EngineMcpTools.cs # MCP tool definitions +│ │ │ ├── EngineMcpTools.cs # MCP HTTP tool definitions │ │ │ └── McpEngineServerHost.cs # In-process MCP HTTP server +│ │ ├── Stdio/ +│ │ │ └── McpStdioServer.cs # Minimal stdio MCP server │ │ ├── Commands/ # AI command DTOs │ │ └── Serialization/ # JSON converters for Vector3/Quaternion │ │ @@ -676,7 +701,7 @@ Stack: - ModelContextProtocol for AI tool integration Rules: -1. All state lives in ECS components (Transform, Camera, Mesh, Material). +1. All state lives in ECS components (Transform, Camera, Light, Mesh, Material). 2. All AI mutations go through Engine.AI (AiCommandProcessor / MCP tools). 3. All Dev-Mode AI scripts must be AOT-compatible and avoid unsafe, Reflection.Emit, Assembly.Load, File I/O. 4. All systems are registered in SystemSlotRegistry and tagged with [Slot("name")]. @@ -691,10 +716,86 @@ Current file context: [insert path here] ## 12. NEXT DECISION POINTS -1. Which **Step 1/2/3** should be implemented first? (Recommended: Step 1 for visible window) -2. Should `Hexa.NET.ImGui` be pinned to a specific version immediately? -3. Should `Engine.Broker` HTTP server be included in MVP or deferred? -4. Should Jolt Physics be added in Step 3 or kept for a later milestone? +1. Add ImGui editor UI (`Hexa.NET.ImGui`) for scene hierarchy and inspector. +2. Add physics integration (`JoltPhysicsSharp`) with rigid bodies and colliders. +3. Implement semantic segmentation render pass for AI vision. +4. Add audio module (`NAudio` or `OpenAL` bindings). +5. Add networking / multiplayer foundation. + +--- + +## 13. RUNTIME NOTES & CRITICAL CONTEXT + +### 13.1 Building & Running + +```bash +export DOTNET_ROOT="$HOME/.dotnet" +export PATH="$DOTNET_ROOT:$PATH" +export DISPLAY=:0 +dotnet build CORTEX_ENGINE.sln -c Debug +dotnet run --project src/CortexEngine.App/CortexEngine.App.csproj +``` + +- `RuntimeIdentifier=linux-x64` is required in Debug to use the bundled native `libSDL3.so` from `ppy.SDL3-CS` (system `libSDL3.so.3.4.2` is ABI-incompatible). +- AOT builds: `dotnet build CORTEX_ENGINE.sln -c ReleaseAOT`. + +### 13.2 CLI Arguments + +- `--mcp-port ` — start the HTTP MCP server on `http://localhost:/` (SSE). +- `--mcp-stdio` — run the headless stdio MCP server for Claude Desktop / other stdio clients. +- Any other positional argument is treated as a model path (`.obj`, `.gltf`, `.glb`). + +### 13.3 Vulkan & Shader Pipeline + +- Pipeline layout uses **two descriptor sets**: set 0 = per-frame uniform buffer (camera + lights), set 1 = per-entity combined image sampler. +- Push constants: 96 bytes (`mat4 mvp` + material albedo/roughness/metallic + texture flag + padding), stages `VertexBit | FragmentBit`. +- Uniform buffer: std140 224 bytes (`cameraPosition`, `lightCount`, `ambientColor`, up to 4 `Light` structs). +- Shaders are compiled with `glslangValidator`: + ```bash + /tmp/glslang/bin/glslangValidator -V src/Engine.Graphics/Shaders/vertex.vert -o src/Engine.Graphics/Shaders/vertex.spv + /tmp/glslang/bin/glslangValidator -V src/Engine.Graphics/Shaders/fragment.frag -o src/Engine.Graphics/Shaders/fragment.spv + ``` + +### 13.4 SDL3 Input + +- `SDL3 2026.520.0` API: `SDL_Init` returns `SDLBool`, `SDL_PollEvent` returns `SDLBool`, `evt.type` is `uint`. +- Keyboard: `evt.key.key`; Mouse: `evt.motion.x`, `evt.motion.y`, `evt.wheel.y`. +- Orbit camera: right mouse drag rotates, mouse wheel zooms, `ESC` exits. + +### 13.5 MCP Client Config + +Sample Claude Desktop config (`claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "cortex-engine": { + "command": "dotnet", + "args": [ + "run", + "--project", + "/home/emil/Desktop/Cortex_Engine/src/CortexEngine.App/CortexEngine.App.csproj", + "--", + "--mcp-stdio" + ], + "env": { + "DOTNET_ROOT": "/home/emil/.dotnet", + "PATH": "/home/emil/.dotnet:/usr/bin:/bin" + } + } + } +} +``` + +For the HTTP MCP server, use the `--mcp-port` argument and connect an SSE MCP client. + +### 13.6 Process Cleanup + +Background `dotnet run` processes may leave the apphost running. Kill them with: + +```bash +ps -C CortexEngine.App -o pid= | xargs -r kill -9 +``` --- diff --git a/Content/checkerboard.png b/Content/checkerboard.png new file mode 100644 index 0000000..620a788 Binary files /dev/null and b/Content/checkerboard.png differ diff --git a/claude_desktop_config.json b/claude_desktop_config.json new file mode 100644 index 0000000..482d086 --- /dev/null +++ b/claude_desktop_config.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "cortex-engine": { + "command": "dotnet", + "args": [ + "run", + "--project", + "/home/emil/Desktop/Cortex_Engine/src/CortexEngine.App/CortexEngine.App.csproj", + "--", + "--mcp-stdio" + ], + "env": { + "DOTNET_ROOT": "/home/emil/.dotnet", + "PATH": "/home/emil/.dotnet:/usr/bin:/bin" + } + } + } +} diff --git a/scripts/start_mcp_engine.sh b/scripts/start_mcp_engine.sh new file mode 100755 index 0000000..54356ca --- /dev/null +++ b/scripts/start_mcp_engine.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Start the Cortex Engine with the HTTP MCP server enabled. + +PORT="${1:-5000}" +ENGINE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +export DISPLAY="${DISPLAY:-:0}" +export DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}" +export PATH="$DOTNET_ROOT:$PATH" + +cd "$ENGINE_DIR" +exec dotnet run --project "$ENGINE_DIR/src/CortexEngine.App/CortexEngine.App.csproj" -- --mcp-port "$PORT" diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index e89a736..a07c288 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -3,8 +3,12 @@ using System.Collections.Generic; using System.IO; using System.Numerics; using Engine.AI; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; #if !RELEASE_AOT using Engine.AI.Mcp; +using Microsoft.AspNetCore.Builder; #endif using Engine.Core; using Engine.Core.Components; @@ -22,6 +26,12 @@ class Program try { + if (args.Contains("--mcp-stdio")) + { + RunMcpStdioServer(); + return; + } + using var world = World.Create(); using var window = new Sdl3Window("Cortex Engine", 1280, 720); var timing = new Timing(); @@ -37,16 +47,35 @@ class Program var queue = new AiCommandQueue(processor); var cameraEntity = world.Entity("Camera") + .Set(new Transform(new Vector3(0.0f, 2.5f, -4.0f), Quaternion.Identity, Vector3.One)) .Set(new Camera( - new Vector3(0.0f, 1.5f, -3.0f), - Vector3.Zero, + new Vector3(0.0f, 2.5f, -4.0f), + new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY, MathF.PI / 4.0f, 1280.0f / 720.0f, 0.1f, 100.0f)); - var orbit = new OrbitCameraController(cameraEntity, Vector3.Zero); + world.Entity("MainLight") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(new Light(new Vector3(0.5f, -1.0f, -0.5f), new Vector3(1.0f, 0.95f, 0.8f), 1.0f)); + + world.Entity("FillLight") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(new Light(new Vector3(-0.8f, -0.6f, 0.3f), new Vector3(0.3f, 0.4f, 0.6f), 0.6f)); + + world.Entity("FrontLight") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(new Light(new Vector3(0.0f, -0.3f, -1.0f), new Vector3(0.8f, 0.8f, 0.9f), 0.4f)); + + world.Entity("GroundLight") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(new Light(new Vector3(0.0f, 1.0f, 0.0f), new Vector3(0.15f, 0.15f, 0.2f), 0.3f)); + + var orbit = new OrbitCameraController(cameraEntity, new Vector3(0.0f, 0.5f, 0.0f)); + + var texturePath = GenerateCheckerboardTexture("Content/checkerboard.png", 256); var model = world.Entity("Model") .Set(new Transform(new Vector3(0.0f, 0.5f, 0.0f), Quaternion.Identity, new Vector3(0.5f))) @@ -55,8 +84,8 @@ class Program var floor = world.Entity("Floor") .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) - .Set(CreateFloorMesh(20.0f, new Vector3(0.08f, 0.08f, 0.1f))) - .Set(new Material(new Vector3(0.08f, 0.08f, 0.1f), roughness: 0.9f, metallic: 0.0f)); + .Set(CreateFloorMesh(20.0f, new Vector3(0.8f, 0.8f, 0.85f))) + .Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.9f, metallic: 0.0f, texturePath: texturePath)); var grid = world.Entity("Grid") .Set(new Transform(new Vector3(0.0f, 0.01f, 0.0f), Quaternion.Identity, Vector3.One)) @@ -74,24 +103,37 @@ class Program secondCube.Set(new Material(new Vector3(0.3f, 0.7f, 0.9f), roughness: 0.3f, metallic: 0.2f)); Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); + Console.WriteLine(processor.Process("""{ "type": "get_world_state" }""").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. - var mcpApp = McpEngineServerHost.Create(args, queue, port: mcpPort); - var mcpTask = mcpApp.RunAsync(); - _ = mcpTask.ContinueWith(t => + WebApplication? mcpApp = null; + Task? mcpTask = null; + + if (mcpPort > 0) { - if (t.IsFaulted) - Console.WriteLine($"MCP server error: {t.Exception?.GetBaseException().Message}"); - else if (t.IsCanceled) - Console.WriteLine("MCP server canceled."); - else - Console.WriteLine("MCP server stopped."); - }, TaskScheduler.Default); - Console.WriteLine($"MCP server starting on http://localhost:{mcpPort}"); + // Start the MCP server in the background so AI agents can connect via HTTP. + mcpApp = McpEngineServerHost.Create(args, queue, port: mcpPort); + mcpTask = mcpApp.RunAsync(); + _ = mcpTask.ContinueWith(t => + { + if (t.IsFaulted) + Console.WriteLine($"MCP server error: {t.Exception?.GetBaseException().Message}"); + else if (t.IsCanceled) + Console.WriteLine("MCP server canceled."); + else + Console.WriteLine("MCP server stopped."); + }, TaskScheduler.Default); + + Console.WriteLine($"MCP HTTP server listening on http://localhost:{mcpPort}/ (SSE)"); + } + else + { + Console.WriteLine("MCP server disabled (--mcp-port 0)."); + } #endif + var frames = 0; var lastFpsTime = 0.0; var lastWidth = window.Width; @@ -138,10 +180,10 @@ class Program Console.WriteLine("Shutting down..."); #if !RELEASE_AOT - await mcpApp.StopAsync(); - await mcpTask; -#else - await Task.CompletedTask; + if (mcpApp != null) + await mcpApp.StopAsync(); + if (mcpTask != null) + await mcpTask; #endif } catch (Exception ex) @@ -258,4 +300,35 @@ class Program throw new FileNotFoundException("No model file found. Pass a .obj/.gltf/.glb path as argument or place Content/cube.obj next to the executable."); } + + private static string GenerateCheckerboardTexture(string path, int size) + { + var tileSize = size / 8; + using var image = new Image(size, size); + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + var tileX = x / tileSize; + var tileY = y / tileSize; + var isDark = (tileX + tileY) % 2 == 0; + image[x, y] = isDark + ? new Rgba32(60, 60, 70, 255) + : new Rgba32(160, 160, 170, 255); + } + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + image.SaveAsPng(path); + return path; + } + + private static void RunMcpStdioServer() + { + Console.WriteLine("Starting headless stdio MCP server..."); + using var world = World.Create(); + var processor = new AiCommandProcessor(world, LoadModel, _ => { }); + var server = new Engine.AI.Stdio.McpStdioServer(processor); + server.Run(); + } } diff --git a/src/Engine.AI/AiCommandProcessor.cs b/src/Engine.AI/AiCommandProcessor.cs index 7679661..61a120d 100644 --- a/src/Engine.AI/AiCommandProcessor.cs +++ b/src/Engine.AI/AiCommandProcessor.cs @@ -1,4 +1,5 @@ using System.Numerics; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Engine.AI.Commands; @@ -55,6 +56,8 @@ public sealed class AiCommandProcessor DeleteEntityCommand c => DeleteEntity(c), ListEntitiesCommand => ListEntities(), CaptureScreenshotCommand c => CaptureScreenshot(c), + GetWorldStateCommand => GetWorldState(), + SetMaterialCommand c => SetMaterial(c), _ => AiCommandResult.Error($"Unknown command type: {command.Type}") }; } @@ -132,4 +135,128 @@ public sealed class AiCommandProcessor _requestScreenshot(path); return AiCommandResult.Ok($"Screenshot requested: {path}"); } + + private AiCommandResult SetMaterial(SetMaterialCommand command) + { + var entity = _world.Lookup(command.Name); + if ((ulong)entity.Id == 0) + return AiCommandResult.Error($"Entity '{command.Name}' not found."); + + ref var material = ref entity.Ensure(); + + if (command.Albedo.HasValue) + material.Albedo = command.Albedo.Value; + if (command.Roughness.HasValue) + material.Roughness = command.Roughness.Value; + if (command.Metallic.HasValue) + material.Metallic = command.Metallic.Value; + if (command.TexturePath is not null) + material.TexturePath = command.TexturePath; + + return AiCommandResult.Ok($"Updated material for entity '{command.Name}'."); + } + + private AiCommandResult GetWorldState() + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false })) + { + writer.WriteStartArray(); + + _world.Each((Entity e, ref Transform _) => + { + var name = e.Name(); + if (string.IsNullOrEmpty(name)) + return; + + writer.WriteStartObject(); + writer.WriteString("name", name); + writer.WriteNumber("id", (ulong)e.Id); + + writer.WriteStartObject("components"); + + if (e.Has()) + { + ref var transform = ref e.Ensure(); + writer.WriteStartObject("Transform"); + WriteVector3(writer, "position", transform.Position); + WriteQuaternion(writer, "rotation", transform.Rotation); + WriteVector3(writer, "scale", transform.Scale); + writer.WriteEndObject(); + } + + if (e.Has()) + { + ref var camera = ref e.Ensure(); + writer.WriteStartObject("Camera"); + writer.WriteNumber("fieldOfView", camera.FieldOfView); + writer.WriteNumber("aspectRatio", camera.AspectRatio); + writer.WriteNumber("nearPlane", camera.NearPlane); + writer.WriteNumber("farPlane", camera.FarPlane); + WriteVector3(writer, "position", camera.Position); + WriteVector3(writer, "target", camera.Target); + WriteVector3(writer, "up", camera.Up); + writer.WriteEndObject(); + } + + if (e.Has()) + { + ref var material = ref e.Ensure(); + writer.WriteStartObject("Material"); + WriteVector3(writer, "albedo", material.Albedo); + writer.WriteNumber("roughness", material.Roughness); + writer.WriteNumber("metallic", material.Metallic); + if (material.HasTexture) + writer.WriteString("texturePath", material.TexturePath); + writer.WriteEndObject(); + } + + if (e.Has()) + { + ref var mesh = ref e.Ensure(); + writer.WriteStartObject("Mesh"); + writer.WriteNumber("vertexCount", mesh.Vertices.Length); + writer.WriteNumber("indexCount", mesh.Indices.Length); + writer.WriteEndObject(); + } + + if (e.Has()) + { + ref var light = ref e.Ensure(); + writer.WriteStartObject("Light"); + WriteVector3(writer, "direction", light.Direction); + WriteVector3(writer, "color", light.Color); + writer.WriteNumber("intensity", light.Intensity); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + }); + + writer.WriteEndArray(); + } + + var json = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + return AiCommandResult.Ok(json); + } + + private static void WriteVector3(Utf8JsonWriter writer, string propertyName, Vector3 value) + { + writer.WriteStartArray(propertyName); + writer.WriteNumberValue(value.X); + writer.WriteNumberValue(value.Y); + writer.WriteNumberValue(value.Z); + writer.WriteEndArray(); + } + + private static void WriteQuaternion(Utf8JsonWriter writer, string propertyName, Quaternion value) + { + writer.WriteStartArray(propertyName); + writer.WriteNumberValue(value.X); + writer.WriteNumberValue(value.Y); + writer.WriteNumberValue(value.Z); + writer.WriteNumberValue(value.W); + writer.WriteEndArray(); + } } diff --git a/src/Engine.AI/Commands/AiCommand.cs b/src/Engine.AI/Commands/AiCommand.cs index 59e6972..2308cd5 100644 --- a/src/Engine.AI/Commands/AiCommand.cs +++ b/src/Engine.AI/Commands/AiCommand.cs @@ -12,6 +12,8 @@ namespace Engine.AI.Commands; [JsonDerivedType(typeof(DeleteEntityCommand), "delete_entity")] [JsonDerivedType(typeof(ListEntitiesCommand), "list_entities")] [JsonDerivedType(typeof(CaptureScreenshotCommand), "capture_screenshot")] +[JsonDerivedType(typeof(GetWorldStateCommand), "get_world_state")] +[JsonDerivedType(typeof(SetMaterialCommand), "set_material")] public abstract record AiCommand { public string Type => GetType().Name.Replace("Command", "").ToLowerInvariant(); diff --git a/src/Engine.AI/Commands/GetWorldStateCommand.cs b/src/Engine.AI/Commands/GetWorldStateCommand.cs new file mode 100644 index 0000000..9784a9c --- /dev/null +++ b/src/Engine.AI/Commands/GetWorldStateCommand.cs @@ -0,0 +1,6 @@ +namespace Engine.AI.Commands; + +/// +/// Dump the current ECS world state as JSON for AI analysis. +/// +public sealed record GetWorldStateCommand : AiCommand; diff --git a/src/Engine.AI/Commands/SetMaterialCommand.cs b/src/Engine.AI/Commands/SetMaterialCommand.cs new file mode 100644 index 0000000..fb0afc3 --- /dev/null +++ b/src/Engine.AI/Commands/SetMaterialCommand.cs @@ -0,0 +1,15 @@ +using System.Numerics; + +namespace Engine.AI.Commands; + +/// +/// Update the Material component of an existing entity by name. +/// +public sealed record SetMaterialCommand : AiCommand +{ + public required string Name { get; init; } + public Vector3? Albedo { get; init; } + public float? Roughness { get; init; } + public float? Metallic { get; init; } + public string? TexturePath { get; init; } +} diff --git a/src/Engine.AI/Mcp/EngineMcpTools.cs b/src/Engine.AI/Mcp/EngineMcpTools.cs index 74724e5..22a5d11 100644 --- a/src/Engine.AI/Mcp/EngineMcpTools.cs +++ b/src/Engine.AI/Mcp/EngineMcpTools.cs @@ -77,6 +77,33 @@ public sealed class EngineMcpTools return EnqueueAndReturnMessage(cmd); } + [McpServerTool, Description("Dump the current ECS world state as JSON, including Transform, Camera, Material, Light, and Mesh component summaries.")] + public Task GetWorldState() + { + var cmd = new GetWorldStateCommand(); + return EnqueueAndReturnMessage(cmd); + } + + [McpServerTool, Description("Update the material of an existing entity by name.")] + public Task SetMaterial( + string name, + [Description("Optional albedo color as [r, g, b] (0-1)")] IReadOnlyList? albedo = null, + [Description("Optional roughness value (0-1)")] float? roughness = null, + [Description("Optional metallic value (0-1)")] float? metallic = null, + [Description("Optional path to a PNG texture file")] string? texturePath = null) + { + var cmd = new SetMaterialCommand + { + Name = name, + Albedo = ToVector3(albedo), + Roughness = roughness, + Metallic = metallic, + TexturePath = texturePath + }; + + return EnqueueAndReturnMessage(cmd); + } + private async Task EnqueueAndReturnMessage(AiCommand command) { var result = await _queue.EnqueueAsync(command).ConfigureAwait(false); diff --git a/src/Engine.AI/Stdio/McpStdioServer.cs b/src/Engine.AI/Stdio/McpStdioServer.cs new file mode 100644 index 0000000..0fac0d1 --- /dev/null +++ b/src/Engine.AI/Stdio/McpStdioServer.cs @@ -0,0 +1,243 @@ +using System.Text; +using System.Text.Json; +using Engine.AI.Commands; + +namespace Engine.AI.Stdio; + +/// +/// A minimal stdio MCP server that routes JSON-RPC requests to an . +/// This is suitable for clients like Claude Desktop that speak MCP over stdio. +/// +public sealed class McpStdioServer +{ + private readonly AiCommandProcessor _processor; + private readonly JsonSerializerOptions _jsonOptions; + private readonly Dictionary _tools; + + public McpStdioServer(AiCommandProcessor processor) + { + _processor = processor; + _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + _tools = new Dictionary + { + ["SpawnModel"] = new( + "Spawn a 3D model entity in the engine world.", + new JsonSchemaBuilder() + .AddRequiredString("name") + .AddRequiredString("modelPath") + .AddOptionalArray("position", "number", 3) + .AddOptionalArray("rotation", "number", 4) + .AddOptionalArray("scale", "number", 3) + .Build()), + ["SetTransform"] = new( + "Update the transform of an existing entity by name.", + new JsonSchemaBuilder() + .AddRequiredString("name") + .AddOptionalArray("position", "number", 3) + .AddOptionalArray("rotation", "number", 4) + .AddOptionalArray("scale", "number", 3) + .Build()), + ["SetMaterial"] = new( + "Update the material of an existing entity by name.", + new JsonSchemaBuilder() + .AddRequiredString("name") + .AddOptionalArray("albedo", "number", 3) + .AddOptionalNumber("roughness") + .AddOptionalNumber("metallic") + .AddOptionalString("texturePath") + .Build()), + ["DeleteEntity"] = new( + "Delete an entity by name.", + new JsonSchemaBuilder() + .AddRequiredString("name") + .Build()), + ["ListEntities"] = new( + "List all named entities in the ECS world.", + new JsonSchemaBuilder().Build()), + ["CaptureScreenshot"] = new( + "Capture a screenshot of the current rendered frame and save it to disk.", + new JsonSchemaBuilder() + .AddOptionalString("outputPath") + .Build()), + ["GetWorldState"] = new( + "Dump the current ECS world state as JSON.", + new JsonSchemaBuilder().Build()) + }; + } + + public void Run() + { + var input = Console.OpenStandardInput(); + var output = Console.OpenStandardOutput(); + using var reader = new StreamReader(input, Encoding.UTF8); + using var writer = new StreamWriter(output, Encoding.UTF8) { AutoFlush = true }; + + while (true) + { + var line = reader.ReadLine(); + if (line == null) + break; + + if (string.IsNullOrWhiteSpace(line)) + continue; + + var response = HandleMessage(line); + if (response != null) + { + writer.WriteLine(JsonSerializer.Serialize(response, _jsonOptions)); + } + } + } + + private JsonElement? HandleMessage(string line) + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + + var method = root.GetProperty("method").GetString(); + var id = root.TryGetProperty("id", out var idProp) ? (JsonElement?)idProp : null; + + switch (method) + { + case "initialize": + return MakeResponse(id, new + { + protocolVersion = "2024-11-05", + capabilities = new { tools = new { } }, + serverInfo = new { name = "CortexEngine", version = "0.1.0" } + }); + + case "notifications/initialized": + return null; + + case "tools/list": + return MakeResponse(id, new { tools = _tools.Select(t => new { type = "function", function = new { name = t.Key, description = t.Value.Description, parameters = t.Value.Parameters } }).ToList() }); + + case "tools/call": + return HandleToolCall(id, root.GetProperty("params")); + + case "ping": + return MakeResponse(id, new { }); + + default: + return MakeError(id, -32601, $"Method not found: {method}"); + } + } + + private JsonElement? HandleToolCall(JsonElement? id, JsonElement paramsElement) + { + var name = paramsElement.GetProperty("name").GetString(); + var arguments = paramsElement.GetProperty("arguments"); + + if (!_tools.TryGetValue(name ?? string.Empty, out _)) + return MakeError(id, -32601, $"Tool not found: {name}"); + + var command = BuildCommand(name!, arguments); + var result = _processor.Process(command); + + return MakeResponse(id, new { content = new[] { new { type = "text", text = result.Message } }, isError = !result.Success }); + } + + private string BuildCommand(string toolName, JsonElement arguments) + { + var type = toolName switch + { + "SpawnModel" => "spawn_model", + "SetTransform" => "set_transform", + "SetMaterial" => "set_material", + "DeleteEntity" => "delete_entity", + "ListEntities" => "list_entities", + "CaptureScreenshot" => "capture_screenshot", + "GetWorldState" => "get_world_state", + _ => toolName.ToLowerInvariant() + }; + + var dict = new Dictionary { ["type"] = type }; + foreach (var property in arguments.EnumerateObject()) + dict[property.Name] = ConvertArgument(property.Value); + + return JsonSerializer.Serialize(dict, _jsonOptions); + } + + private static object ConvertArgument(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString()!, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Array => element.EnumerateArray().Select(ConvertArgument).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => ConvertArgument(p.Value)), + _ => element.GetRawText() + }; + } + + private JsonElement? MakeResponse(JsonElement? id, object result) + { + if (id == null) + return null; + + var json = JsonSerializer.Serialize(new { jsonrpc = "2.0", result, id = id.Value }, _jsonOptions); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + private JsonElement? MakeError(JsonElement? id, int code, string message) + { + if (id == null) + return null; + + var json = JsonSerializer.Serialize(new { jsonrpc = "2.0", error = new { code, message }, id = id.Value }, _jsonOptions); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + private sealed record ToolDefinition(string Description, JsonElement Parameters); + + private sealed class JsonSchemaBuilder + { + private readonly Dictionary _properties = new(); + private readonly List _required = new(); + private readonly string _type = "object"; + + public JsonSchemaBuilder AddRequiredString(string name) + { + _properties[name] = new { type = "string" }; + _required.Add(name); + return this; + } + + public JsonSchemaBuilder AddOptionalString(string name) + { + _properties[name] = new { type = "string" }; + return this; + } + + public JsonSchemaBuilder AddOptionalNumber(string name) + { + _properties[name] = new { type = "number" }; + return this; + } + + public JsonSchemaBuilder AddOptionalArray(string name, string itemType, int? minItems = null) + { + _properties[name] = new { type = "array", items = new { type = itemType }, minItems }; + return this; + } + + public JsonElement Build() + { + var dict = new Dictionary + { + ["type"] = _type, + ["properties"] = _properties, + ["required"] = _required + }; + var json = JsonSerializer.Serialize(dict); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + } + +} diff --git a/src/Engine.Core/Components/Light.cs b/src/Engine.Core/Components/Light.cs new file mode 100644 index 0000000..0f4336e --- /dev/null +++ b/src/Engine.Core/Components/Light.cs @@ -0,0 +1,20 @@ +using System.Numerics; + +namespace Engine.Core.Components; + +/// +/// A directional light component for the ECS. +/// +public record struct Light +{ + public Vector3 Direction; + public Vector3 Color; + public float Intensity; + + public Light(Vector3 direction, Vector3 color, float intensity = 1.0f) + { + Direction = Vector3.Normalize(direction); + Color = color; + Intensity = intensity; + } +} diff --git a/src/Engine.Core/Components/Material.cs b/src/Engine.Core/Components/Material.cs index 015970a..da4e673 100644 --- a/src/Engine.Core/Components/Material.cs +++ b/src/Engine.Core/Components/Material.cs @@ -11,13 +11,16 @@ public record struct Material public Vector3 Albedo; public float Roughness; public float Metallic; + public string? TexturePath; - public Material(Vector3? albedo = null, float roughness = 0.5f, float metallic = 0.0f) + public Material(Vector3? albedo = null, float roughness = 0.5f, float metallic = 0.0f, string? texturePath = null) { Albedo = albedo ?? new Vector3(0.7f, 0.6f, 0.5f); Roughness = roughness; Metallic = metallic; + TexturePath = texturePath; } public static Material Default => new(new Vector3(0.7f, 0.6f, 0.5f), 0.5f, 0.0f); + public bool HasTexture => !string.IsNullOrEmpty(TexturePath); } diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics/MeshRenderer.cs index 65408dc..93550b5 100644 --- a/src/Engine.Graphics/MeshRenderer.cs +++ b/src/Engine.Graphics/MeshRenderer.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using Flecs.NET.Core; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; using Silk.NET.Core; using Silk.NET.Vulkan; using Engine.Core; @@ -20,6 +22,13 @@ public sealed unsafe class MeshRenderer : IDisposable private readonly Swapchain _swapchain; private readonly VulkanPipeline _pipeline; private readonly ScreenshotCapture _screenshot; + private readonly UniformBuffer _frameConstantsBuffer; + private DescriptorPool _frameDescriptorPool; + private DescriptorSet _frameDescriptorSet; + private DescriptorPool _textureDescriptorPool; + private readonly Dictionary _textures = new(); + private readonly Dictionary _textureDescriptorSets = new(); + private Texture? _defaultTexture; private readonly Dictionary _buffers = new(); private CommandPool _commandPool; private CommandBuffer[] _commandBuffers = null!; @@ -28,18 +37,38 @@ public sealed unsafe class MeshRenderer : IDisposable private Silk.NET.Vulkan.Fence[] _inFlightFences = null!; private int _currentFrame; - [StructLayout(LayoutKind.Sequential)] + [StructLayout(LayoutKind.Sequential, Size = 96)] private struct PushConstants { public Matrix4x4 Mvp; - public Vector3 LightDirection; - public float Pad1; - public Vector3 LightColor; - public float Pad2; - public Vector3 AmbientColor; - public float Pad3; + public Vector3 MaterialAlbedo; + public float MaterialRoughness; + public float MaterialMetallic; + public uint UseTexture; + public uint TextureIndex; + public uint Pad0; + } + + [StructLayout(LayoutKind.Sequential, Size = 48)] + private struct GpuLight + { + public Vector3 Direction; + public float Intensity; + public Vector3 Color; + public float Padding; + } + + [StructLayout(LayoutKind.Sequential, Size = 224)] + private struct FrameConstants + { public Vector3 CameraPosition; - public float Pad4; + public uint LightCount; + public Vector3 AmbientColor; + public float AmbientPadding; + public GpuLight Light0; + public GpuLight Light1; + public GpuLight Light2; + public GpuLight Light3; } private sealed class MeshBuffers : IDisposable @@ -67,6 +96,11 @@ public sealed unsafe class MeshRenderer : IDisposable _screenshot = new ScreenshotCapture(context, swapchain); _pipeline = new VulkanPipeline(context, swapchain); + _frameConstantsBuffer = new UniformBuffer(context, (ulong)sizeof(FrameConstants)); + CreateFrameDescriptorPool(); + CreateFrameDescriptorSet(); + CreateTextureDescriptorPool(); + CreateDefaultTexture(); CreateCommandPool(); CreateCommandBuffers(); CreateSyncObjects(); @@ -135,6 +169,120 @@ public sealed unsafe class MeshRenderer : IDisposable } } + private void CreateFrameDescriptorPool() + { + var poolSize = new DescriptorPoolSize + { + Type = DescriptorType.UniformBuffer, + DescriptorCount = 1 + }; + + var createInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &poolSize + }; + + DescriptorPool descriptorPool; + var result = _context.Vk.CreateDescriptorPool(_context.Device, &createInfo, null, &descriptorPool); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateDescriptorPool failed: {result}"); + _frameDescriptorPool = descriptorPool; + } + + private void CreateFrameDescriptorSet() + { + var layout = _pipeline.FrameDescriptorSetLayout; + var allocInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _frameDescriptorPool, + DescriptorSetCount = 1, + PSetLayouts = &layout + }; + + DescriptorSet descriptorSet; + var result = _context.Vk.AllocateDescriptorSets(_context.Device, &allocInfo, &descriptorSet); + if (result != Result.Success) + throw new InvalidOperationException($"vkAllocateDescriptorSets failed: {result}"); + _frameDescriptorSet = descriptorSet; + + var bufferInfo = new DescriptorBufferInfo + { + Buffer = _frameConstantsBuffer.Buffer, + Offset = 0, + Range = (ulong)sizeof(FrameConstants) + }; + + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = _frameDescriptorSet, + DstBinding = 0, + DstArrayElement = 0, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + PBufferInfo = &bufferInfo + }; + + _context.Vk.UpdateDescriptorSets(_context.Device, 1, &write, 0, null); + } + + private void CreateTextureDescriptorPool() + { + var poolSize = new DescriptorPoolSize + { + Type = DescriptorType.CombinedImageSampler, + DescriptorCount = 16 + }; + + var createInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = 16, + PoolSizeCount = 1, + PPoolSizes = &poolSize + }; + + DescriptorPool descriptorPool; + var result = _context.Vk.CreateDescriptorPool(_context.Device, &createInfo, null, &descriptorPool); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateDescriptorPool (texture) failed: {result}"); + _textureDescriptorPool = descriptorPool; + } + + private void CreateDefaultTexture() + { + var whitePixel = new byte[] { 255, 255, 255, 255 }; + _defaultTexture = CreateTextureFromBytes("__default__", whitePixel, 1, 1); + } + + private Texture CreateTextureFromBytes(string key, byte[] rgbaPixels, uint width, uint height) + { + var path = $"/tmp/cortex_texture_{key}.png"; + System.IO.File.WriteAllBytes(path, EncodePng(rgbaPixels, width, height)); + var texture = new Texture(_context, path); + try + { + System.IO.File.Delete(path); + } + catch + { + // Ignore cleanup failure. + } + return texture; + } + + private static byte[] EncodePng(byte[] rgbaPixels, uint width, uint height) + { + using var image = SixLabors.ImageSharp.Image.LoadPixelData(rgbaPixels, (int)width, (int)height); + using var stream = new System.IO.MemoryStream(); + image.SaveAsPng(stream); + return stream.ToArray(); + } + public void RequestScreenshot(string outputPath) => _screenshot.Request(outputPath); public bool IsScreenshotRequested => _screenshot.IsRequested; @@ -184,6 +332,8 @@ public sealed unsafe class MeshRenderer : IDisposable _context.Vk.CmdBeginRenderPass(cmd, &renderPassInfo, SubpassContents.Inline); _context.Vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, _pipeline.Handle); + var frameDescriptorSet = _frameDescriptorSet; + _context.Vk.CmdBindDescriptorSets(cmd, PipelineBindPoint.Graphics, _pipeline.Layout, 0, 1, &frameDescriptorSet, 0, null); var viewport = new Viewport(0, 0, _swapchain.Extent.Width, _swapchain.Extent.Height, 0, 1); var scissor = new Rect2D(new Offset2D(0, 0), _swapchain.Extent); @@ -195,6 +345,14 @@ public sealed unsafe class MeshRenderer : IDisposable var proj = camera.GetProjectionMatrix(); var drawCmd = cmd; + var frameConstants = BuildFrameConstants(world, camera); + var frameConstantsBytes = new byte[sizeof(FrameConstants)]; + fixed (byte* p = frameConstantsBytes) + { + *(FrameConstants*)p = frameConstants; + } + _frameConstantsBuffer.Update(frameConstantsBytes); + world.Each((Entity e, ref Mesh mesh, ref Transform transform) => { if (!_buffers.TryGetValue(e, out var buffers)) @@ -208,14 +366,20 @@ public sealed unsafe class MeshRenderer : IDisposable buffers.VertexBuffer.Update(bytes); var mvp = Matrix4x4.Transpose(Matrix4x4.Multiply(view, proj)); + var texture = GetTexture(material); + var textureDescriptorSet = GetTextureDescriptorSet(texture); + var textureSet = textureDescriptorSet; + _context.Vk.CmdBindDescriptorSets(drawCmd, PipelineBindPoint.Graphics, _pipeline.Layout, 1, 1, &textureSet, 0, null); var push = new PushConstants { Mvp = mvp, - LightDirection = new Vector3(0.5f, -1.0f, -0.5f), - LightColor = new Vector3(1.0f, 0.95f, 0.8f), - AmbientColor = new Vector3(0.15f, 0.15f, 0.2f), - CameraPosition = camera.Position + MaterialAlbedo = material.Albedo, + MaterialRoughness = material.Roughness, + MaterialMetallic = material.Metallic, + UseTexture = material.HasTexture ? 1u : 0u, + TextureIndex = 0, + Pad0 = 0 }; var pushSize = (uint)sizeof(PushConstants); @@ -335,6 +499,117 @@ public sealed unsafe class MeshRenderer : IDisposable return bytes; } + private Texture GetTexture(Material material) + { + if (!material.HasTexture) + return _defaultTexture!; + + if (_textures.TryGetValue(material.TexturePath!, out var texture)) + return texture; + + if (!System.IO.File.Exists(material.TexturePath!)) + return _defaultTexture!; + + texture = new Texture(_context, material.TexturePath!); + _textures[material.TexturePath!] = texture; + return texture; + } + + private DescriptorSet GetTextureDescriptorSet(Texture texture) + { + if (_textureDescriptorSets.TryGetValue(texture, out var descriptorSet)) + return descriptorSet; + + var layout = _pipeline.TextureDescriptorSetLayout; + var allocInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _textureDescriptorPool, + DescriptorSetCount = 1, + PSetLayouts = &layout + }; + + DescriptorSet set; + var result = _context.Vk.AllocateDescriptorSets(_context.Device, &allocInfo, &set); + if (result != Result.Success) + throw new InvalidOperationException($"vkAllocateDescriptorSets (texture) failed: {result}"); + + var imageInfo = new DescriptorImageInfo + { + ImageLayout = ImageLayout.ShaderReadOnlyOptimal, + ImageView = texture.View, + Sampler = texture.Sampler + }; + + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = 0, + DstArrayElement = 0, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = 1, + PImageInfo = &imageInfo + }; + + _context.Vk.UpdateDescriptorSets(_context.Device, 1, &write, 0, null); + _textureDescriptorSets[texture] = set; + return set; + } + + private FrameConstants BuildFrameConstants(World world, Camera camera) + { + var frameConstants = new FrameConstants + { + CameraPosition = camera.Position, + LightCount = 0, + AmbientColor = new Vector3(0.4f, 0.4f, 0.45f), + AmbientPadding = 0 + }; + + world.Each((Entity e, ref Light light) => + { + if (frameConstants.LightCount >= 4) + return; + + var index = (int)frameConstants.LightCount; + frameConstants.LightCount++; + SetLight(ref frameConstants, index, new GpuLight + { + Direction = light.Direction, + Intensity = light.Intensity, + Color = light.Color, + Padding = 0 + }); + }); + + // Fallback: if no light components exist, add a default directional light. + if (frameConstants.LightCount == 0) + { + frameConstants.LightCount = 1; + SetLight(ref frameConstants, 0, new GpuLight + { + Direction = new Vector3(0.5f, -1.0f, -0.5f), + Intensity = 1.0f, + Color = new Vector3(1.0f, 0.95f, 0.8f), + Padding = 0 + }); + } + + return frameConstants; + } + + private static void SetLight(ref FrameConstants frameConstants, int index, GpuLight light) + { + switch (index) + { + case 0: frameConstants.Light0 = light; break; + case 1: frameConstants.Light1 = light; break; + case 2: frameConstants.Light2 = light; break; + case 3: frameConstants.Light3 = light; break; + } + } + private Camera GetCamera(World world) { var camera = new Camera( @@ -374,6 +649,16 @@ public sealed unsafe class MeshRenderer : IDisposable } _context.Vk.DestroyCommandPool(_context.Device, _commandPool, null); + _context.Vk.DestroyDescriptorPool(_context.Device, _textureDescriptorPool, null); + _context.Vk.DestroyDescriptorPool(_context.Device, _frameDescriptorPool, null); + + foreach (var texture in _textures.Values) + texture.Dispose(); + _textures.Clear(); + + _defaultTexture?.Dispose(); + + _frameConstantsBuffer.Dispose(); _pipeline.Dispose(); } diff --git a/src/Engine.Graphics/Shaders/fragment.frag b/src/Engine.Graphics/Shaders/fragment.frag index 8c80d96..1372e2e 100644 --- a/src/Engine.Graphics/Shaders/fragment.frag +++ b/src/Engine.Graphics/Shaders/fragment.frag @@ -3,36 +3,67 @@ layout(location = 0) in vec3 fragColor; layout(location = 1) in vec3 fragNormal; layout(location = 2) in vec3 fragWorldPos; +layout(location = 3) in vec2 fragUv; layout(location = 0) out vec4 outColor; +struct Light +{ + vec3 direction; + float intensity; + vec3 color; + float _pad; +}; + +layout(set = 0, binding = 0) uniform FrameConstants +{ + vec3 cameraPosition; + uint lightCount; + vec3 ambientColor; + float _pad; + Light lights[4]; +} frame; + +layout(set = 1, binding = 0) uniform sampler2D albedoTexture; + layout(push_constant) uniform PushConstants { mat4 mvp; - vec3 lightDirection; - float pad1; - vec3 lightColor; - float pad2; - vec3 ambientColor; - float pad3; - vec3 cameraPosition; - float pad4; + vec3 materialAlbedo; + float materialRoughness; + float materialMetallic; + uint useTexture; + uint textureIndex; + uint _pad0; + uint _pad1; } push; void main() { vec3 normal = normalize(fragNormal); - vec3 lightDir = normalize(-push.lightDirection); - vec3 viewDir = normalize(push.cameraPosition - fragWorldPos); - vec3 halfDir = normalize(lightDir + viewDir); + vec3 viewDir = normalize(frame.cameraPosition - fragWorldPos); + vec3 albedo = fragColor * push.materialAlbedo; + if (push.useTexture != 0u) + { + albedo *= texture(albedoTexture, fragUv).rgb; + } + float roughness = clamp(push.materialRoughness, 0.05, 1.0); + float metallic = clamp(push.materialMetallic, 0.0, 1.0); - float diff = max(dot(normal, lightDir), 0.0); - float spec = pow(max(dot(normal, halfDir), 0.0), 64.0) * 0.5; + vec3 result = frame.ambientColor * albedo; - vec3 diffuse = push.lightColor * diff; - vec3 specular = push.lightColor * spec; - vec3 ambient = push.ambientColor; + for (uint i = 0u; i < frame.lightCount; i++) + { + vec3 lightDir = normalize(-frame.lights[i].direction); + vec3 halfDir = normalize(lightDir + viewDir); + float diff = max(dot(normal, lightDir), 0.0); + float spec = pow(max(dot(normal, halfDir), 0.0), mix(8.0, 128.0, 1.0 - roughness)) * mix(0.5, 1.0, metallic); + + vec3 diffuse = frame.lights[i].color * diff * frame.lights[i].intensity; + vec3 specular = frame.lights[i].color * spec * frame.lights[i].intensity; + + result += diffuse * albedo + specular; + } - vec3 result = (ambient + diffuse + specular) * fragColor; outColor = vec4(result, 1.0); } diff --git a/src/Engine.Graphics/Shaders/fragment.spv b/src/Engine.Graphics/Shaders/fragment.spv index fc4e2b2..f24c63f 100644 Binary files a/src/Engine.Graphics/Shaders/fragment.spv and b/src/Engine.Graphics/Shaders/fragment.spv differ diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics/Shaders/vertex.spv index bb76553..dd808e8 100644 Binary files a/src/Engine.Graphics/Shaders/vertex.spv and b/src/Engine.Graphics/Shaders/vertex.spv differ diff --git a/src/Engine.Graphics/Shaders/vertex.vert b/src/Engine.Graphics/Shaders/vertex.vert index ecb695d..7344767 100644 --- a/src/Engine.Graphics/Shaders/vertex.vert +++ b/src/Engine.Graphics/Shaders/vertex.vert @@ -7,18 +7,35 @@ layout(location = 2) in vec3 inNormal; layout(location = 0) out vec3 fragColor; layout(location = 1) out vec3 fragNormal; layout(location = 2) out vec3 fragWorldPos; +layout(location = 3) out vec2 fragUv; + +struct Light +{ + vec3 direction; + float intensity; + vec3 color; + float _pad; +}; + +layout(set = 0, binding = 0) uniform FrameConstants +{ + vec3 cameraPosition; + uint lightCount; + vec3 ambientColor; + float _pad; + Light lights[4]; +} frame; layout(push_constant) uniform PushConstants { mat4 mvp; - vec3 lightDirection; - float pad1; - vec3 lightColor; - float pad2; - vec3 ambientColor; - float pad3; - vec3 cameraPosition; - float pad4; + vec3 materialAlbedo; + float materialRoughness; + float materialMetallic; + uint useTexture; + uint textureIndex; + uint _pad0; + uint _pad1; } push; void main() @@ -27,4 +44,5 @@ void main() fragColor = inColor; fragNormal = inNormal; fragWorldPos = inPosition; + fragUv = inPosition.xz * 0.5 + 0.5; } diff --git a/src/Engine.Graphics/Texture.cs b/src/Engine.Graphics/Texture.cs new file mode 100644 index 0000000..b8e6cbb --- /dev/null +++ b/src/Engine.Graphics/Texture.cs @@ -0,0 +1,330 @@ +using System; +using System.Runtime.InteropServices; +using Silk.NET.Vulkan; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Engine.Graphics; + +/// +/// A Vulkan texture: image, device memory, image view, and sampler. +/// +public sealed unsafe class Texture : IDisposable +{ + private readonly VulkanContext _context; + public Silk.NET.Vulkan.Image Image { get; } + public DeviceMemory Memory { get; } + public ImageView View { get; } + public Sampler Sampler { get; } + public uint Width { get; } + public uint Height { get; } + + public Texture(VulkanContext context, string path) + { + _context = context; + + using var image = SixLabors.ImageSharp.Image.Load(path); + Width = (uint)image.Width; + Height = (uint)image.Height; + + var pixels = new byte[Width * Height * 4]; + image.CopyPixelDataTo(pixels); + + Image = CreateImage(Width, Height); + var memoryRequirements = GetImageMemoryRequirements(Image); + Memory = AllocateMemory(memoryRequirements, MemoryPropertyFlags.DeviceLocalBit); + + var bindResult = _context.Vk.BindImageMemory(_context.Device, Image, Memory, 0); + if (bindResult != Result.Success) + throw new InvalidOperationException($"vkBindImageMemory failed: {bindResult}"); + + UploadPixels(pixels); + + View = CreateImageView(Image); + Sampler = CreateSampler(); + } + + private Silk.NET.Vulkan.Image CreateImage(uint width, uint height) + { + var createInfo = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Extent = new Extent3D(width, height, 1), + MipLevels = 1, + ArrayLayers = 1, + Format = Format.R8G8B8A8Srgb, + Tiling = ImageTiling.Optimal, + InitialLayout = ImageLayout.Undefined, + Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit, + SharingMode = SharingMode.Exclusive, + Samples = SampleCountFlags.Count1Bit + }; + + Silk.NET.Vulkan.Image image; + var result = _context.Vk.CreateImage(_context.Device, &createInfo, null, &image); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateImage failed: {result}"); + return image; + } + + private MemoryRequirements GetImageMemoryRequirements(Silk.NET.Vulkan.Image image) + { + MemoryRequirements requirements; + _context.Vk.GetImageMemoryRequirements(_context.Device, image, &requirements); + return requirements; + } + + private DeviceMemory AllocateMemory(MemoryRequirements requirements, MemoryPropertyFlags properties) + { + var memoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, properties); + var allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = memoryTypeIndex + }; + + DeviceMemory memory; + var result = _context.Vk.AllocateMemory(_context.Device, &allocateInfo, 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 for texture."); + } + + private void UploadPixels(byte[] pixels) + { + var imageSize = (ulong)pixels.Length; + + var stagingBuffer = CreateBuffer(imageSize, BufferUsageFlags.TransferSrcBit); + var stagingMemory = AllocateStagingMemory(stagingBuffer); + + var bindResult = _context.Vk.BindBufferMemory(_context.Device, stagingBuffer, stagingMemory, 0); + if (bindResult != Result.Success) + throw new InvalidOperationException($"vkBindBufferMemory for staging failed: {bindResult}"); + + void* mappedData; + var mapResult = _context.Vk.MapMemory(_context.Device, stagingMemory, 0, imageSize, MemoryMapFlags.None, &mappedData); + if (mapResult != Result.Success) + throw new InvalidOperationException($"vkMapMemory failed: {mapResult}"); + + fixed (byte* src = pixels) + { + global::System.Buffer.MemoryCopy(src, mappedData, (long)imageSize, pixels.Length); + } + + _context.Vk.UnmapMemory(_context.Device, stagingMemory); + + ExecuteOneTimeCommand(cmd => + { + TransitionImageLayout(cmd, Image, ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + + var bufferCopy = 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(Width, Height, 1) + }; + + _context.Vk.CmdCopyBufferToImage(cmd, stagingBuffer, Image, ImageLayout.TransferDstOptimal, 1, &bufferCopy); + + TransitionImageLayout(cmd, Image, ImageLayout.TransferDstOptimal, ImageLayout.ShaderReadOnlyOptimal); + }); + + _context.Vk.FreeMemory(_context.Device, stagingMemory, null); + _context.Vk.DestroyBuffer(_context.Device, stagingBuffer, null); + } + + 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 DeviceMemory AllocateStagingMemory(Silk.NET.Vulkan.Buffer buffer) + { + var requirements = GetBufferMemoryRequirements(buffer); + return AllocateMemory(requirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + } + + private MemoryRequirements GetBufferMemoryRequirements(Silk.NET.Vulkan.Buffer buffer) + { + MemoryRequirements requirements; + _context.Vk.GetBufferMemoryRequirements(_context.Device, buffer, &requirements); + return requirements; + } + + private void TransitionImageLayout(CommandBuffer cmd, Silk.NET.Vulkan.Image image, ImageLayout oldLayout, ImageLayout newLayout) + { + var barrier = new ImageMemoryBarrier + { + SType = StructureType.ImageMemoryBarrier, + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = uint.MaxValue, + DstQueueFamilyIndex = uint.MaxValue, + Image = image, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1 + } + }; + + var srcStage = PipelineStageFlags.TopOfPipeBit; + var dstStage = PipelineStageFlags.TransferBit; + AccessFlags srcAccessMask = 0; + AccessFlags dstAccessMask = AccessFlags.TransferWriteBit; + + if (oldLayout == ImageLayout.TransferDstOptimal && newLayout == ImageLayout.ShaderReadOnlyOptimal) + { + srcStage = PipelineStageFlags.TransferBit; + dstStage = PipelineStageFlags.FragmentShaderBit; + srcAccessMask = AccessFlags.TransferWriteBit; + dstAccessMask = AccessFlags.ShaderReadBit; + } + + barrier.SrcAccessMask = srcAccessMask; + barrier.DstAccessMask = dstAccessMask; + + _context.Vk.CmdPipelineBarrier(cmd, srcStage, dstStage, 0, 0, null, 0, null, 1, &barrier); + } + + private void ExecuteOneTimeCommand(Action action) + { + var allocInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = _context.CommandPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1 + }; + + CommandBuffer commandBuffer; + var result = _context.Vk.AllocateCommandBuffers(_context.Device, &allocInfo, &commandBuffer); + if (result != Result.Success) + throw new InvalidOperationException($"vkAllocateCommandBuffers failed: {result}"); + + var beginInfo = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit + }; + _context.Vk.BeginCommandBuffer(commandBuffer, &beginInfo); + + action(commandBuffer); + + _context.Vk.EndCommandBuffer(commandBuffer); + + var submitInfo = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer + }; + + _context.Vk.QueueSubmit(_context.GraphicsQueue, 1, &submitInfo, new Fence()); + _context.Vk.QueueWaitIdle(_context.GraphicsQueue); + + _context.Vk.FreeCommandBuffers(_context.Device, _context.CommandPool, 1, &commandBuffer); + } + + private ImageView CreateImageView(Silk.NET.Vulkan.Image image) + { + var createInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type2D, + Format = Format.R8G8B8A8Srgb, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1 + } + }; + + ImageView view; + var result = _context.Vk.CreateImageView(_context.Device, &createInfo, null, &view); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateImageView failed: {result}"); + return view; + } + + private Sampler CreateSampler() + { + var createInfo = new SamplerCreateInfo + { + SType = StructureType.SamplerCreateInfo, + MagFilter = Filter.Linear, + MinFilter = Filter.Linear, + AddressModeU = SamplerAddressMode.Repeat, + AddressModeV = SamplerAddressMode.Repeat, + AddressModeW = SamplerAddressMode.Repeat, + AnisotropyEnable = false, + BorderColor = BorderColor.IntOpaqueBlack, + UnnormalizedCoordinates = false, + CompareEnable = false, + MipmapMode = SamplerMipmapMode.Linear, + MipLodBias = 0.0f, + MinLod = 0.0f, + MaxLod = 1.0f + }; + + Sampler sampler; + var result = _context.Vk.CreateSampler(_context.Device, &createInfo, null, &sampler); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateSampler failed: {result}"); + return sampler; + } + + public void Dispose() + { + _context.Vk.DeviceWaitIdle(_context.Device); + _context.Vk.DestroySampler(_context.Device, Sampler, null); + _context.Vk.DestroyImageView(_context.Device, View, null); + _context.Vk.DestroyImage(_context.Device, Image, null); + _context.Vk.FreeMemory(_context.Device, Memory, null); + } +} diff --git a/src/Engine.Graphics/UniformBuffer.cs b/src/Engine.Graphics/UniformBuffer.cs new file mode 100644 index 0000000..8b99512 --- /dev/null +++ b/src/Engine.Graphics/UniformBuffer.cs @@ -0,0 +1,110 @@ +using System; +using Silk.NET.Vulkan; + +namespace Engine.Graphics; + +/// +/// A host-visible, coherent Vulkan buffer for uniform data that is updated every frame. +/// +public sealed unsafe class UniformBuffer : IDisposable +{ + private readonly VulkanContext _context; + public Silk.NET.Vulkan.Buffer Buffer { get; } + public DeviceMemory Memory { get; } + public ulong Size { get; } + + public UniformBuffer(VulkanContext context, ulong size) + { + _context = context; + Size = size; + + Buffer = CreateBuffer(Size, BufferUsageFlags.UniformBufferBit); + var memoryRequirements = GetMemoryRequirements(Buffer); + Memory = AllocateMemory(memoryRequirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + var result = _context.Vk.BindBufferMemory(_context.Device, Buffer, Memory, 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 allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = memoryTypeIndex + }; + + DeviceMemory memory; + var result = _context.Vk.AllocateMemory(_context.Device, &allocateInfo, 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 for uniform buffer."); + } + + public void Update(ReadOnlySpan data) + { + if ((ulong)data.Length != Size) + throw new ArgumentException($"Uniform buffer update size mismatch: {data.Length} != {Size}"); + + void* mappedData; + var result = _context.Vk.MapMemory(_context.Device, Memory, 0, Size, MemoryMapFlags.None, &mappedData); + if (result != Result.Success) + throw new InvalidOperationException($"vkMapMemory failed: {result}"); + + fixed (byte* src = data) + { + global::System.Buffer.MemoryCopy(src, mappedData, (long)Size, data.Length); + } + + _context.Vk.UnmapMemory(_context.Device, Memory); + } + + public void Dispose() + { + _context.Vk.DeviceWaitIdle(_context.Device); + _context.Vk.DestroyBuffer(_context.Device, Buffer, null); + _context.Vk.FreeMemory(_context.Device, Memory, null); + } +} diff --git a/src/Engine.Graphics/VulkanContext.cs b/src/Engine.Graphics/VulkanContext.cs index cced80f..8d06450 100644 --- a/src/Engine.Graphics/VulkanContext.cs +++ b/src/Engine.Graphics/VulkanContext.cs @@ -31,6 +31,7 @@ public sealed unsafe class VulkanContext : IDisposable public SurfaceKHR Surface { get; private set; } public uint GraphicsFamilyIndex { get; private set; } public uint PresentFamilyIndex { get; private set; } + public CommandPool CommandPool { get; private set; } public VulkanContext(Sdl3Window window, bool enableValidation = true) { @@ -42,6 +43,23 @@ public sealed unsafe class VulkanContext : IDisposable CreateLogicalDevice(); LoadDeviceExtensions(); GetQueues(); + CreateCommandPool(); + } + + private void CreateCommandPool() + { + var createInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = GraphicsFamilyIndex, + Flags = CommandPoolCreateFlags.ResetCommandBufferBit + }; + + CommandPool commandPool; + var result = Vk.CreateCommandPool(Device, &createInfo, null, &commandPool); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateCommandPool failed: {result}"); + CommandPool = commandPool; } private void CreateInstance(Sdl3Window window, bool enableValidation) @@ -290,6 +308,8 @@ public sealed unsafe class VulkanContext : IDisposable _disposed = true; Vk.DeviceWaitIdle(Device); + if (CommandPool.Handle != 0) + Vk.DestroyCommandPool(Device, CommandPool, null); if (Device.Handle != 0) Vk.DestroyDevice(Device, null); if (Surface.Handle != 0) diff --git a/src/Engine.Graphics/VulkanPipeline.cs b/src/Engine.Graphics/VulkanPipeline.cs index 3249777..1d15b06 100644 --- a/src/Engine.Graphics/VulkanPipeline.cs +++ b/src/Engine.Graphics/VulkanPipeline.cs @@ -15,6 +15,8 @@ public sealed unsafe class VulkanPipeline : IDisposable public Pipeline Handle { get; } public PipelineLayout Layout { get; } + public DescriptorSetLayout FrameDescriptorSetLayout { get; } + public DescriptorSetLayout TextureDescriptorSetLayout { get; } private readonly ShaderModule _vertexModule; private readonly ShaderModule _fragmentModule; @@ -26,6 +28,8 @@ public sealed unsafe class VulkanPipeline : IDisposable _vertexModule = CreateShaderModule("vertex.spv"); _fragmentModule = CreateShaderModule("fragment.spv"); + FrameDescriptorSetLayout = CreateFrameDescriptorSetLayout(); + TextureDescriptorSetLayout = CreateTextureDescriptorSetLayout(); Layout = CreatePipelineLayout(); Handle = CreateGraphicsPipeline(); } @@ -53,19 +57,69 @@ public sealed unsafe class VulkanPipeline : IDisposable } } + private DescriptorSetLayout CreateFrameDescriptorSetLayout() + { + var binding = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit + }; + + var createInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 1, + PBindings = &binding + }; + + DescriptorSetLayout layout; + var result = _context.Vk.CreateDescriptorSetLayout(_context.Device, &createInfo, null, &layout); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateDescriptorSetLayout failed: {result}"); + return layout; + } + + private DescriptorSetLayout CreateTextureDescriptorSetLayout() + { + var binding = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.FragmentBit + }; + + var createInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 1, + PBindings = &binding + }; + + DescriptorSetLayout layout; + var result = _context.Vk.CreateDescriptorSetLayout(_context.Device, &createInfo, null, &layout); + if (result != Result.Success) + throw new InvalidOperationException($"vkCreateDescriptorSetLayout (texture) failed: {result}"); + return layout; + } + private PipelineLayout CreatePipelineLayout() { var pushConstantRange = new PushConstantRange { StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, Offset = 0, - Size = (uint)(32 * sizeof(float)) + Size = 96 }; + var setLayouts = stackalloc DescriptorSetLayout[] { FrameDescriptorSetLayout, TextureDescriptorSetLayout }; var createInfo = new PipelineLayoutCreateInfo { SType = StructureType.PipelineLayoutCreateInfo, - SetLayoutCount = 0, + SetLayoutCount = 2, + PSetLayouts = setLayouts, PushConstantRangeCount = 1, PPushConstantRanges = &pushConstantRange }; @@ -244,6 +298,8 @@ public sealed unsafe class VulkanPipeline : IDisposable _context.Vk.DeviceWaitIdle(_context.Device); _context.Vk.DestroyPipeline(_context.Device, Handle, null); _context.Vk.DestroyPipelineLayout(_context.Device, Layout, null); + _context.Vk.DestroyDescriptorSetLayout(_context.Device, FrameDescriptorSetLayout, null); + _context.Vk.DestroyDescriptorSetLayout(_context.Device, TextureDescriptorSetLayout, null); _context.Vk.DestroyShaderModule(_context.Device, _vertexModule, null); _context.Vk.DestroyShaderModule(_context.Device, _fragmentModule, null); }