From fb6e26a268b9301ae686ab729936be9abd145e6f Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Wed, 17 Jun 2026 13:49:12 +0300 Subject: [PATCH] feat: modular HAL, Raylib backend, PBR shading, textures, 60 unit tests - Replace hardcoded SDL3 windowing with IWindow/IInputState/Key abstractions - Each render backend owns its window (Raylib GLFW, SDL3 for Vulkan) - Raylib backend: DrawModelEx, custom GLSL shader with Fresnel, ACES tonemapping, gamma correction, hemisphere ambient - Fix backface culling, mesh memory (NativeMemory.Alloc), texture loading - Camera controllers use backend-agnostic Key enum (inverted yaw/strafe) - Demo scene: 8 cubes, 7 spheres, torus knot OBJ with checker texture - Extract ProceduralMesh + MeshMath from Program.cs to Engine.Graphics - Vulkan backend deferred (compiles, untested, IWindow-compatible) - 60 unit tests: ObjLoader, camera controllers, AiCommandProcessor, RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform - AGENTS.md for opencode integration --- AGENTS.md | 66 + CORTEX_ENGINE.sln | 22 + CORTEX_ENGINE_ARCHITECTURE.md | 262 +- Content/checker.png | Bin 0 -> 3200 bytes Content/torusknot.obj | 19201 ++++++++++++++++ scripts/run.sh | 16 + src/CortexEngine.App/CortexEngine.App.csproj | 2 + src/CortexEngine.App/Program.cs | 375 +- src/Engine.AI/AiCommandQueue.cs | 54 +- src/Engine.AI/Mcp/EngineMcpTools.cs | 2 +- src/Engine.AI/Stdio/McpStdioServer.cs | 2 +- src/Engine.Core/Components/Light.cs | 4 +- src/Engine.Core/FreeFlyCameraController.cs | 21 +- src/Engine.Core/ICameraController.cs | 4 +- src/Engine.Core/IInputState.cs | 25 + src/Engine.Core/IScreenshotProvider.cs | 15 + src/Engine.Core/IWindow.cs | 41 + src/Engine.Core/InputMapping.cs | 118 +- src/Engine.Core/Key.cs | 39 + src/Engine.Core/OrbitCameraController.cs | 95 +- src/Engine.Core/Sdl3Window.cs | 38 +- .../Engine.Graphics.Raylib.csproj | 33 + .../RaylibBackendRegistrar.cs | 20 + .../RaylibInputState.cs | 156 + .../RaylibRenderContext.cs | 28 + src/Engine.Graphics.Raylib/RaylibRenderer.cs | 503 + src/Engine.Graphics.Raylib/RaylibWindow.cs | 54 + .../Engine.Graphics.Vulkan.csproj | 40 + .../IndexBuffer.cs | 0 .../ScreenshotCapture.cs | 119 +- .../ShaderLoader.cs | 2 +- .../Shaders/fragment.frag | 0 .../Shaders/fragment.spv | Bin .../Shaders/vertex.spv | Bin .../Shaders/vertex.vert | 0 .../Swapchain.cs | 0 .../Texture.cs | 0 .../UniformBuffer.cs | 0 .../VertexBuffer.cs | 0 .../VulkanBackendRegistrar.cs | 20 + .../VulkanContext.cs | 8 +- .../VulkanPipeline.cs | 0 .../VulkanRenderContext.cs | 35 + .../VulkanRenderer.cs} | 22 +- src/Engine.Graphics/Engine.Graphics.csproj | 7 - src/Engine.Graphics/IRenderContext.cs | 27 + src/Engine.Graphics/IRenderer.cs | 31 + src/Engine.Graphics/Loaders/GltfLoader.cs | 11 +- src/Engine.Graphics/Loaders/ObjLoader.cs | 11 +- src/Engine.Graphics/MeshMath.cs | 25 + src/Engine.Graphics/ProceduralMesh.cs | 101 + src/Engine.Graphics/RenderBackendFactory.cs | 36 + tests/Engine.Tests/AiCommandProcessorTests.cs | 173 + tests/Engine.Tests/CameraControllerTests.cs | 235 + tests/Engine.Tests/CameraTests.cs | 65 + tests/Engine.Tests/Engine.Tests.csproj | 27 + tests/Engine.Tests/MeshAndLightTests.cs | 40 + .../MeshMathAndProceduralTests.cs | 119 + tests/Engine.Tests/ObjLoaderTests.cs | 146 + .../Engine.Tests/RenderBackendFactoryTests.cs | 41 + tests/Engine.Tests/TimingTests.cs | 65 + tests/Engine.Tests/TransformTests.cs | 52 + 62 files changed, 22259 insertions(+), 395 deletions(-) create mode 100644 AGENTS.md create mode 100644 Content/checker.png create mode 100644 Content/torusknot.obj create mode 100755 scripts/run.sh create mode 100644 src/Engine.Core/IInputState.cs create mode 100644 src/Engine.Core/IScreenshotProvider.cs create mode 100644 src/Engine.Core/IWindow.cs create mode 100644 src/Engine.Core/Key.cs create mode 100644 src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj create mode 100644 src/Engine.Graphics.Raylib/RaylibBackendRegistrar.cs create mode 100644 src/Engine.Graphics.Raylib/RaylibInputState.cs create mode 100644 src/Engine.Graphics.Raylib/RaylibRenderContext.cs create mode 100644 src/Engine.Graphics.Raylib/RaylibRenderer.cs create mode 100644 src/Engine.Graphics.Raylib/RaylibWindow.cs create mode 100644 src/Engine.Graphics.Vulkan/Engine.Graphics.Vulkan.csproj rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/IndexBuffer.cs (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/ScreenshotCapture.cs (76%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/ShaderLoader.cs (89%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Shaders/fragment.frag (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Shaders/fragment.spv (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Shaders/vertex.spv (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Shaders/vertex.vert (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Swapchain.cs (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/Texture.cs (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/UniformBuffer.cs (100%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/VertexBuffer.cs (100%) create mode 100644 src/Engine.Graphics.Vulkan/VulkanBackendRegistrar.cs rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/VulkanContext.cs (98%) rename src/{Engine.Graphics => Engine.Graphics.Vulkan}/VulkanPipeline.cs (100%) create mode 100644 src/Engine.Graphics.Vulkan/VulkanRenderContext.cs rename src/{Engine.Graphics/MeshRenderer.cs => Engine.Graphics.Vulkan/VulkanRenderer.cs} (97%) create mode 100644 src/Engine.Graphics/IRenderContext.cs create mode 100644 src/Engine.Graphics/IRenderer.cs create mode 100644 src/Engine.Graphics/MeshMath.cs create mode 100644 src/Engine.Graphics/ProceduralMesh.cs create mode 100644 src/Engine.Graphics/RenderBackendFactory.cs create mode 100644 tests/Engine.Tests/AiCommandProcessorTests.cs create mode 100644 tests/Engine.Tests/CameraControllerTests.cs create mode 100644 tests/Engine.Tests/CameraTests.cs create mode 100644 tests/Engine.Tests/Engine.Tests.csproj create mode 100644 tests/Engine.Tests/MeshAndLightTests.cs create mode 100644 tests/Engine.Tests/MeshMathAndProceduralTests.cs create mode 100644 tests/Engine.Tests/ObjLoaderTests.cs create mode 100644 tests/Engine.Tests/RenderBackendFactoryTests.cs create mode 100644 tests/Engine.Tests/TimingTests.cs create mode 100644 tests/Engine.Tests/TransformTests.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79dfeb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# AGENTS.md — Cortex Engine + +## Project Overview + +Cortex Engine is a C# (.NET 9) AI-Native 3D game engine. The primary render backend is Raylib-cs (OpenGL). A Vulkan backend exists but is deferred. + +## Build Commands + +```bash +# Build (Debug) +dotnet build CORTEX_ENGINE.sln -c Debug + +# Build (Release) +dotnet build CORTEX_ENGINE.sln -c Release + +# Run the engine +./scripts/run.sh + +# Run with test scene + camera tour (headless screenshot capture) +dotnet run --project src/CortexEngine.App/CortexEngine.App.csproj -c Release -- --test-scene --camera-tour --mcp-port 0 + +# Run with MCP server +./scripts/run.sh --mcp-port 5000 +``` + +## Lint / Typecheck + +No separate lint command. `dotnet build` with 0 warnings is the standard. Run `dotnet build CORTEX_ENGINE.sln -c Release` to verify. + +## Architecture + +- **Engine.Core** — `IWindow`, `IInputState`, `Key` enum, `Sdl3Window`, camera controllers, ECS components (`Transform`, `Mesh`, `Material`, `Light`, `Camera`), `Timing` +- **Engine.Graphics** — HAL interfaces (`IRenderContext`, `IRenderer`), `RenderBackendFactory`, mesh loaders (`ObjLoader`, `GltfLoader`) +- **Engine.Graphics.Raylib** — Primary backend. `RaylibWindow` (GLFW), `RaylibInputState`, `RaylibRenderer` with custom GLSL 330 shader (Fresnel, ACES, gamma) +- **Engine.Graphics.Vulkan** — Deferred backend. Compiles but untested. Uses `Sdl3Window` for Vulkan surface. +- **Engine.AI** — `AiCommandProcessor` (7 commands), MCP HTTP + stdio servers +- **CortexEngine.App** — Entry point, main loop, scene setup + +## Key Conventions + +- Each render backend owns its window (`IWindow`). The app gets the window from `IRenderContext.Window`. +- Input is backend-agnostic via `IInputState` + `Key` enum. No SDL3 types in app code. +- Camera controllers use `IInputState`, not `InputMapping` directly. +- `RenderBackendFactory.Create(name, width, height, validation)` — backends register by name. +- Custom mesh CPU data uses `NativeMemory.Alloc` (not `Marshal.AllocHGlobal`) to match Raylib's `RL_FREE`. +- `SetShaderValue` uses `float[]` for vectors, not `Vector3`/`Vector4` (marshaling reliability). +- Backface culling disabled (`Rlgl.DisableBackfaceCulling`) for mixed-winding meshes. + +## Current Roadmap + +See `CORTEX_ENGINE_ARCHITECTURE.md` §11 for the full roadmap. Short-term priorities: +- Unit tests +- Texture loading verification +- ImGui integration (medium-term) + +## Files Not to Edit + +- `CORTEX_ENGINE_ARCHITECTURE.md` — canonical architecture reference, update only when architecture changes +- `src/Engine.Graphics.Vulkan/Shaders/*.spv` — compiled SPIR-V, regenerate from `.vert`/`.frag` with glslangValidator + +## Environment + +- .NET 9 SDK at `$HOME/.dotnet` +- `DOTNET_ROOT` and `PATH` must include `$HOME/.dotnet` +- Raylib-cs 8.0.0 (Raylib 6.0 native library bundled in NuGet) +- Display required (X11/Wayland) for Raylib window diff --git a/CORTEX_ENGINE.sln b/CORTEX_ENGINE.sln index b28c102..60959f6 100644 --- a/CORTEX_ENGINE.sln +++ b/CORTEX_ENGINE.sln @@ -10,6 +10,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CortexEngine.App", "src\Cor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.AI", "src\Engine.AI\Engine.AI.csproj", "{44444444-4444-4444-4444-444444444444}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics.Vulkan", "src\Engine.Graphics.Vulkan\Engine.Graphics.Vulkan.csproj", "{43C6A648-4F0C-4440-95E3-733BD8D29BCE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics.Raylib", "src\Engine.Graphics.Raylib\Engine.Graphics.Raylib.csproj", "{31447693-7B61-4B22-95BB-47FF08C1CB2A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Tests", "tests\Engine.Tests\Engine.Tests.csproj", "{55555555-5555-5555-5555-555555555555}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,5 +47,21 @@ Global {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU {44444444-4444-4444-4444-444444444444}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU {44444444-4444-4444-4444-444444444444}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.Release|Any CPU.Build.0 = Release|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU + {43C6A648-4F0C-4440-95E3-733BD8D29BCE}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.Release|Any CPU.Build.0 = Release|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU + {31447693-7B61-4B22-95BB-47FF08C1CB2A}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + {55555555-5555-5555-5555-555555555555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55555555-5555-5555-5555-555555555555}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55555555-5555-5555-5555-555555555555}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55555555-5555-5555-5555-555555555555}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index 4332b45..abe613e 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -15,7 +15,7 @@ Cortex Engine is a 3D game engine built from scratch to provide a Unity-like dev - **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 Hexa.NET.ImGui with a native Vulkan backend. +The architecture prioritizes **production maturity** over experimental technologies: a Render HAL with a Raylib-cs default backend and an optional Vulkan (Silk.NET.Vulkan) backend, Flecs.NET (C# bindings for the C-based Flecs ECS), SDL3-cs (ppy.SDL3-CS), and Hexa.NET.ImGui with a native backend. --- @@ -58,8 +58,10 @@ The final stack was chosen to eliminate experimental dependencies and maximize p - **C# (.NET 9) with dual-runtime strategy**: JIT for development (Roslyn hot-reload), NativeAOT for release. - **SDL3-cs**: `ppy.SDL3-CS` — direct, zero-overhead P/Invoke bindings maintained by the osu! team. -- **Vulkan**: `Vortice.Vulkan` — mature C# Vulkan bindings, .NET 9/10 support. -- **MoltenVK**: For macOS/iOS compatibility. +- **Render HAL**: `Engine.Graphics` abstraction with pluggable backends. +- **Raylib-cs**: `Raylib-cs` 8.0.0 — default, simple OpenGL-based backend for rapid iteration and screenshot capture. +- **Vulkan**: `Silk.NET.Vulkan` 2.21.0 — optional high-performance backend retained as a reference implementation. +- **MoltenVK**: For macOS/iOS compatibility when using the Vulkan backend. - **Flecs.NET**: `Flecs.NET.Release` — C# bindings for Flecs with NativeAOT static-link support. - **ImGui**: `Hexa.NET.ImGui` — ships pre-built SDL3 + Vulkan native backends. - **Jolt Physics**: `JoltPhysicsSharp` — C# bindings for Jolt Physics, .NET 9/10. @@ -112,25 +114,46 @@ All Roslyn and `AssemblyLoadContext` code is wrapped in `#if DEV_MODE`. ### 3.3 Graphics HAL -**Vulkan via `Silk.NET.Vulkan`** +The graphics layer is split into a backend-agnostic **Render HAL** (`Engine.Graphics`) and concrete backend implementations. -- NuGet: `Silk.NET.Vulkan` 2.21.0 -- .NET 9/10 low-level bindings -- Mature, used by Silk.NET ecosystem -- MoltenVK provides macOS/iOS support +**Core abstraction (`Engine.Graphics`)** -**Note:** Initial prototype used Vortice.Vulkan, but its loader segfaulted on the Kubuntu development setup. Silk.NET.Vulkan is the verified working binding. +- `IRenderContext` — backend lifetime, resize, and surface handling. +- `IRenderer` — renders the ECS world and exposes screenshot capture. +- `RenderBackendFactory` — a registry/factory pattern; backend assemblies register themselves. +- The app depends only on these interfaces. -**Why Vulkan over WebGPU:** +**Default backend: Raylib-cs** -- Battle-tested in production engines -- Full compute shader support (mandatory for AI vision pipelines) -- Mature C# tooling and ImGui integration -- MoltenVK provides macOS/iOS support +- NuGet: `Raylib-cs` 8.0.0 +- Simple, mature OpenGL-based renderer +- Handles window creation, mesh upload, 3D camera, and PNG screenshots internally +- Owns its GLFW window and input via `RaylibWindow` + `RaylibInputState` (no SDL3 dependency) + +**Optional backend: Vulkan via `Silk.NET.Vulkan` — DEFERRED** + +- NuGet: `Silk.NET.Vulkan` 2.21.0 and `Silk.NET.Vulkan.Extensions.KHR` 2.21.0 +- The Vulkan backend compiles and implements the same `IRenderContext` / `IRenderer` HAL interfaces +- Uses `Sdl3Window` internally for Vulkan surface creation (`SDL_Vulkan_CreateSurface`) +- **Status: deferred to long-term backlog.** The backend is kept compilable and architecturally + integrated (via `IWindow`, `IRenderContext`), but is not actively tested or maintained. + The Raylib backend is the primary render path for all current development. +- **Reintegration checklist** (when picked up): + 1. Test `VulkanRenderContext` with the new `IWindow`-based factory signature + 2. Verify `SDL_Vulkan_CreateSurface` works through `IWindow.Handle` + 3. Port improved shading (Fresnel, ACES, gamma, hemisphere ambient) to Vulkan GLSL shaders + 4. Verify custom mesh upload (spheres, grids) works via Vulkan vertex/index buffers + 5. Test screenshot capture via `ScreenshotCapture` with the new frame-deferral logic + +**Why a HAL + Raylib default?** + +- Drastically reduces the code the app, AI commands, and camera tools depend on +- Raylib-cs provides a fast, stable path for screenshots, 3D drawing, and windowing without custom shader/pipeline work +- Vulkan remains available as a high-performance, compute-capable backend for future vision pipelines **macOS/iOS path:** -- MoltenVK 1.4 supports Vulkan 1.4 on macOS, iOS, tvOS, visionOS +- When using the Vulkan backend: MoltenVK 1.4 supports Vulkan 1.4 on macOS, iOS, tvOS, visionOS - `VK_KHR_portability_subset` and `VK_KHR_portability_enumeration` must be enabled - Loader and MoltenVK libraries must be bundled with the application - KosmicKrisp (via Mesa 3D) is an emerging alternative for Apple Silicon desktops @@ -304,15 +327,17 @@ When the AI generates a C# script, the engine: ### 4.5 Rendering & Shading -The renderer uses a simple forward-lit pipeline: +The renderer uses a simple forward-lit pipeline that is implemented by each backend behind the HAL: - **Vertex format**: position, color, normal. - **Per-entity**: Mesh + Transform + optional Material. -- **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. +- **Per-frame constants**: camera position, up to 4 directional lights, ambient color. +- **Per-entity 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. +- **Vulkan backend**: uses a uniform buffer (descriptor set 0) and push constants; textures are Vulkan images with a combined image sampler (descriptor set 1). +- **Raylib backend**: uses a custom GLSL shader with `materialColor`, `useTexture`, `roughness`, `metallic`, and light arrays. Textures are loaded via `Raylib.LoadTexture` and UVs use world-space XZ. +- **UV mapping**: meshes use world-space XZ as a simple UV mapping for both backends. ### 4.6 SystemSlotRegistry @@ -474,7 +499,7 @@ Available tools: - `delete_entity` — delete an entity by name. - `list_entities` — list all named entities with a `Transform`. - `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). +- `capture_screenshot` — capture the current frame, save it as PNG on disk, and return a JSON envelope `{ "path": "...", "base64": "..." }` with the base64-encoded PNG (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. @@ -529,9 +554,9 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ ├── Sdl3Window.cs # SDL3 window wrapper │ │ ├── Timing.cs # DeltaTime, fixed timestep │ │ ├── InputMapping.cs # Keyboard, mouse, gamepad input -│ │ ├── ICameraController.cs # Camera controller interface -│ │ ├── OrbitCameraController.cs # Mouse orbit camera -│ │ ├── FreeFlyCameraController.cs # WASD + mouse look camera + │ │ ├── ICameraController.cs # Camera controller interface + │ │ ├── FreeFlyCameraController.cs # WASD + mouse look camera + │ │ ├── IScreenshotProvider.cs # Async screenshot capture interface │ │ └── Components/ # Transform, Camera, Light, Material, Mesh │ │ │ ├── Engine.Data/ @@ -541,16 +566,30 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ └── SystemSlotRegistry.cs # Named system hot-swap registry │ │ │ ├── Engine.Graphics/ +│ │ ├── IRenderContext.cs # Backend context abstraction +│ │ ├── IRenderer.cs # ECS world renderer abstraction +│ │ ├── RenderBackendFactory.cs # Backend registry and factory +│ │ └── Loaders/ # ObjLoader, GltfLoader +│ │ +│ ├── Engine.Graphics.Raylib/ +│ │ ├── RaylibBackendRegistrar.cs # Registers the Raylib backend with the factory +│ │ ├── RaylibRenderContext.cs # Raylib window/surface context +│ │ └── RaylibRenderer.cs # Raylib ECS mesh renderer + screenshot capture +│ │ +│ ├── Engine.Graphics.Vulkan/ +│ │ ├── VulkanBackendRegistrar.cs # Registers the Vulkan backend with the factory +│ │ ├── VulkanRenderContext.cs # Vulkan instance, device, surface, swapchain +│ │ ├── VulkanRenderer.cs # Vulkan ECS mesh renderer │ │ ├── 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 + descriptor layouts +│ │ ├── ScreenshotCapture.cs # Vulkan readback → PNG │ │ ├── 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 +│ │ ├── ShaderLoader.cs # Embedded SPIR-V loader +│ │ └── Shaders/ # vertex.vert, fragment.frag, *.spv │ │ │ ├── Engine.Diagnostics/ │ │ ├── DiagnosticsManager.cs # Orchestrator @@ -596,60 +635,27 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can --- -## 8. FOUNDATIONAL MVP — 3 INITIAL CODE STEPS +## 8. FOUNDATIONAL MVP — COMPLETED -### Step 1: Engine.Core — Window + Vulkan Context + Clear Screen +### Step 1: Window + Render HAL + Raylib Backend — DONE -**Goal**: A visible window with a functioning Vulkan device and a frame loop that clears the screen to a solid color. +- `IWindow` / `IInputState` / `Key` abstractions in `Engine.Core` +- `Sdl3Window` (SDL3) and `RaylibWindow` (GLFW) both implement `IWindow` +- `RenderBackendFactory` — backends register by name, each owns its window +- `RaylibRenderer` — custom GLSL shader, PBR-like lighting, screenshots +- Vulkan backend compiles but is **deferred** (see §3.3) -**Deliverables**: +### Step 2: Flecs World + Components + Camera Controllers — DONE -- `EngineApp.cs` — `Init`, `Update`, `Render`, `Shutdown` loop -- `Sdl3Window.cs` — `ppy.SDL3-CS` wrapper (create window, poll events, resize) -- `VulkanContext.cs` — Vortice.Vulkan instance, physical device, logical device, queues -- `Swapchain.cs` — swapchain creation and recreation -- First frame: `vkCmdClearColorImage` → present +- `World` (Flecs.NET) with `Transform`, `Mesh`, `Material`, `Light`, `Camera` components +- `FreeFlyCameraController` and `OrbitCameraController` using `IInputState` + `Key` enum +- Procedural mesh generation: `CreateGridMesh`, `CreateSphereMesh` -**Dependencies**: +### Step 3: AI Bridge + MCP Server — DONE -- `ppy.SDL3-CS` -- `Vortice.Vulkan` -- `Vortice.VulkanMemoryAllocator` (optional but recommended) - -### Step 2: Engine.Data — Flecs World + GameObject + SystemSlotRegistry - -**Goal**: A working ECS world with Unity-like access patterns and a hot-swap registry skeleton. - -**Deliverables**: - -- `GameObject.cs` — readonly struct facade -- `ComponentTypes.cs` — `Transform`, `MeshRef`, `Camera`, `SemanticClass` -- `WorldContext.cs` — Flecs world initialization -- `SystemSlotRegistry.cs` — named system registration and hot-swap -- Test: create 1000 entities, add `Transform`, iterate, print FPS - -**Dependencies**: - -- `Flecs.NET.Release` - -### Step 3: Engine.Diagnostics — DiagnosticsManager + Flecs JSON Export - -**Goal**: The MMLM context loop skeleton — captures world state as JSON plus a placeholder visual capture. - -**Deliverables**: - -- `DiagnosticsManager.cs` — `CapturePayload()` orchestrator -- `FlecsJsonExporter.cs` — `ecs_world_to_json()` wrapper -- `Payload.cs` — unified diagnostic payload structure -- `SystemGraphSvg.cs` — SVG dependency graph generator -- `LogBuffer.cs` — circular console log buffer -- Visual capture stub (placeholder JPEG until Step 1's Vulkan readback is wired) -- Console test: `CapturePayload()` → print JSON + SVG to stdout - -**Dependencies**: - -- `Flecs.NET.Release` -- `SixLabors.ImageSharp` +- `AiCommandProcessor` — 7 commands: spawn_model, set_transform, set_material, delete_entity, list_entities, capture_screenshot, get_world_state +- HTTP MCP server (SSE, `--mcp-port`) and stdio MCP server (`--mcp-stdio`) +- Screenshot capture with 10-frame warm-up for stable GPU output --- @@ -686,7 +692,44 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can --- -## 11. PROMPT ENGINEERING FOR AI CODING +## 11. CURRENT ROADMAP (Post-MVP) + +### Completed + +- [x] Modular window/input HAL (`IWindow`, `IInputState`, `Key` enum) +- [x] Raylib backend as primary render path (GLFW window, no SDL3 dependency) +- [x] PBR-like shading: Fresnel (Schlick), hemisphere ambient, ACES tonemapping, gamma correction +- [x] Procedural mesh generation (spheres, grids) with correct memory management +- [x] FreeFly + Orbit camera controllers with inverted-yaw and strafe fixes +- [x] MCP server (HTTP + stdio) with 7 AI commands +- [x] Demo scene with cubes + spheres showcasing different materials + +### Short-term (next) + +- [x] Texture loading in RaylibRenderer (`SetMaterialUniforms` now loads/binds textures) +- [x] Fix `demo.png` screenshot timing (moved to main loop with frame warm-up) +- [ ] Unit tests (`tests/Engine.Tests/` — planned but never created) +- [x] `AGENTS.md` — created for opencode integration + +### Medium-term + +- [ ] Dear ImGui integration (Hexa.NET.ImGui) for editor UI +- [ ] Model loading from GLTF/OBJ with textures and materials +- [ ] Scene serialization / deserialization +- [ ] Multi-light shadow mapping + +### Long-term (backlog) + +- [ ] **Vulkan backend reintegration** — see §3.3 checklist. Compiles but untested. + Kept architecturally compatible via `IWindow` / `IRenderContext` / `IRenderer`. + Deferred because Raylib covers all current needs with far less complexity. +- [ ] Physics (JoltPhysicsSharp) +- [ ] AI hot-reload of C# scripts (Roslyn — conflicts with NativeAOT) +- [ ] Semantic segmentation maps for MMLM vision input + +--- + +## 12. PROMPT ENGINEERING FOR AI CODING When generating code with an MMLM for this engine, always include this context header: @@ -716,7 +759,7 @@ Current file context: [insert path here] --- -## 12. NEXT DECISION POINTS +## 13. NEXT DECISION POINTS 1. Add ImGui editor UI (`Hexa.NET.ImGui`) for scene hierarchy and inspector. 2. Add physics integration (`JoltPhysicsSharp`) with rigid bodies and colliders. @@ -726,47 +769,80 @@ Current file context: [insert path here] --- -## 13. RUNTIME NOTES & CRITICAL CONTEXT +## 14. RUNTIME NOTES & CRITICAL CONTEXT -### 13.1 Building & Running +### 14.1 Building & Running ```bash export DOTNET_ROOT="$HOME/.dotnet" export PATH="$DOTNET_ROOT:$PATH" export DISPLAY=:0 dotnet build CORTEX_ENGINE.sln -c Debug + +# Convenience script (handles DOTNET_ROOT/PATH/DISPLAY automatically): +./scripts/run.sh + +# Or run directly: 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 +### 14.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. +- `--camera-tour` — capture screenshots from predefined poses and exit. +- `--test-scene` — enable a calibration scene with colored cubes at known world positions and run a camera tour. Useful for visually verifying perspective and camera movement. - Any other positional argument is treated as a model path (`.obj`, `.gltf`, `.glb`). -### 13.3 Vulkan & Shader Pipeline +### 14.3 Convenience Scripts -- 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). +| Script | Purpose | +|--------|---------| +| `./scripts/run.sh` | Run the engine; passes all arguments to the app (e.g., `./scripts/run.sh --mcp-port 5000`). | +| `./scripts/start_mcp_engine.sh ` | Run the engine with MCP enabled on the given port (default 5000). | + +### 14.4 Graphics Backends + +**Default backend: Raylib-cs** + +- The app calls `RenderBackendFactory.Create("raylib", width, height, enableValidation: false)`. +- `RaylibRenderContext` creates a `RaylibWindow` (GLFW) and `RaylibRenderer` handles the frame. +- `RaylibRenderer` uploads `Mesh` data to GPU via `LoadModelFromMesh`, sets a custom GLSL 330 core + shader with Fresnel, ACES tonemapping, gamma correction, hemisphere ambient, and up to 4 + directional lights. Renders the ECS world via `DrawModelEx`. +- Backface culling is disabled (`Rlgl.DisableBackfaceCulling`) for compatibility with mixed-winding meshes. +- Screenshots are captured via `Raylib.LoadImageFromScreen` with a 10-frame warm-up delay. +- Custom mesh CPU data is allocated via `NativeMemory.Alloc` (matching Raylib's `RL_FREE` allocator) + and kept alive until `UnloadModel` — freeing early caused broken large meshes (spheres, grids). + +**Vulkan backend (DEFERRED — not actively tested)** + +- Compiles and registers via `VulkanBackendRegistrar`, but is not the active render path. +- Uses `Sdl3Window` internally for `SDL_Vulkan_CreateSurface`. +- 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). - 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 + /tmp/glslang/bin/glslangValidator -V src/Engine.Graphics.Vulkan/Shaders/vertex.vert -o src/Engine.Graphics.Vulkan/Shaders/vertex.spv + /tmp/glslang/bin/glslangValidator -V src/Engine.Graphics.Vulkan/Shaders/fragment.frag -o src/Engine.Graphics.Vulkan/Shaders/fragment.spv ``` +- See §3.3 for the reintegration checklist. -### 13.4 SDL3 Input +### 14.5 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** (по умолчанию): правый клик + движение мыши — вращать, колесо — zoom. -- **FreeFly camera** (переключается клавишей `F`): `WASD` — двигаться, `Q`/`E` — вниз/вверх, `Shift` — ускорение, правый клик + мышь — осмотр. -- `ESC` — выход. +- Input is backend-agnostic via `IInputState` + `Key` enum (defined in `Engine.Core`). +- **Raylib backend**: `RaylibInputState` polls Raylib's input functions directly (no SDL3). +- **Vulkan backend** (deferred): `Sdl3Window` + `InputMapping` polls SDL3 events. +- **FreeFly camera** (default): `WASD` — move, `Q`/`E` — down/up, `Shift` — boost, right-click + mouse — look. +- **Orbit camera** (toggle with `F`): right-click + mouse — orbit target `(0, 0.5, 0)`, wheel — zoom, `WASD`/`Q`/`E`/`Shift` — move target. +- `ESC` — exit. +- Default camera: `(0, 0.75, -30)`, target `(0, 0.5, 0)`, FOV 15° (vertical), near 0.1, far 100. -### 13.5 MCP Client Config +### 14.6 MCP Client Config Sample Claude Desktop config (`claude_desktop_config.json`): @@ -793,7 +869,7 @@ Sample Claude Desktop config (`claude_desktop_config.json`): For the HTTP MCP server, use the `--mcp-port` argument and connect an SSE MCP client. -### 13.6 Process Cleanup +### 14.7 Process Cleanup Background `dotnet run` processes may leave the apphost running. Kill them with: diff --git a/Content/checker.png b/Content/checker.png new file mode 100644 index 0000000000000000000000000000000000000000..93c88c4c9154df922a642d2be9ce1ffaaf3ab2cf GIT binary patch literal 3200 zcmds4dr(tX8b3D|i6{^YMFk~+D#brUqJi*Etf;J|3(~PbEsa4fC@+Il5zyqa^4b-o zZe0P(YnOJwL3v04QA2L6HLW}X(`vv#$Zjf7BZ)C2JZ^68-q6l;I8R+8ryehmQoh&?-B+jpvZW-RRZ zy13OyOy=v6{HN}L57|xol=%mjm8=ikQ6C>7xSIaul_cuv`d=)d+*!o69q zP+wk*tXYk13X>_;2+95 z+*v0%EySsGi7*7)1!bUWx#$3qkNHY!^v9>{P~|5QP4O3?L~(028-MnE?PdowrdcFs zR6rinO%EiRa^J7e@1Xb_i*=%l_TZ(JafG2f*avR3QI$3m3)1^Q@cE!s__WJ@b2@dP=I<{Gx619#~krA#N<(!pTqO^EU-THshaR)-Cm94uyvsy zg7pWBW+eLi8$)2GVz4Hij=VcLYFT${QtuA7f71t58Zz?+uYj%r)4W9guYFMf#Q56q zC#f@4$#A7XFH$e0S$WYglh|M~J5&D1;#!V{Rt?#G?>y3nPbxq{pT?M4Q}w-NzEu7obtb5hGQytnRDE%!np8V!%kPrTj7~x$?{$TvEjhdpsJ|m? zs>(*S%HXH^+aA+x%H>4EAQML0la8Po*1RL|ND+a193;<7ZIO?JqsnH+`;@hvh3O*I zqp}p#Q+`IIN=`qi$17y#UJb5%KpQI^*djD9>b!ijOVqvajecGip0CKNYCvP+f)*oH z-O9l(uAo_Y6Osxoe{V&)#KRhoX2y-2W3$22pMF~Hgp{$8&?nS42P^Zv8vpV&+}6&7 zW4Rx6GU2`hn|vJ5dnWqCIyjP{BD&`0m20c#-YGEH@IS&G1(#P64Ul97Whr~LbULT3 zHu1uP6#v|UT@N+J>6cb#64K*@^ri{Z*??7EWb`P%M{;6LEtL(kHectpMwGXGnbPSN zcIOo>5l=3;koW~TXf6Jq%g^7o?AHytQ8){j{lzqih_K8}O1MJvp{Gnija@-XsqKOQIJ;n&uj)NFs;yc<27IT1rtS z<$q{R)2-s-=!WF@!{piY#0$pUFtUS;5$)t^!7U*W*l7p0Uj)BLGLFydvu5qjUHwn+ zK=i`H{(nrmUmSEX7xU(DEXGCJ*aVSdI!75pFih4;plWqDi>D2RMyzP4Z7s;mtFW{% zc=WTMK!GVn-J*IMuyOBP%*ORzu7P1-2@U-f@;LaMC&3({q(ZHl*HL}7TSAPSn-FRqxr6FI8!P$eZK8M!@U zo~>scgQ3#gjoB}Qmhr-FB1S&WK-%AlhY^i`3dT5f!a5;SqCwIrP?uJnq(&=yuS?(;8h(?21$yZjsdu>FH|8qBoFO{K}l{9$l_$kf?Uw9Q92R8tG-X7 zohOB$-&`MNNF!w=0;(IZ@{WQ@(cN0nj{2jMQliY;Irzt-!JUxQ9I?(aAfHUK{UuW>M^O-$pRf=88S8v-O}MsIXPf>WXG?q36X6C#g&av z>l@dnT!!pF$}VyR=l#QBUfpNeWQWIKrU@ETa|Pmibc%_G*E^$4K4};%&)5e$_GmGf zo7!=X!65eg4S#l3s7gvBF%mu%$A73eW>4T-2?#V6qN zxH`fh + + diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index 6520380..8138c21 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -3,10 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Numerics; using Engine.AI; -using SDL; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; #if !RELEASE_AOT using Engine.AI.Mcp; using Microsoft.AspNetCore.Builder; @@ -15,6 +11,8 @@ using Engine.Core; using Engine.Core.Components; using Engine.Graphics; using Engine.Graphics.Loaders; +using Engine.Graphics.RaylibBackend; +using Engine.Graphics.Vulkan; using Flecs.NET.Core; namespace CortexEngine.App; @@ -23,7 +21,7 @@ class Program { static async Task Main(string[] args) { - Console.WriteLine("Cortex Engine — Materials, Grid, Lighting, Orbit Camera..."); + Console.WriteLine("Cortex Engine — Materials, Grid, Lighting, FreeFly Camera..."); try { @@ -33,27 +31,34 @@ class Program return; } + var cameraTour = args.Contains("--camera-tour"); + var testScene = args.Contains("--test-scene"); + if (testScene) + cameraTour = true; + using var world = World.Create(); - using var window = new Sdl3Window("Cortex Engine", 1280, 720); var timing = new Timing(); - var input = new InputMapping(); - using var vulkan = new VulkanContext(window, enableValidation: false); - using var swapchain = new Swapchain(vulkan); - using var renderer = new MeshRenderer(vulkan, swapchain); + + RaylibBackendRegistrar.EnsureRegistered(); + VulkanBackendRegistrar.EnsureRegistered(); + using var renderContext = RenderBackendFactory.Create("raylib", 1280, 720, enableValidation: false); + var window = renderContext.Window; + var input = window.Input; + using var renderer = renderContext.CreateRenderer(); 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 queue = new AiCommandQueue(processor, renderer.ScreenshotProvider); var cameraEntity = world.Entity("Camera") - .Set(new Transform(new Vector3(0.0f, 2.5f, -4.0f), Quaternion.Identity, Vector3.One)) + .Set(new Transform(new Vector3(0.0f, 0.75f, -30.0f), Quaternion.Identity, Vector3.One)) .Set(new Camera( - new Vector3(0.0f, 2.5f, -4.0f), + new Vector3(0.0f, 0.75f, -30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY, - MathF.PI / 4.0f, + MathF.PI / 12.0f, 1280.0f / 720.0f, 0.1f, 100.0f)); @@ -75,42 +80,23 @@ class Program .Set(new Light(new Vector3(0.0f, 1.0f, 0.0f), new Vector3(0.15f, 0.15f, 0.2f), 0.3f)); ICameraController[] cameraControllers = - [ - new OrbitCameraController(cameraEntity, new Vector3(0.0f, 0.5f, 0.0f)), - new FreeFlyCameraController(cameraEntity) - ]; + { + new FreeFlyCameraController(cameraEntity), + new OrbitCameraController(cameraEntity, new Vector3(0.0f, 0.5f, 0.0f)) + }; var activeControllerIndex = 0; + var cameraController = cameraControllers[activeControllerIndex]; + Console.WriteLine($"Active camera controller: {cameraController.Name} (press F to toggle)"); - 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))) - .Set(mesh) - .Set(new Material(new Vector3(0.9f, 0.6f, 0.3f), roughness: 0.4f, metallic: 0.1f)); - - 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.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)) - .Set(CreateGridMesh(20, 0.5f, new Vector3(0.5f, 0.5f, 0.55f))) - .Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f)); - - // Demo: local AI commands processed on the main thread. - Console.WriteLine("AI demo commands:"); - Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); - 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": "set_transform", "name": "SecondCube", "position": [0.8, 0.5, 0], "rotation": [0, 0, 0, 1], "scale": [0.3, 0.3, 0.3] }""").Message); - - var secondCube = world.Lookup("SecondCube"); - if ((ulong)secondCube.Id != 0) - 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 (testScene) + { + Console.WriteLine("Calibration test scene enabled."); + CreateCalibrationScene(world, mesh); + } + else + { + CreateDemoScene(world, mesh); + } #if !RELEASE_AOT WebApplication? mcpApp = null; @@ -144,11 +130,46 @@ class Program var lastFpsTime = 0.0; var lastWidth = window.Width; var lastHeight = window.Height; + var demoScreenshotRequested = false; + + var tourPoses = testScene + ? new CameraPose[] + { + new("test_front", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_back", new Vector3(0.0f, 0.75f, 30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_left", new Vector3(-30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_right", new Vector3(30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_top", new Vector3(0.0f, 30.0f, 0.0f), new Vector3(0.0f, 0.0f, 0.0f), -Vector3.UnitZ), + new("test_shifted", new Vector3(15.0f, 0.75f, -22.5f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_rotated", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(2.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_yaw_15", new Vector3(7.76f, 0.75f, -28.98f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_yaw_30", new Vector3(15.0f, 0.75f, -25.98f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_yaw_45", new Vector3(21.21f, 0.75f, -21.21f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_yaw_90", new Vector3(30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_pitch_45", new Vector3(0.0f, 21.96f, -21.21f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_close", new Vector3(0.0f, 0.75f, -15.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_far", new Vector3(0.0f, 0.75f, -60.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_farther", new Vector3(0.0f, 0.75f, -120.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("test_toward", new Vector3(0.0f, 0.75f, -20.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY) + } + : new CameraPose[] + { + new("front", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("top", new Vector3(0.0f, 30.0f, 0.0f), new Vector3(0.0f, 0.0f, 0.0f), -Vector3.UnitZ), + new("side", new Vector3(30.0f, 0.75f, 4.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("close", new Vector3(1.0f, 0.75f, -5.0f), new Vector3(0.5f, 0.5f, 0.0f), Vector3.UnitY), + new("low", new Vector3(0.0f, 0.25f, -6.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY), + new("back", new Vector3(0.0f, 0.75f, 30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY) + }; + var tourIndex = -1; + var tourSettleFrames = 0; + var tourScreenshotPending = false; + var tourDone = false; while (!window.ShouldClose) { timing.Tick(); - window.PumpEvents(input); + window.PumpEvents(); input.BeginFrame(); // Drain any commands that arrived from the MCP server. @@ -160,27 +181,85 @@ class Program { lastWidth = window.Width; lastHeight = window.Height; - swapchain.Recreate(lastWidth, lastHeight); + renderContext.Resize(lastWidth, lastHeight); ref var camera = ref cameraEntity.Ensure(); camera.AspectRatio = (float)lastWidth / lastHeight; } - // Toggle camera controller with F. - if (input.IsKeyPressed(SDL_Keycode.SDLK_F)) + // Toggle camera controller on F key press. + if (input.IsKeyPressed(Key.F)) { activeControllerIndex = (activeControllerIndex + 1) % cameraControllers.Length; - Console.WriteLine($"Camera controller: {cameraControllers[activeControllerIndex].Name}"); + cameraController = cameraControllers[activeControllerIndex]; + Console.WriteLine($"Active camera controller: {cameraController.Name}"); } - // Update active camera controller from input. - cameraControllers[activeControllerIndex].Update(input, (float)timing.DeltaTime); + // Update the active camera controller from input, unless the camera tour is driving the pose. + if (!cameraTour) + cameraController.Update(input, (float)timing.DeltaTime); - // Slowly rotate the model so we can see it in 3D. - ref var modelTransform = ref model.Ensure(); - modelTransform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f) - * Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)timing.TotalTime * 0.25f); + if (cameraTour && !tourDone) + { + if (tourIndex < 0) + { + tourIndex = 0; + SetCameraPose(cameraEntity, tourPoses[tourIndex]); + tourSettleFrames = 0; + tourScreenshotPending = true; + } + + // Hold the pose for a few frames to let the GPU settle, then screenshot. + if (tourScreenshotPending) + { + tourSettleFrames++; + if (tourSettleFrames >= 5) + { + var path = $"Screenshots/tour_{tourPoses[tourIndex].Name}.png"; + renderer.RequestScreenshot(path); + Console.WriteLine($"Tour screenshot: {path}"); + tourScreenshotPending = false; + } + } + + // After the screenshot has been saved, advance to the next pose. + if (!tourScreenshotPending && !renderer.IsScreenshotRequested) + { + tourIndex++; + if (tourIndex >= tourPoses.Length) + { + tourDone = true; + Console.WriteLine("Camera tour complete."); + window.Close(); + } + else + { + SetCameraPose(cameraEntity, tourPoses[tourIndex]); + tourSettleFrames = 0; + tourScreenshotPending = true; + } + } + } + + // Slowly rotate the model so we can see it in 3D, unless the camera tour or test scene is active. + if (!cameraTour && !testScene) + { + var modelEntity = world.Lookup("CubeCenter"); + if (modelEntity.Id != 0) + { + ref var modelTransform = ref modelEntity.Ensure(); + modelTransform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f); + } + } + + // Capture a demo screenshot after the scene warms up (non-tour mode only). + if (!demoScreenshotRequested && !cameraTour && frames >= 15) + { + renderer.RequestScreenshot("Screenshots/demo.png"); + demoScreenshotRequested = true; + } renderer.RenderWorld(world); + queue.CompletePendingScreenshots(); frames++; if (timing.TotalTime - lastFpsTime >= 1.0) @@ -206,6 +285,98 @@ class Program } } + private static void CreateDemoScene(World world, Mesh mesh) + { + var sphere = ProceduralMesh.CreateSphere(0.5f, 32, 16, new Vector3(0.8f, 0.8f, 0.8f)); + var torusKnot = ObjLoader.Load("Content/torusknot.obj", new Vector3(0.8f, 0.8f, 0.8f)); + + var cubes = new (string name, Vector3 pos, Vector3 color, float scale, float rough, float metal)[] + { + ("CubeCenter", new Vector3(0, 0.5f, 0), new Vector3(0.9f, 0.6f, 0.3f), 0.5f, 0.3f, 0.1f), + ("CubeRed", new Vector3(2, 0.5f, 0), new Vector3(0.85f, 0.15f, 0.15f), 0.5f, 0.4f, 0.2f), + ("CubeGreen", new Vector3(-2, 0.5f, 0), new Vector3(0.2f, 0.8f, 0.3f), 0.5f, 0.5f, 0.0f), + ("CubeBlue", new Vector3(0, 0.5f, 3), new Vector3(0.2f, 0.4f, 0.9f), 0.6f, 0.2f, 0.3f), + ("CubeYellow", new Vector3(0, 0.5f, -3), new Vector3(0.95f, 0.85f, 0.2f), 0.5f, 0.6f, 0.0f), + ("CubeOrange", new Vector3(-3, 0.5f, 3), new Vector3(0.95f, 0.5f, 0.1f), 0.45f, 0.5f, 0.1f), + ("CubeWide", new Vector3(-1.5f, 0.5f, -1.5f), new Vector3(0.5f, 0.5f, 0.6f), 0.8f, 0.8f, 0.0f), + ("CubeSmallGold", new Vector3(5, 0.3f, -2), new Vector3(1.0f, 0.8f, 0.3f), 0.3f, 0.15f, 1.0f), + }; + + foreach (var (name, pos, color, scale, rough, metal) in cubes) + { + world.Entity(name) + .Set(new Transform(pos, Quaternion.Identity, new Vector3(scale))) + .Set(mesh) + .Set(new Material(color, roughness: rough, metallic: metal)); + } + + var spheres = new (string name, Vector3 pos, Vector3 color, float scale, float rough, float metal)[] + { + ("SphereGold", new Vector3(-5, 0.5f, -2), new Vector3(1.0f, 0.85f, 0.4f), 1.0f, 0.1f, 1.0f), + ("SphereChrome", new Vector3(-6, 0.5f, 0), new Vector3(0.9f, 0.9f, 0.95f), 1.0f, 0.05f, 1.0f), + ("SphereRed", new Vector3(-5, 0.5f, 2), new Vector3(0.9f, 0.1f, 0.1f), 1.0f, 0.4f, 0.0f), + ("SphereBlue", new Vector3(5, 0.5f, 2), new Vector3(0.1f, 0.3f, 0.9f), 1.0f, 0.2f, 0.5f), + ("SphereGreen", new Vector3(6, 0.5f, 0), new Vector3(0.1f, 0.8f, 0.3f), 1.0f, 0.7f, 0.0f), + ("SphereWhite", new Vector3(5, 0.5f, -4), new Vector3(0.95f, 0.95f, 0.95f), 1.0f, 0.3f, 0.0f), + ("SphereRough", new Vector3(3, 0.5f, 5), new Vector3(0.6f, 0.4f, 0.2f), 1.0f, 0.9f, 0.0f), + }; + + foreach (var (name, pos, color, scale, rough, metal) in spheres) + { + world.Entity(name) + .Set(new Transform(pos, Quaternion.Identity, new Vector3(scale))) + .Set(sphere) + .Set(new Material(color, roughness: rough, metallic: metal)); + } + + // Torus knot with checker texture + world.Entity("TorusKnot") + .Set(new Transform(new Vector3(0, 2.0f, 0), Quaternion.Identity, new Vector3(1.5f))) + .Set(torusKnot) + .Set(new Material(new Vector3(0.9f, 0.9f, 0.9f), roughness: 0.25f, metallic: 0.6f, texturePath: "Content/checker.png")); + + // Textured cube + world.Entity("CubeTextured") + .Set(new Transform(new Vector3(-4, 0.5f, -3), Quaternion.Identity, new Vector3(0.7f))) + .Set(mesh) + .Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.4f, metallic: 0.0f, texturePath: "Content/checker.png")); + + world.Entity("Grid") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f))) + .Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f)); + } + + private static void CreateCalibrationScene(World world, Mesh mesh) + { + // Colored cubes at known world positions for visual analysis of perspective and camera movement. + var positions = new (string name, Vector3 pos, Vector3 color)[] + { + ("CubeOrigin", new Vector3(0.0f, 0.5f, 0.0f), new Vector3(1.0f, 1.0f, 1.0f)), // white at origin + ("CubeRight", new Vector3(2.0f, 0.5f, 0.0f), new Vector3(1.0f, 0.0f, 0.0f)), // red +X + ("CubeLeft", new Vector3(-2.0f, 0.5f, 0.0f), new Vector3(0.0f, 1.0f, 0.0f)), // green -X + ("CubeFront", new Vector3(0.0f, 0.5f, 2.0f), new Vector3(0.0f, 0.0f, 1.0f)), // blue +Z + ("CubeBack", new Vector3(0.0f, 0.5f, -2.0f), new Vector3(1.0f, 1.0f, 0.0f)), // yellow -Z + ("CubeUp", new Vector3(0.0f, 2.5f, 0.0f), new Vector3(1.0f, 0.0f, 1.0f)), // magenta +Y + ("CubeFar", new Vector3(0.0f, 0.5f, 8.0f), new Vector3(0.0f, 1.0f, 1.0f)), // cyan far +Z + ("CubeFarLeft", new Vector3(-5.0f, 0.5f, 5.0f), new Vector3(0.5f, 0.5f, 1.0f)) // light blue far corner + }; + + foreach (var (name, pos, color) in positions) + { + world.Entity(name) + .Set(new Transform(pos, Quaternion.Identity, new Vector3(0.5f))) + .Set(mesh) + .Set(new Material(color, roughness: 0.5f, metallic: 0.1f)); + } + + // A large reference grid at Y=0. + world.Entity("Grid") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f))) + .Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f)); + } + private static Mesh LoadModel(string path) { return path.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase) @@ -214,57 +385,6 @@ class Program : ObjLoader.Load(path, new Vector3(0.7f, 0.6f, 0.5f)); } - private static Mesh CreateGridMesh(int lines, float spacing, Vector3 color) - { - var vertices = new List(); - var indices = new List(); - var extent = lines * spacing; - var normal = Vector3.UnitY; - var halfWidth = 0.02f; - - for (var i = -lines; i <= lines; i++) - { - var offset = i * spacing; - - // Line parallel to X axis as a thin quad. - var baseIndex = (uint)vertices.Count; - vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal)); - vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal)); - vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal)); - vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal)); - indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2); - indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3); - - // Line parallel to Z axis as a thin quad. - baseIndex = (uint)vertices.Count; - vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal)); - vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal)); - vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal)); - vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal)); - indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2); - indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3); - } - - return new Mesh(vertices.ToArray(), indices.ToArray()); - } - - private static Mesh CreateFloorMesh(float size, Vector3 color) - { - var half = size / 2.0f; - var normal = Vector3.UnitY; - - var vertices = new Vertex[] - { - new(new Vector3(-half, 0, -half), color, normal), - new(new Vector3(half, 0, -half), color, normal), - new(new Vector3(half, 0, half), color, normal), - new(new Vector3(-half, 0, half), color, normal) - }; - - var indices = new uint[] { 0, 1, 2, 0, 2, 3 }; - return new Mesh(vertices, indices); - } - private static (string modelPath, int mcpPort) ParseArgs(string[] args) { var modelPath = FindModelPath(args); @@ -314,28 +434,6 @@ 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..."); @@ -344,4 +442,17 @@ class Program var server = new Engine.AI.Stdio.McpStdioServer(processor); server.Run(); } + + private readonly record struct CameraPose(string Name, Vector3 Position, Vector3 Target, Vector3 Up, float Fov = MathF.PI / 12.0f); + + private static void SetCameraPose(Entity cameraEntity, CameraPose pose) + { + ref var camera = ref cameraEntity.Ensure(); + camera.Position = pose.Position; + camera.Target = pose.Target; + camera.Up = pose.Up; + camera.FieldOfView = pose.Fov; + cameraEntity.Set(camera); + Console.WriteLine($"Camera pose '{pose.Name}': pos={pose.Position}, target={pose.Target}, up={pose.Up}, fov={pose.Fov * 180f / MathF.PI:F0}°"); + } } diff --git a/src/Engine.AI/AiCommandQueue.cs b/src/Engine.AI/AiCommandQueue.cs index bbe54df..c3e4399 100644 --- a/src/Engine.AI/AiCommandQueue.cs +++ b/src/Engine.AI/AiCommandQueue.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text.Json; using Engine.AI.Commands; +using Engine.Core; namespace Engine.AI; @@ -12,11 +13,14 @@ public sealed class AiCommandQueue { private readonly ConcurrentQueue<(string commandJson, TaskCompletionSource tcs)> _queue = new(); private readonly AiCommandProcessor _processor; + private readonly IScreenshotProvider _screenshot; private readonly JsonSerializerOptions _jsonOptions; + private (TaskCompletionSource tcs, Task screenshotTask, string path)? _pendingScreenshot; - public AiCommandQueue(AiCommandProcessor processor) + public AiCommandQueue(AiCommandProcessor processor, IScreenshotProvider screenshot) { _processor = processor; + _screenshot = screenshot; _jsonOptions = processor.JsonOptions; } @@ -49,13 +53,57 @@ public sealed class AiCommandQueue int processed = 0; while (_queue.TryDequeue(out var item)) { - var result = _processor.Process(item.commandJson); - item.tcs.TrySetResult(result); + var command = JsonSerializer.Deserialize(item.commandJson, _jsonOptions); + if (command is CaptureScreenshotCommand screenshotCommand) + { + // Screenshot commands are handled asynchronously because the frame must be rendered + // before the PNG bytes are available. CompletePendingScreenshots must be called after + // the renderer has presented the frame. + if (_pendingScreenshot.HasValue) + { + item.tcs.TrySetResult(AiCommandResult.Error("Another screenshot request is already pending.")); + continue; + } + + var path = screenshotCommand.OutputPath ?? $"screenshot_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}.png"; + var screenshotTask = _screenshot.CaptureAsync(path); + _pendingScreenshot = (item.tcs, screenshotTask, path); + } + else + { + var result = _processor.Process(item.commandJson); + item.tcs.TrySetResult(result); + } processed++; } return processed; } + /// + /// Completes any pending screenshot requests that have finished rendering. + /// Must be called on the main engine thread after the frame has been presented. + /// + public void CompletePendingScreenshots() + { + if (_pendingScreenshot == null || !_pendingScreenshot.Value.screenshotTask.IsCompleted) + return; + + var (tcs, screenshotTask, path) = _pendingScreenshot.Value; + _pendingScreenshot = null; + + try + { + var bytes = screenshotTask.Result; + var base64 = Convert.ToBase64String(bytes); + var json = $"{{\"path\":{JsonSerializer.Serialize(path)},\"base64\":{JsonSerializer.Serialize(base64)}}}"; + tcs.TrySetResult(AiCommandResult.Ok(json)); + } + catch (Exception ex) + { + tcs.TrySetResult(AiCommandResult.Error($"Screenshot capture failed: {ex.Message}")); + } + } + /// /// Number of commands waiting to be processed. /// diff --git a/src/Engine.AI/Mcp/EngineMcpTools.cs b/src/Engine.AI/Mcp/EngineMcpTools.cs index 22a5d11..d979c34 100644 --- a/src/Engine.AI/Mcp/EngineMcpTools.cs +++ b/src/Engine.AI/Mcp/EngineMcpTools.cs @@ -70,7 +70,7 @@ public sealed class EngineMcpTools return EnqueueAndReturnMessage(cmd); } - [McpServerTool, Description("Capture a screenshot of the current rendered frame and save it to disk.")] + [McpServerTool, Description("Capture a screenshot of the current rendered frame and return it as a base64-encoded PNG. The image is also saved to disk.")] public Task CaptureScreenshot([Description("Optional output file path (default: screenshot_.png)")] string? outputPath = null) { var cmd = new CaptureScreenshotCommand { OutputPath = outputPath }; diff --git a/src/Engine.AI/Stdio/McpStdioServer.cs b/src/Engine.AI/Stdio/McpStdioServer.cs index 0fac0d1..7d408b7 100644 --- a/src/Engine.AI/Stdio/McpStdioServer.cs +++ b/src/Engine.AI/Stdio/McpStdioServer.cs @@ -55,7 +55,7 @@ public sealed class McpStdioServer "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.", + "Capture a screenshot of the current rendered frame and save it to disk. (No graphics context in stdio mode; returns requested path.)", new JsonSchemaBuilder() .AddOptionalString("outputPath") .Build()), diff --git a/src/Engine.Core/Components/Light.cs b/src/Engine.Core/Components/Light.cs index 0f4336e..8555bc4 100644 --- a/src/Engine.Core/Components/Light.cs +++ b/src/Engine.Core/Components/Light.cs @@ -13,7 +13,9 @@ public record struct Light public Light(Vector3 direction, Vector3 color, float intensity = 1.0f) { - Direction = Vector3.Normalize(direction); + Direction = direction.LengthSquared() > 0.0001f + ? Vector3.Normalize(direction) + : Vector3.UnitY; Color = color; Intensity = intensity; } diff --git a/src/Engine.Core/FreeFlyCameraController.cs b/src/Engine.Core/FreeFlyCameraController.cs index 4b9687d..aa74897 100644 --- a/src/Engine.Core/FreeFlyCameraController.cs +++ b/src/Engine.Core/FreeFlyCameraController.cs @@ -1,7 +1,6 @@ using System; using System.Numerics; using Flecs.NET.Core; -using SDL; using Engine.Core.Components; namespace Engine.Core; @@ -37,30 +36,30 @@ public sealed class FreeFlyCameraController : ICameraController _yaw = MathF.Atan2(forward.X, forward.Z); } - public void Update(InputMapping input, float deltaTime) + public void Update(IInputState input, float deltaTime) { var move = Vector3.Zero; var forward = new Vector3(MathF.Sin(_yaw), 0.0f, MathF.Cos(_yaw)); - var right = new Vector3(MathF.Cos(_yaw), 0.0f, -MathF.Sin(_yaw)); + var right = new Vector3(-MathF.Cos(_yaw), 0.0f, MathF.Sin(_yaw)); var up = Vector3.UnitY; - if (input.IsKeyDown(SDL_Keycode.SDLK_W)) + if (input.IsKeyDown(Key.W)) move += forward; - if (input.IsKeyDown(SDL_Keycode.SDLK_S)) + if (input.IsKeyDown(Key.S)) move -= forward; - if (input.IsKeyDown(SDL_Keycode.SDLK_A)) + if (input.IsKeyDown(Key.A)) move -= right; - if (input.IsKeyDown(SDL_Keycode.SDLK_D)) + if (input.IsKeyDown(Key.D)) move += right; - if (input.IsKeyDown(SDL_Keycode.SDLK_E)) + if (input.IsKeyDown(Key.E)) move += up; - if (input.IsKeyDown(SDL_Keycode.SDLK_Q)) + if (input.IsKeyDown(Key.Q)) move -= up; if (move.LengthSquared() > 0.0f) { move = Vector3.Normalize(move); - var speed = input.IsKeyDown(SDL_Keycode.SDLK_LSHIFT) ? _fastSpeed : _speed; + var speed = input.IsKeyDown(Key.LeftShift) ? _fastSpeed : _speed; _position += move * speed * deltaTime; } @@ -82,7 +81,7 @@ public sealed class FreeFlyCameraController : ICameraController { var dx = input.MouseX - _lastMouseX; var dy = input.MouseY - _lastMouseY; - _yaw += dx * _mouseSensitivity; + _yaw -= dx * _mouseSensitivity; _pitch += dy * _mouseSensitivity; _pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f); _lastMouseX = input.MouseX; diff --git a/src/Engine.Core/ICameraController.cs b/src/Engine.Core/ICameraController.cs index be141b5..0589596 100644 --- a/src/Engine.Core/ICameraController.cs +++ b/src/Engine.Core/ICameraController.cs @@ -1,10 +1,10 @@ namespace Engine.Core; /// -/// Common interface for camera controllers (orbit, free-fly, etc.). +/// Common interface for camera controllers (free-fly, etc.). /// public interface ICameraController { string Name { get; } - void Update(InputMapping input, float deltaTime); + void Update(IInputState input, float deltaTime); } diff --git a/src/Engine.Core/IInputState.cs b/src/Engine.Core/IInputState.cs new file mode 100644 index 0000000..382a581 --- /dev/null +++ b/src/Engine.Core/IInputState.cs @@ -0,0 +1,25 @@ +namespace Engine.Core; + +/// +/// Read-only query interface for keyboard and mouse input state. +/// Implemented by each windowing backend (SDL3, Raylib, etc.). +/// +public interface IInputState +{ + int MouseX { get; } + int MouseY { get; } + bool MouseLeft { get; } + bool MouseRight { get; } + bool MouseMiddle { get; } + float MouseWheelDelta { get; } + + bool IsKeyDown(Key key); + bool IsKeyPressed(Key key); + bool IsKeyReleased(Key key); + + /// + /// Called at the start of each frame to clear per-frame edge state + /// (key-pressed, key-released, mouse-wheel delta). + /// + void BeginFrame(); +} diff --git a/src/Engine.Core/IScreenshotProvider.cs b/src/Engine.Core/IScreenshotProvider.cs new file mode 100644 index 0000000..1acf000 --- /dev/null +++ b/src/Engine.Core/IScreenshotProvider.cs @@ -0,0 +1,15 @@ +namespace Engine.Core; + +/// +/// Provider that can capture the current rendered frame to a PNG byte array. +/// Implemented by the graphics subsystem and consumed by the AI layer. +/// +public interface IScreenshotProvider +{ + /// + /// Request a screenshot of the next rendered frame. + /// The returned task completes once the frame has been rendered and the PNG bytes are available. + /// The image is also saved to on disk. + /// + Task CaptureAsync(string outputPath); +} diff --git a/src/Engine.Core/IWindow.cs b/src/Engine.Core/IWindow.cs new file mode 100644 index 0000000..9f1b7b4 --- /dev/null +++ b/src/Engine.Core/IWindow.cs @@ -0,0 +1,41 @@ +namespace Engine.Core; + +/// +/// Backend-agnostic window abstraction. +/// Each render backend (Vulkan+SDL3, Raylib+GLFW, etc.) owns and creates its own window. +/// The application retrieves the window from . +/// +public interface IWindow : IDisposable +{ + int Width { get; } + int Height { get; } + bool ShouldClose { get; } + + /// + /// Read-only input state populated during . + /// + IInputState Input { get; } + + /// + /// Poll window events and update . Called once per frame + /// before reading input state or rendering. + /// + void PumpEvents(); + + /// + /// Request the window to close at the next frame boundary. + /// + void Close(); + + /// + /// Native window handle (e.g. SDL_Window*). Used by backends that need + /// the raw OS handle for surface creation. Returns 0 if not applicable. + /// + nint Handle { get; } + + /// + /// Vulkan instance extensions required by this window (e.g. VK_KHR_xlib_surface). + /// Returns an empty array if the windowing system does not support Vulkan. + /// + string[] GetRequiredVulkanExtensions(); +} diff --git a/src/Engine.Core/InputMapping.cs b/src/Engine.Core/InputMapping.cs index dd1507e..45c2328 100644 --- a/src/Engine.Core/InputMapping.cs +++ b/src/Engine.Core/InputMapping.cs @@ -4,14 +4,14 @@ using SDL; namespace Engine.Core; /// -/// Minimal snapshot of current input state. -/// Populated by polling SDL events once per frame. +/// SDL3-backed implementation of . +/// Populated by polling SDL events via once per frame. /// -public sealed class InputMapping +public sealed class InputMapping : IInputState { - private readonly HashSet _keysPressed = new(); - private readonly HashSet _keysDown = new(); - private readonly HashSet _keysReleased = new(); + private readonly HashSet _keysPressed = new(); + private readonly HashSet _keysDown = new(); + private readonly HashSet _keysReleased = new(); public int MouseX { get; private set; } public int MouseY { get; private set; } @@ -32,15 +32,23 @@ public sealed class InputMapping switch ((SDL_EventType)evt.type) { case SDL_EventType.SDL_EVENT_KEY_DOWN: - if (!_keysDown.Contains((SDL_Keycode)evt.key.key)) - _keysPressed.Add((SDL_Keycode)evt.key.key); - _keysDown.Add((SDL_Keycode)evt.key.key); + { + var key = SdlKeyMap.ToKey((SDL_Keycode)evt.key.key); + if (key == Key.Unknown) break; + if (!_keysDown.Contains(key)) + _keysPressed.Add(key); + _keysDown.Add(key); break; + } case SDL_EventType.SDL_EVENT_KEY_UP: - _keysDown.Remove((SDL_Keycode)evt.key.key); - _keysReleased.Add((SDL_Keycode)evt.key.key); + { + var key = SdlKeyMap.ToKey((SDL_Keycode)evt.key.key); + if (key == Key.Unknown) break; + _keysDown.Remove(key); + _keysReleased.Add(key); break; + } case SDL_EventType.SDL_EVENT_MOUSE_MOTION: MouseX = (int)evt.motion.x; @@ -61,9 +69,9 @@ public sealed class InputMapping } } - public bool IsKeyDown(SDL_Keycode key) => _keysDown.Contains(key); - public bool IsKeyPressed(SDL_Keycode key) => _keysPressed.Contains(key); - public bool IsKeyReleased(SDL_Keycode key) => _keysReleased.Contains(key); + public bool IsKeyDown(Key key) => _keysDown.Contains(key); + public bool IsKeyPressed(Key key) => _keysPressed.Contains(key); + public bool IsKeyReleased(Key key) => _keysReleased.Contains(key); private void SetMouseButton(byte button, bool pressed) { @@ -75,3 +83,85 @@ public sealed class InputMapping } } } + +/// +/// Maps SDL3 keycodes to the backend-agnostic enum. +/// +internal static class SdlKeyMap +{ + private static readonly Dictionary _map = new() + { + { SDL_Keycode.SDLK_SPACE, Key.Space }, + { SDL_Keycode.SDLK_ESCAPE, Key.Escape }, + { SDL_Keycode.SDLK_RETURN, Key.Enter }, + { SDL_Keycode.SDLK_TAB, Key.Tab }, + { SDL_Keycode.SDLK_BACKSPACE, Key.Backspace }, + { SDL_Keycode.SDLK_INSERT, Key.Insert }, + { SDL_Keycode.SDLK_DELETE, Key.Delete }, + { SDL_Keycode.SDLK_HOME, Key.Home }, + { SDL_Keycode.SDLK_END, Key.End }, + { SDL_Keycode.SDLK_PAGEUP, Key.PageUp }, + { SDL_Keycode.SDLK_PAGEDOWN, Key.PageDown }, + { SDL_Keycode.SDLK_LEFT, Key.Left }, + { SDL_Keycode.SDLK_RIGHT, Key.Right }, + { SDL_Keycode.SDLK_UP, Key.Up }, + { SDL_Keycode.SDLK_DOWN, Key.Down }, + { SDL_Keycode.SDLK_A, Key.A }, + { SDL_Keycode.SDLK_B, Key.B }, + { SDL_Keycode.SDLK_C, Key.C }, + { SDL_Keycode.SDLK_D, Key.D }, + { SDL_Keycode.SDLK_E, Key.E }, + { SDL_Keycode.SDLK_F, Key.F }, + { SDL_Keycode.SDLK_G, Key.G }, + { SDL_Keycode.SDLK_H, Key.H }, + { SDL_Keycode.SDLK_I, Key.I }, + { SDL_Keycode.SDLK_J, Key.J }, + { SDL_Keycode.SDLK_K, Key.K }, + { SDL_Keycode.SDLK_L, Key.L }, + { SDL_Keycode.SDLK_M, Key.M }, + { SDL_Keycode.SDLK_N, Key.N }, + { SDL_Keycode.SDLK_O, Key.O }, + { SDL_Keycode.SDLK_P, Key.P }, + { SDL_Keycode.SDLK_Q, Key.Q }, + { SDL_Keycode.SDLK_R, Key.R }, + { SDL_Keycode.SDLK_S, Key.S }, + { SDL_Keycode.SDLK_T, Key.T }, + { SDL_Keycode.SDLK_U, Key.U }, + { SDL_Keycode.SDLK_V, Key.V }, + { SDL_Keycode.SDLK_W, Key.W }, + { SDL_Keycode.SDLK_X, Key.X }, + { SDL_Keycode.SDLK_Y, Key.Y }, + { SDL_Keycode.SDLK_Z, Key.Z }, + { SDL_Keycode.SDLK_0, Key.Zero }, + { SDL_Keycode.SDLK_1, Key.One }, + { SDL_Keycode.SDLK_2, Key.Two }, + { SDL_Keycode.SDLK_3, Key.Three }, + { SDL_Keycode.SDLK_4, Key.Four }, + { SDL_Keycode.SDLK_5, Key.Five }, + { SDL_Keycode.SDLK_6, Key.Six }, + { SDL_Keycode.SDLK_7, Key.Seven }, + { SDL_Keycode.SDLK_8, Key.Eight }, + { SDL_Keycode.SDLK_9, Key.Nine }, + { SDL_Keycode.SDLK_F1, Key.F1 }, + { SDL_Keycode.SDLK_F2, Key.F2 }, + { SDL_Keycode.SDLK_F3, Key.F3 }, + { SDL_Keycode.SDLK_F4, Key.F4 }, + { SDL_Keycode.SDLK_F5, Key.F5 }, + { SDL_Keycode.SDLK_F6, Key.F6 }, + { SDL_Keycode.SDLK_F7, Key.F7 }, + { SDL_Keycode.SDLK_F8, Key.F8 }, + { SDL_Keycode.SDLK_F9, Key.F9 }, + { SDL_Keycode.SDLK_F10, Key.F10 }, + { SDL_Keycode.SDLK_F11, Key.F11 }, + { SDL_Keycode.SDLK_F12, Key.F12 }, + { SDL_Keycode.SDLK_LSHIFT, Key.LeftShift }, + { SDL_Keycode.SDLK_LCTRL, Key.LeftControl }, + { SDL_Keycode.SDLK_LALT, Key.LeftAlt }, + { SDL_Keycode.SDLK_RSHIFT, Key.RightShift }, + { SDL_Keycode.SDLK_RCTRL, Key.RightControl }, + { SDL_Keycode.SDLK_RALT, Key.RightAlt }, + }; + + public static Key ToKey(SDL_Keycode sdlKey) => + _map.TryGetValue(sdlKey, out var key) ? key : Key.Unknown; +} diff --git a/src/Engine.Core/Key.cs b/src/Engine.Core/Key.cs new file mode 100644 index 0000000..587db96 --- /dev/null +++ b/src/Engine.Core/Key.cs @@ -0,0 +1,39 @@ +namespace Engine.Core; + +/// +/// Backend-agnostic key codes used by and camera controllers. +/// Each windowing backend (SDL3, Raylib, etc.) maps its native key codes to these values. +/// +public enum Key +{ + Unknown = 0, + Space, + Escape, + Enter, + Tab, + Backspace, + Insert, + Delete, + Home, + End, + PageUp, + PageDown, + Left, + Right, + Up, + Down, + + A, B, C, D, E, F, G, H, I, J, K, L, M, + N, O, P, Q, R, S, T, U, V, W, X, Y, Z, + + Zero, One, Two, Three, Four, Five, Six, Seven, Eight, Nine, + + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, + + LeftShift, + LeftControl, + LeftAlt, + RightShift, + RightControl, + RightAlt, +} diff --git a/src/Engine.Core/OrbitCameraController.cs b/src/Engine.Core/OrbitCameraController.cs index 84135e7..4365d33 100644 --- a/src/Engine.Core/OrbitCameraController.cs +++ b/src/Engine.Core/OrbitCameraController.cs @@ -1,68 +1,102 @@ using System; using System.Numerics; using Flecs.NET.Core; +using Engine.Core.Components; namespace Engine.Core; /// -/// Orbit camera controller. Right mouse drag rotates around the target, -/// mouse wheel zooms in/out. +/// Orbit camera controller. Rotates the camera around a fixed target point. +/// Right mouse drag rotates; mouse wheel zooms; WASD moves the target on the ground plane. /// public sealed class OrbitCameraController : ICameraController { private readonly Entity _cameraEntity; + private Vector3 _target; private float _distance; private float _yaw; private float _pitch; - private readonly Vector3 _target; + private float _speed = 3.0f; + private float _fastSpeed = 8.0f; + private float _mouseSensitivity = 0.005f; + private float _zoomSensitivity = 0.1f; private int _lastMouseX; private int _lastMouseY; - private bool _isDragging; + private bool _wasRightMouseDown; public string Name => "Orbit"; - public OrbitCameraController(Entity cameraEntity, Vector3? target = null) + public OrbitCameraController(Entity cameraEntity, Vector3 target) { _cameraEntity = cameraEntity; - var camera = cameraEntity.Get(); - _target = target ?? Vector3.Zero; - _distance = Vector3.Distance(camera.Position, _target); + _target = target; - var direction = Vector3.Normalize(camera.Position - _target); - _pitch = MathF.Asin(-direction.Y); - _yaw = MathF.Atan2(direction.X, direction.Z); + var camera = cameraEntity.Get(); + _distance = Vector3.Distance(camera.Position, target); + + var forward = Vector3.Normalize(target - camera.Position); + _pitch = MathF.Asin(-forward.Y); + _yaw = MathF.Atan2(forward.X, forward.Z); + + // Clamp pitch to avoid gimbal-lock and sudden flips. + _pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f); } - public void Update(InputMapping input, float deltaTime) + public void Update(IInputState input, float deltaTime) { + var move = Vector3.Zero; + var forward = new Vector3(MathF.Sin(_yaw), 0.0f, MathF.Cos(_yaw)); + var right = new Vector3(-MathF.Cos(_yaw), 0.0f, MathF.Sin(_yaw)); + var up = Vector3.UnitY; + + if (input.IsKeyDown(Key.W)) + move += forward; + if (input.IsKeyDown(Key.S)) + move -= forward; + if (input.IsKeyDown(Key.A)) + move -= right; + if (input.IsKeyDown(Key.D)) + move += right; + if (input.IsKeyDown(Key.E)) + move += up; + if (input.IsKeyDown(Key.Q)) + move -= up; + + if (move.LengthSquared() > 0.0f) + { + move = Vector3.Normalize(move); + var speed = input.IsKeyDown(Key.LeftShift) ? _fastSpeed : _speed; + _target += move * speed * deltaTime; + } + + if (input.MouseWheelDelta != 0) + { + _distance *= 1.0f - input.MouseWheelDelta * _zoomSensitivity; + _distance = Math.Clamp(_distance, 1.0f, 200.0f); + } + if (input.MouseRight) { - if (!_isDragging) + if (!_wasRightMouseDown) { - _isDragging = true; _lastMouseX = input.MouseX; _lastMouseY = input.MouseY; + _wasRightMouseDown = true; } else { var dx = input.MouseX - _lastMouseX; var dy = input.MouseY - _lastMouseY; - _yaw -= dx * 0.005f; - _pitch -= dy * 0.005f; - _pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.1f, MathF.PI / 2.0f - 0.1f); + _yaw -= dx * _mouseSensitivity; + _pitch += dy * _mouseSensitivity; + _pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f); _lastMouseX = input.MouseX; _lastMouseY = input.MouseY; } } else { - _isDragging = false; - } - - if (input.MouseWheelDelta != 0) - { - _distance *= 1.0f - input.MouseWheelDelta * 0.1f; - _distance = Math.Clamp(_distance, 0.5f, 50.0f); + _wasRightMouseDown = false; } UpdateCamera(); @@ -70,13 +104,16 @@ public sealed class OrbitCameraController : ICameraController private void UpdateCamera() { - var x = _distance * MathF.Cos(_pitch) * MathF.Sin(_yaw); - var y = _distance * MathF.Sin(_pitch); - var z = _distance * MathF.Cos(_pitch) * MathF.Cos(_yaw); + var camera = _cameraEntity.Get(); - var camera = _cameraEntity.Get(); - camera.Position = _target + new Vector3(x, y, z); + var direction = new Vector3( + MathF.Cos(_pitch) * MathF.Sin(_yaw), + -MathF.Sin(_pitch), + MathF.Cos(_pitch) * MathF.Cos(_yaw)); + + camera.Position = _target - direction * _distance; camera.Target = _target; + camera.Up = Vector3.UnitY; _cameraEntity.Set(camera); } } diff --git a/src/Engine.Core/Sdl3Window.cs b/src/Engine.Core/Sdl3Window.cs index 805fb74..eb0f9bd 100644 --- a/src/Engine.Core/Sdl3Window.cs +++ b/src/Engine.Core/Sdl3Window.cs @@ -6,51 +6,51 @@ using SDL; namespace Engine.Core; /// -/// A thin, disposable wrapper around an SDL3 window. -/// Handles creation, Vulkan surface discovery, and event polling. +/// SDL3-backed implementation of . +/// Creates a native window, polls SDL events, and exposes input via . /// -public sealed unsafe class Sdl3Window : IDisposable +public sealed unsafe class Sdl3Window : IWindow { private readonly SDL_Window* _window; + private readonly InputMapping _input = new(); private bool _disposed; public int Width { get; private set; } public int Height { get; private set; } - public nint Handle => (nint)_window; public bool ShouldClose { get; private set; } + public IInputState Input => _input; + public nint Handle => (nint)_window; - public Sdl3Window(string title, int width, int height) + public void Close() => ShouldClose = true; + + public Sdl3Window(string title, int width, int height, bool vulkanSurface = true) { Width = width; Height = height; if (!SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO)) - { throw new InvalidOperationException($"SDL_Init failed: {SDL3.SDL_GetError()}"); - } + + var flags = SDL_WindowFlags.SDL_WINDOW_RESIZABLE; + if (vulkanSurface) + flags |= SDL_WindowFlags.SDL_WINDOW_VULKAN; var titleBytes = Encoding.UTF8.GetBytes(title + '\0'); fixed (byte* titlePtr = titleBytes) { - _window = SDL3.SDL_CreateWindow( - titlePtr, - width, - height, - SDL_WindowFlags.SDL_WINDOW_VULKAN | SDL_WindowFlags.SDL_WINDOW_RESIZABLE); + _window = SDL3.SDL_CreateWindow(titlePtr, width, height, flags); } if (_window == null) - { throw new InvalidOperationException($"SDL_CreateWindow failed: {SDL3.SDL_GetError()}"); - } } - public void PumpEvents(InputMapping? input = null) + public void PumpEvents() { SDL_Event evt; while (SDL3.SDL_PollEvent(&evt)) { - input?.ProcessEvent(evt); + _input.ProcessEvent(evt); switch ((SDL_EventType)evt.type) { @@ -71,20 +71,16 @@ public sealed unsafe class Sdl3Window : IDisposable } } - public string[] GetRequiredInstanceExtensions() + public string[] GetRequiredVulkanExtensions() { uint count; var extensionsPtr = SDL3.SDL_Vulkan_GetInstanceExtensions(&count); if (extensionsPtr == null) - { throw new InvalidOperationException($"SDL_Vulkan_GetInstanceExtensions failed: {SDL3.SDL_GetError()}"); - } var result = new string[count]; for (var i = 0; i < count; i++) - { result[i] = SDL3.PtrToStringUTF8(extensionsPtr[i]) ?? string.Empty; - } return result; } diff --git a/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj new file mode 100644 index 0000000..2bbc2cc --- /dev/null +++ b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + true + false + Engine.Graphics.Raylib + Engine.Graphics.Raylib + + + + DEV_MODE + + + + RELEASE_AOT + false + + + + + + + + + + + + + + diff --git a/src/Engine.Graphics.Raylib/RaylibBackendRegistrar.cs b/src/Engine.Graphics.Raylib/RaylibBackendRegistrar.cs new file mode 100644 index 0000000..2bb8e4f --- /dev/null +++ b/src/Engine.Graphics.Raylib/RaylibBackendRegistrar.cs @@ -0,0 +1,20 @@ +using Engine.Graphics; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Triggers registration of the Raylib backend with the HAL factory. +/// +public static class RaylibBackendRegistrar +{ + static RaylibBackendRegistrar() + { + RenderBackendFactory.Register("raylib", (width, height, _) => new RaylibRenderContext(width, height)); + } + + /// + /// No-op method that forces the static constructor to run. + /// Call this before using . + /// + public static void EnsureRegistered() { } +} diff --git a/src/Engine.Graphics.Raylib/RaylibInputState.cs b/src/Engine.Graphics.Raylib/RaylibInputState.cs new file mode 100644 index 0000000..83ca180 --- /dev/null +++ b/src/Engine.Graphics.Raylib/RaylibInputState.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using Engine.Core; +using Raylib_cs; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Raylib-backed implementation of . +/// Queries Raylib's input functions directly each frame. +/// +public sealed class RaylibInputState : IInputState +{ + private static readonly Key[] _allKeys = (Key[])Enum.GetValues(typeof(Key)); + + private readonly HashSet _keysDown = new(); + private readonly HashSet _keysPressed = new(); + private readonly HashSet _keysReleased = new(); + + private float _mouseWheelDelta; + private bool _wheelConsumed; + + public int MouseX => Raylib.GetMouseX(); + public int MouseY => Raylib.GetMouseY(); + public bool MouseLeft => Raylib.IsMouseButtonDown(MouseButton.Left); + public bool MouseRight => Raylib.IsMouseButtonDown(MouseButton.Right); + public bool MouseMiddle => Raylib.IsMouseButtonDown(MouseButton.Middle); + + public float MouseWheelDelta + { + get + { + if (!_wheelConsumed) + { + _mouseWheelDelta = Raylib.GetMouseWheelMove(); + _wheelConsumed = true; + } + return _mouseWheelDelta; + } + } + + public void BeginFrame() + { + _keysPressed.Clear(); + _keysReleased.Clear(); + _mouseWheelDelta = 0; + _wheelConsumed = false; + } + + /// + /// Poll Raylib input and update edge state. Called by . + /// + public void Poll() + { + _keysPressed.Clear(); + _keysReleased.Clear(); + + foreach (var key in _allKeys) + { + if (key == Key.Unknown) continue; + var rlKey = ToRaylibKey(key); + if (rlKey == KeyboardKey.Null) continue; + + var isDown = Raylib.IsKeyDown(rlKey); + var wasDown = _keysDown.Contains(key); + + if (isDown && !wasDown) + _keysPressed.Add(key); + if (!isDown && wasDown) + _keysReleased.Add(key); + + if (isDown) + _keysDown.Add(key); + else + _keysDown.Remove(key); + } + } + + public bool IsKeyDown(Key key) => _keysDown.Contains(key); + public bool IsKeyPressed(Key key) => _keysPressed.Contains(key); + public bool IsKeyReleased(Key key) => _keysReleased.Contains(key); + + private static KeyboardKey ToRaylibKey(Key key) => key switch + { + Key.Space => KeyboardKey.Space, + Key.Escape => KeyboardKey.Escape, + Key.Enter => KeyboardKey.Enter, + Key.Tab => KeyboardKey.Tab, + Key.Backspace => KeyboardKey.Backspace, + Key.Insert => KeyboardKey.Insert, + Key.Delete => KeyboardKey.Delete, + Key.Home => KeyboardKey.Home, + Key.End => KeyboardKey.End, + Key.PageUp => KeyboardKey.PageUp, + Key.PageDown => KeyboardKey.PageDown, + Key.Left => KeyboardKey.Left, + Key.Right => KeyboardKey.Right, + Key.Up => KeyboardKey.Up, + Key.Down => KeyboardKey.Down, + Key.A => KeyboardKey.A, + Key.B => KeyboardKey.B, + Key.C => KeyboardKey.C, + Key.D => KeyboardKey.D, + Key.E => KeyboardKey.E, + Key.F => KeyboardKey.F, + Key.G => KeyboardKey.G, + Key.H => KeyboardKey.H, + Key.I => KeyboardKey.I, + Key.J => KeyboardKey.J, + Key.K => KeyboardKey.K, + Key.L => KeyboardKey.L, + Key.M => KeyboardKey.M, + Key.N => KeyboardKey.N, + Key.O => KeyboardKey.O, + Key.P => KeyboardKey.P, + Key.Q => KeyboardKey.Q, + Key.R => KeyboardKey.R, + Key.S => KeyboardKey.S, + Key.T => KeyboardKey.T, + Key.U => KeyboardKey.U, + Key.V => KeyboardKey.V, + Key.W => KeyboardKey.W, + Key.X => KeyboardKey.X, + Key.Y => KeyboardKey.Y, + Key.Z => KeyboardKey.Z, + Key.Zero => KeyboardKey.Zero, + Key.One => KeyboardKey.One, + Key.Two => KeyboardKey.Two, + Key.Three => KeyboardKey.Three, + Key.Four => KeyboardKey.Four, + Key.Five => KeyboardKey.Five, + Key.Six => KeyboardKey.Six, + Key.Seven => KeyboardKey.Seven, + Key.Eight => KeyboardKey.Eight, + Key.Nine => KeyboardKey.Nine, + Key.F1 => KeyboardKey.F1, + Key.F2 => KeyboardKey.F2, + Key.F3 => KeyboardKey.F3, + Key.F4 => KeyboardKey.F4, + Key.F5 => KeyboardKey.F5, + Key.F6 => KeyboardKey.F6, + Key.F7 => KeyboardKey.F7, + Key.F8 => KeyboardKey.F8, + Key.F9 => KeyboardKey.F9, + Key.F10 => KeyboardKey.F10, + Key.F11 => KeyboardKey.F11, + Key.F12 => KeyboardKey.F12, + Key.LeftShift => KeyboardKey.LeftShift, + Key.LeftControl => KeyboardKey.LeftControl, + Key.LeftAlt => KeyboardKey.LeftAlt, + Key.RightShift => KeyboardKey.RightShift, + Key.RightControl => KeyboardKey.RightControl, + Key.RightAlt => KeyboardKey.RightAlt, + _ => KeyboardKey.Null, + }; +} diff --git a/src/Engine.Graphics.Raylib/RaylibRenderContext.cs b/src/Engine.Graphics.Raylib/RaylibRenderContext.cs new file mode 100644 index 0000000..a18cc2f --- /dev/null +++ b/src/Engine.Graphics.Raylib/RaylibRenderContext.cs @@ -0,0 +1,28 @@ +using Engine.Core; +using Engine.Graphics; +using Raylib_cs; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Raylib implementation of the render HAL context. +/// Creates and owns a (GLFW-based). +/// No SDL3 dependency — the Raylib window handles both rendering and input. +/// +public sealed class RaylibRenderContext : IRenderContext +{ + private readonly RaylibWindow _window; + + public IWindow Window => _window; + + public RaylibRenderContext(int width, int height, bool enableValidation = false) + { + _window = new RaylibWindow("Cortex Engine", width, height); + } + + public IRenderer CreateRenderer() => new RaylibRenderer(); + + public void Resize(int width, int height) => Raylib.SetWindowSize(width, height); + + public void Dispose() => _window.Dispose(); +} diff --git a/src/Engine.Graphics.Raylib/RaylibRenderer.cs b/src/Engine.Graphics.Raylib/RaylibRenderer.cs new file mode 100644 index 0000000..a8b7a9d --- /dev/null +++ b/src/Engine.Graphics.Raylib/RaylibRenderer.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Engine.Core; +using Engine.Core.Components; +using EngineMaterial = Engine.Core.Components.Material; +using EngineMesh = Engine.Core.Components.Mesh; +using EngineTransform = Engine.Core.Components.Transform; +using Flecs.NET.Core; +using Raylib_cs; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Raylib implementation of the ECS world renderer. +/// Renders Mesh + Transform + Material entities with up to four directional lights. +/// +public sealed class RaylibRenderer : IRenderer +{ + private readonly Shader _shader; + private readonly Dictionary _modelCache = new(); + private readonly Dictionary _textureCache = new(); + private readonly int _materialColorLoc; + private readonly int _useTextureLoc; + private readonly int _roughnessLoc; + private readonly int _metallicLoc; + private readonly int _ambientLoc; + private readonly int _viewPosLoc; + private readonly int _lightCountLoc; + private readonly int _lightDirLoc; + private readonly int _lightIntensityLoc; + private readonly int _lightColorLoc; + private readonly float[] _lightDirs = new float[12]; // 4 lights * 3 floats + private readonly float[] _lightIntensities = new float[4]; + private readonly float[] _lightColors = new float[12]; // 4 lights * 3 floats + + private ScreenshotRequest? _pendingScreenshot; + private int _frameCount; + private bool _disposed; + + public RaylibRenderer() + { + _shader = LoadShader(); + + _materialColorLoc = Raylib.GetShaderLocation(_shader, "materialColor"); + _useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture"); + _roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness"); + _metallicLoc = Raylib.GetShaderLocation(_shader, "metallic"); + _ambientLoc = Raylib.GetShaderLocation(_shader, "ambientColor"); + _viewPosLoc = Raylib.GetShaderLocation(_shader, "viewPos"); + _lightCountLoc = Raylib.GetShaderLocation(_shader, "lightCount"); + _lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs"); + _lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities"); + _lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors"); + } + + public void RequestScreenshot(string outputPath) + { + _pendingScreenshot = new ScreenshotRequest(outputPath, null); + } + + public bool IsScreenshotRequested => _pendingScreenshot != null; + + public IScreenshotProvider ScreenshotProvider => new RaylibScreenshotProvider(this); + + public void RenderWorld(World world) + { + var camera = GetCamera(world); + + Raylib.BeginDrawing(); + Raylib.ClearBackground(new Color(25, 30, 40, 255)); + Raylib.BeginMode3D(ToRaylib(camera)); + + Rlgl.DisableBackfaceCulling(); + + // Frame-level uniforms: SetShaderValue calls glUseProgram internally, + // so these don't need BeginShaderMode. DrawModelEx rebinds the same shader + // (set on the model's material), so the values persist for the draw call. + CollectLights(world); + SetFrameLights(); + Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3); + + world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) => + { + if (e.Name() == "Grid") + return; + + var material = e.Has() ? e.Get() : EngineMaterial.Default; + var model = GetOrUploadModel(e, mesh); + var modelMatrix = transform.GetMatrix(); + + if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) + { + var axis = Vector3.UnitY; + var angle = 0.0f; + var q = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W); + if (MathF.Abs(q.W) < 0.9999999f) + { + angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f)); + var s = MathF.Sqrt(1.0f - q.W * q.W); + if (s > 0.0001f) + axis = new Vector3(q.X / s, q.Y / s, q.Z / s); + else + axis = new Vector3(q.X, q.Y, q.Z); + } + + // Set per-entity uniforms right before the draw. + // DrawModelEx binds the model's material shader (= _shader) and + // immediately issues the draw, so these values are live during rendering. + SetMaterialUniforms(material, model); + Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White); + } + }); + + Rlgl.EnableBackfaceCulling(); + + Raylib.DrawGrid(20, 1.0f); + + Raylib.EndMode3D(); + Raylib.EndDrawing(); + + // Defer the first screenshot by a few frames. Raylib may return a blank image + // if the window/GPU has not finished presenting the first frame. + if (_pendingScreenshot is { } request && _frameCount >= 10) + { + CaptureScreenshot(request); + _pendingScreenshot = null; + } + + _frameCount++; + } + + private Camera3D ToRaylib(Camera camera) + { + return new Camera3D + { + Position = camera.Position, + Target = camera.Target, + Up = camera.Up, + FovY = camera.FieldOfView * 180.0f / MathF.PI, + Projection = CameraProjection.Perspective + }; + } + + private Camera GetCamera(World world) + { + var width = Raylib.GetScreenWidth(); + var height = Raylib.GetScreenHeight(); + var aspect = height > 0 ? (float)width / height : 16f / 9f; + + var camera = new Camera( + new Vector3(0.0f, 0.75f, -30.0f), + new Vector3(0.0f, 0.5f, 0.0f), + Vector3.UnitY, + MathF.PI / 12.0f, + aspect, + 0.1f, + 100.0f); + + world.Each((Entity e, ref Camera cam) => + { + camera = cam; + }); + + camera.AspectRatio = aspect; + return camera; + } + + private void CollectLights(World world) + { + var count = 0; + world.Each((Entity e, ref Light light) => + { + if (count >= 4) + return; + _lightDirs[count * 3 + 0] = light.Direction.X; + _lightDirs[count * 3 + 1] = light.Direction.Y; + _lightDirs[count * 3 + 2] = light.Direction.Z; + _lightIntensities[count] = light.Intensity; + _lightColors[count * 3 + 0] = light.Color.X; + _lightColors[count * 3 + 1] = light.Color.Y; + _lightColors[count * 3 + 2] = light.Color.Z; + count++; + }); + + if (count == 0) + { + _lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f; + _lightIntensities[0] = 1.0f; + _lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f; + count = 1; + } + + for (var i = count; i < 4; i++) + { + _lightDirs[i * 3 + 0] = 0; + _lightDirs[i * 3 + 1] = 0; + _lightDirs[i * 3 + 2] = 0; + _lightIntensities[i] = 0.0f; + _lightColors[i * 3 + 0] = 0; + _lightColors[i * 3 + 1] = 0; + _lightColors[i * 3 + 2] = 0; + } + + Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int); + Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4); + Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4); + Raylib.SetShaderValueV(_shader, _lightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 4); + } + + private void SetFrameLights() + { + Raylib.SetShaderValue(_shader, _ambientLoc, new float[] { 0.35f, 0.35f, 0.4f }, ShaderUniformDataType.Vec3); + } + + private unsafe void SetMaterialUniforms(EngineMaterial material, Raylib_cs.Model model) + { + Raylib.SetShaderValue(_shader, _materialColorLoc, new float[] { material.Albedo.X, material.Albedo.Y, material.Albedo.Z, 1.0f }, ShaderUniformDataType.Vec4); + Raylib.SetShaderValue(_shader, _roughnessLoc, material.Roughness, ShaderUniformDataType.Float); + Raylib.SetShaderValue(_shader, _metallicLoc, material.Metallic, ShaderUniformDataType.Float); + + if (material.HasTexture && File.Exists(material.TexturePath!)) + { + Raylib.SetShaderValue(_shader, _useTextureLoc, 1, ShaderUniformDataType.Int); + var texture = GetOrLoadTexture(material.TexturePath!); + Raylib.SetMaterialTexture(ref model.Materials[0], MaterialMapIndex.Albedo, texture); + } + else + { + Raylib.SetShaderValue(_shader, _useTextureLoc, 0, ShaderUniformDataType.Int); + } + } + + private unsafe Raylib_cs.Model GetOrUploadModel(Entity e, EngineMesh mesh) + { + if (_modelCache.TryGetValue(e, out var model)) + return model; + + // Use Raylib's native mesh generation when possible — the manual UploadMesh + // + LoadModelFromMesh path is unreliable for larger meshes because + // LoadModelFromMesh reads CPU-side vertex pointers after UploadMesh. + // For custom meshes (from OBJ/GLTF loaders), keep the CPU data alive. + var raylibMesh = UploadRaylibMesh(mesh); + model = Raylib.LoadModelFromMesh(raylibMesh); + + for (var i = 0; i < model.MaterialCount; i++) + { + model.Materials[i].Shader = _shader; + } + _modelCache[e] = model; + return model; + } + + private unsafe Raylib_cs.Mesh UploadRaylibMesh(EngineMesh mesh) + { + var vertexCount = mesh.Vertices.Length; + var triangleCount = mesh.Indices.Length / 3; + + var raylibMesh = new Raylib_cs.Mesh + { + VertexCount = vertexCount, + TriangleCount = triangleCount + }; + + var positionSize = vertexCount * 3 * sizeof(float); + var normalSize = vertexCount * 3 * sizeof(float); + var colorSize = vertexCount * 4; + var texcoordSize = vertexCount * 2 * sizeof(float); + var indexSize = mesh.Indices.Length * sizeof(ushort); + + // Use NativeMemory.Alloc so Raylib's UnloadMesh can free with RL_FREE (free). + var positionPtr = (float*)NativeMemory.Alloc((nuint)positionSize, 4); + var normalPtr = (float*)NativeMemory.Alloc((nuint)normalSize, 4); + var colorPtr = (byte*)NativeMemory.Alloc((nuint)colorSize, 1); + var texcoordPtr = (float*)NativeMemory.Alloc((nuint)texcoordSize, 4); + var indexPtr = (ushort*)NativeMemory.Alloc((nuint)indexSize, 2); + + for (var i = 0; i < vertexCount; i++) + { + var v = mesh.Vertices[i]; + positionPtr[i * 3 + 0] = v.Position.X; + positionPtr[i * 3 + 1] = v.Position.Y; + positionPtr[i * 3 + 2] = v.Position.Z; + + normalPtr[i * 3 + 0] = v.Normal.X; + normalPtr[i * 3 + 1] = v.Normal.Y; + normalPtr[i * 3 + 2] = v.Normal.Z; + + colorPtr[i * 4 + 0] = (byte)Math.Clamp(v.Color.X * 255.0f, 0.0f, 255.0f); + colorPtr[i * 4 + 1] = (byte)Math.Clamp(v.Color.Y * 255.0f, 0.0f, 255.0f); + colorPtr[i * 4 + 2] = (byte)Math.Clamp(v.Color.Z * 255.0f, 0.0f, 255.0f); + colorPtr[i * 4 + 3] = 255; + + texcoordPtr[i * 2 + 0] = v.Position.X; + texcoordPtr[i * 2 + 1] = v.Position.Z; + } + + for (var i = 0; i < mesh.Indices.Length; i++) + indexPtr[i] = (ushort)mesh.Indices[i]; + + raylibMesh.Vertices = positionPtr; + raylibMesh.Normals = normalPtr; + raylibMesh.Colors = colorPtr; + raylibMesh.TexCoords = texcoordPtr; + raylibMesh.Indices = indexPtr; + + Raylib.UploadMesh(ref raylibMesh, false); + + // Keep CPU-side data alive — LoadModelFromMesh reads these pointers + // to compute the bounding box. They will be freed when the model is unloaded. + return raylibMesh; + } + + private Texture2D GetOrLoadTexture(string path) + { + if (_textureCache.TryGetValue(path, out var texture)) + return texture; + + texture = Raylib.LoadTexture(path); + Raylib.SetTextureWrap(texture, TextureWrap.Repeat); + Raylib.SetTextureFilter(texture, TextureFilter.Trilinear); + _textureCache[path] = texture; + return texture; + } + + private unsafe void CaptureScreenshot(ScreenshotRequest request) + { + var image = Raylib.LoadImageFromScreen(); + try + { + var directory = Path.GetDirectoryName(request.Path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + Raylib.ExportImage(image, request.Path); + + if (request.Tcs != null) + { + var size = 0; + var fileType = stackalloc byte[] { (byte)'.', (byte)'p', (byte)'n', (byte)'g', 0 }; + var data = Raylib.ExportImageToMemory(image, (sbyte*)fileType, &size); + var bytes = new byte[size]; + fixed (byte* p = bytes) + { + Buffer.MemoryCopy(data, p, size, size); + } + Raylib.MemFree(data); + request.Tcs.TrySetResult(bytes); + } + + Console.WriteLine($"Screenshot saved: {request.Path}"); + } + finally + { + Raylib.UnloadImage(image); + } + } + + private Task CaptureAsync(string outputPath) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingScreenshot = new ScreenshotRequest(outputPath, tcs); + return tcs.Task; + } + + private static Shader LoadShader() + { + const string VertexSource = @"#version 330 core +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec3 vertexNormal; +in vec4 vertexColor; +uniform mat4 mvp; +uniform mat4 matModel; +out vec3 vNormal; +out vec3 vWorldPos; +out vec4 vColor; +out vec2 vTexCoord; +void main() +{ + vec4 worldPos = matModel * vec4(vertexPosition, 1.0); + vWorldPos = worldPos.xyz; + vNormal = mat3(transpose(inverse(matModel))) * vertexNormal; + vColor = vertexColor; + vTexCoord = vertexTexCoord; + gl_Position = mvp * vec4(vertexPosition, 1.0); +}"; + + const string FragmentSource = @"#version 330 core +in vec3 vNormal; +in vec3 vWorldPos; +in vec4 vColor; +in vec2 vTexCoord; +out vec4 finalColor; +uniform vec4 materialColor; +uniform int useTexture; +uniform sampler2D texture0; +uniform float roughness; +uniform float metallic; +uniform vec3 viewPos; +uniform vec3 ambientColor; +uniform int lightCount; +uniform vec3 lightDirs[4]; +uniform float lightIntensities[4]; +uniform vec3 lightColors[4]; + +vec3 ACESFilm(vec3 x) +{ + const float a = 2.51; const float b = 0.03; const float c = 2.43; const float d = 0.59; const float e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +void main() +{ + vec3 normal = normalize(vNormal); + vec3 albedo = vColor.rgb * materialColor.rgb; + if (useTexture != 0) + { + vec2 uv = vTexCoord * 4.0; + albedo *= texture(texture0, uv).rgb; + } + vec3 viewDir = normalize(viewPos - vWorldPos); + float rough = clamp(roughness, 0.05, 1.0); + float metal = clamp(metallic, 0.0, 1.0); + + // Hemisphere ambient: low ambient for visible shading contrast + vec3 skyColor = ambientColor; + vec3 groundColor = ambientColor * 0.2; + float hemisphere = 0.5 + 0.5 * normal.y; + vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4; + + vec3 F0 = mix(vec3(0.04), albedo, metal); + float shininess = mix(8.0, 256.0, 1.0 - rough); + + for (int i = 0; i < lightCount; i++) + { + vec3 L = normalize(-lightDirs[i]); + vec3 H = normalize(L + viewDir); + + float NdotL = max(dot(normal, L), 0.0); + float NdotH = max(dot(normal, H), 0.0); + float NdotV = max(dot(normal, viewDir), 0.0); + float HdotV = max(dot(H, viewDir), 0.0); + + float diff = NdotL; + float spec = pow(NdotH, shininess); + + // Schlick Fresnel + float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - HdotV, 5.0); + vec3 specularColor = mix(vec3(fresnel), albedo * fresnel, metal); + + vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5; + vec3 specular = specularColor * spec * lightIntensities[i]; + + // Energy conservation + diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5)); + + result += diffuse + specular; + } + + // ACES tonemapping + gamma correction + result = ACESFilm(result * 1.2); + result = pow(result, vec3(1.0 / 2.2)); + + finalColor = vec4(result, 1.0); +}"; + + return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (var model in _modelCache.Values) + Raylib.UnloadModel(model); + _modelCache.Clear(); + + foreach (var texture in _textureCache.Values) + Raylib.UnloadTexture(texture); + _textureCache.Clear(); + + Raylib.UnloadShader(_shader); + } + + private readonly record struct ScreenshotRequest(string Path, TaskCompletionSource? Tcs); + + private sealed class RaylibScreenshotProvider : IScreenshotProvider + { + private readonly RaylibRenderer _renderer; + + public RaylibScreenshotProvider(RaylibRenderer renderer) + { + _renderer = renderer; + } + + public Task CaptureAsync(string outputPath) => _renderer.CaptureAsync(outputPath); + } +} diff --git a/src/Engine.Graphics.Raylib/RaylibWindow.cs b/src/Engine.Graphics.Raylib/RaylibWindow.cs new file mode 100644 index 0000000..1588542 --- /dev/null +++ b/src/Engine.Graphics.Raylib/RaylibWindow.cs @@ -0,0 +1,54 @@ +using System; +using Engine.Core; +using Raylib_cs; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Raylib-backed implementation of . +/// Wraps Raylib's GLFW window creation, event polling, and input. +/// +public sealed class RaylibWindow : IWindow +{ + private readonly RaylibInputState _input = new(); + private bool _shouldClose; + private bool _disposed; + + public int Width => Raylib.GetScreenWidth(); + public int Height => Raylib.GetScreenHeight(); + public bool ShouldClose => _shouldClose; + public IInputState Input => _input; + public nint Handle => 0; + + public RaylibWindow(string title, int width, int height) + { + Raylib.SetConfigFlags(ConfigFlags.VSyncHint); + Raylib.InitWindow(width, height, title); + Raylib.SetTargetFPS(0); + + // Present a blank frame so the window is visible immediately. + Raylib.BeginDrawing(); + Raylib.ClearBackground(new Color(25, 30, 40, 255)); + Raylib.EndDrawing(); + } + + public void PumpEvents() + { + _input.Poll(); + _shouldClose = Raylib.WindowShouldClose() || _shouldClose; + + if (Raylib.IsKeyPressed(KeyboardKey.Escape)) + _shouldClose = true; + } + + public void Close() => _shouldClose = true; + + public string[] GetRequiredVulkanExtensions() => Array.Empty(); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Raylib.CloseWindow(); + } +} diff --git a/src/Engine.Graphics.Vulkan/Engine.Graphics.Vulkan.csproj b/src/Engine.Graphics.Vulkan/Engine.Graphics.Vulkan.csproj new file mode 100644 index 0000000..9ee4295 --- /dev/null +++ b/src/Engine.Graphics.Vulkan/Engine.Graphics.Vulkan.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + enable + enable + true + true + Engine.Graphics.Vulkan + Engine.Graphics.Vulkan + + + + DEV_MODE + + + + RELEASE_AOT + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Engine.Graphics/IndexBuffer.cs b/src/Engine.Graphics.Vulkan/IndexBuffer.cs similarity index 100% rename from src/Engine.Graphics/IndexBuffer.cs rename to src/Engine.Graphics.Vulkan/IndexBuffer.cs diff --git a/src/Engine.Graphics/ScreenshotCapture.cs b/src/Engine.Graphics.Vulkan/ScreenshotCapture.cs similarity index 76% rename from src/Engine.Graphics/ScreenshotCapture.cs rename to src/Engine.Graphics.Vulkan/ScreenshotCapture.cs index 2a7f606..90953ef 100644 --- a/src/Engine.Graphics/ScreenshotCapture.cs +++ b/src/Engine.Graphics.Vulkan/ScreenshotCapture.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using Engine.Core; using Silk.NET.Core; using Silk.NET.Vulkan; using SixLabors.ImageSharp; @@ -11,7 +12,7 @@ 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 +public sealed unsafe class ScreenshotCapture : IDisposable, IScreenshotProvider { private readonly VulkanContext _context; private readonly Swapchain _swapchain; @@ -21,6 +22,9 @@ public sealed unsafe class ScreenshotCapture : IDisposable private bool _requested; private string _outputPath = string.Empty; private bool _ready; + private bool _captureToMemory; + private MemoryStream? _memoryOutput; + private TaskCompletionSource? _captureTcs; public ScreenshotCapture(VulkanContext context, Swapchain swapchain) { @@ -29,13 +33,31 @@ public sealed unsafe class ScreenshotCapture : IDisposable } /// - /// Request a screenshot to be captured on the next frame. + /// Request a screenshot to be captured on the next frame and saved to disk. /// public void Request(string outputPath) { _outputPath = outputPath; _requested = true; _ready = false; + _captureToMemory = false; + _memoryOutput = null; + _captureTcs = null; + } + + /// + /// Request a screenshot of the next rendered frame. The returned task completes once the + /// PNG bytes are available. The image is also saved to on disk. + /// + public Task CaptureAsync(string outputPath) + { + _outputPath = outputPath; + _requested = true; + _ready = false; + _captureToMemory = true; + _memoryOutput = new MemoryStream(); + _captureTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return _captureTcs.Task; } /// @@ -97,6 +119,7 @@ public sealed unsafe class ScreenshotCapture : IDisposable /// /// Save the captured pixels to disk. Must be called after the command buffer containing the readback has finished. + /// If the request was made with the PNG bytes are also written to memory and the task is completed. /// public void Save(uint width, uint height, Format format) { @@ -128,67 +151,67 @@ public sealed unsafe class ScreenshotCapture : IDisposable Console.WriteLine($"Screenshot saved: {_outputPath}"); _requested = false; _ready = false; + _captureToMemory = false; + _memoryOutput = null; + _captureTcs = null; } private void SavePixels(void* mappedData, uint width, uint height, uint rowPitch, Format format) { + using var image = CreateImage(mappedData, width, height, rowPitch, format); + image.SaveAsPng(_outputPath); + + if (_captureToMemory && _memoryOutput != null) + { + image.SaveAsPng(_memoryOutput); + var bytes = _memoryOutput.ToArray(); + _captureTcs?.TrySetResult(bytes); + } + } + + private SixLabors.ImageSharp.Image CreateImage(void* mappedData, uint width, uint height, uint rowPitch, Format format) + { + var image = new SixLabors.ImageSharp.Image((int)width, (int)height); + var src = (byte*)mappedData; + if (format == Format.B8G8R8A8Unorm || format == Format.B8G8R8A8Srgb) { - SaveBgra(mappedData, width, height, rowPitch); - return; + 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); + } + } + return image; } if (format == Format.R8G8B8A8Unorm || format == Format.R8G8B8A8Srgb) { - SaveRgba(mappedData, width, height, rowPitch); - return; + 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); + } + } + return image; } + image.Dispose(); 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) diff --git a/src/Engine.Graphics/ShaderLoader.cs b/src/Engine.Graphics.Vulkan/ShaderLoader.cs similarity index 89% rename from src/Engine.Graphics/ShaderLoader.cs rename to src/Engine.Graphics.Vulkan/ShaderLoader.cs index 4c07366..27cc0a2 100644 --- a/src/Engine.Graphics/ShaderLoader.cs +++ b/src/Engine.Graphics.Vulkan/ShaderLoader.cs @@ -12,7 +12,7 @@ public static class ShaderLoader public static byte[] Load(string name) { var assembly = Assembly.GetExecutingAssembly(); - var resourceName = $"Engine.Graphics.Shaders.{name}"; + var resourceName = $"Engine.Graphics.Vulkan.Shaders.{name}"; using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Embedded shader resource not found: {resourceName}"); diff --git a/src/Engine.Graphics/Shaders/fragment.frag b/src/Engine.Graphics.Vulkan/Shaders/fragment.frag similarity index 100% rename from src/Engine.Graphics/Shaders/fragment.frag rename to src/Engine.Graphics.Vulkan/Shaders/fragment.frag diff --git a/src/Engine.Graphics/Shaders/fragment.spv b/src/Engine.Graphics.Vulkan/Shaders/fragment.spv similarity index 100% rename from src/Engine.Graphics/Shaders/fragment.spv rename to src/Engine.Graphics.Vulkan/Shaders/fragment.spv diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics.Vulkan/Shaders/vertex.spv similarity index 100% rename from src/Engine.Graphics/Shaders/vertex.spv rename to src/Engine.Graphics.Vulkan/Shaders/vertex.spv diff --git a/src/Engine.Graphics/Shaders/vertex.vert b/src/Engine.Graphics.Vulkan/Shaders/vertex.vert similarity index 100% rename from src/Engine.Graphics/Shaders/vertex.vert rename to src/Engine.Graphics.Vulkan/Shaders/vertex.vert diff --git a/src/Engine.Graphics/Swapchain.cs b/src/Engine.Graphics.Vulkan/Swapchain.cs similarity index 100% rename from src/Engine.Graphics/Swapchain.cs rename to src/Engine.Graphics.Vulkan/Swapchain.cs diff --git a/src/Engine.Graphics/Texture.cs b/src/Engine.Graphics.Vulkan/Texture.cs similarity index 100% rename from src/Engine.Graphics/Texture.cs rename to src/Engine.Graphics.Vulkan/Texture.cs diff --git a/src/Engine.Graphics/UniformBuffer.cs b/src/Engine.Graphics.Vulkan/UniformBuffer.cs similarity index 100% rename from src/Engine.Graphics/UniformBuffer.cs rename to src/Engine.Graphics.Vulkan/UniformBuffer.cs diff --git a/src/Engine.Graphics/VertexBuffer.cs b/src/Engine.Graphics.Vulkan/VertexBuffer.cs similarity index 100% rename from src/Engine.Graphics/VertexBuffer.cs rename to src/Engine.Graphics.Vulkan/VertexBuffer.cs diff --git a/src/Engine.Graphics.Vulkan/VulkanBackendRegistrar.cs b/src/Engine.Graphics.Vulkan/VulkanBackendRegistrar.cs new file mode 100644 index 0000000..a1b82bb --- /dev/null +++ b/src/Engine.Graphics.Vulkan/VulkanBackendRegistrar.cs @@ -0,0 +1,20 @@ +using Engine.Graphics; + +namespace Engine.Graphics.Vulkan; + +/// +/// Triggers registration of the Vulkan backend with the HAL factory. +/// +public static class VulkanBackendRegistrar +{ + static VulkanBackendRegistrar() + { + RenderBackendFactory.Register("vulkan", (width, height, enableValidation) => new VulkanRenderContext(width, height, enableValidation)); + } + + /// + /// No-op method that forces the static constructor to run. + /// Call this before using . + /// + public static void EnsureRegistered() { } +} diff --git a/src/Engine.Graphics/VulkanContext.cs b/src/Engine.Graphics.Vulkan/VulkanContext.cs similarity index 98% rename from src/Engine.Graphics/VulkanContext.cs rename to src/Engine.Graphics.Vulkan/VulkanContext.cs index 8d06450..e9dc9db 100644 --- a/src/Engine.Graphics/VulkanContext.cs +++ b/src/Engine.Graphics.Vulkan/VulkanContext.cs @@ -33,7 +33,7 @@ public sealed unsafe class VulkanContext : IDisposable public uint PresentFamilyIndex { get; private set; } public CommandPool CommandPool { get; private set; } - public VulkanContext(Sdl3Window window, bool enableValidation = true) + public VulkanContext(IWindow window, bool enableValidation = true) { Vk = Vk.GetApi(); CreateInstance(window, enableValidation); @@ -62,9 +62,9 @@ public sealed unsafe class VulkanContext : IDisposable CommandPool = commandPool; } - private void CreateInstance(Sdl3Window window, bool enableValidation) + private void CreateInstance(IWindow window, bool enableValidation) { - var requiredExtensions = new List(window.GetRequiredInstanceExtensions()); + var requiredExtensions = new List(window.GetRequiredVulkanExtensions()); if (enableValidation) { requiredExtensions.Add("VK_EXT_debug_utils"); @@ -128,7 +128,7 @@ public sealed unsafe class VulkanContext : IDisposable KhrSwapchain = khrSwapchain; } - private void CreateSurface(Sdl3Window window) + private void CreateSurface(IWindow window) { var sdlInstance = (SDL.VkInstance_T*)Instance.Handle; var sdlSurface = (SDL.VkSurfaceKHR_T*)null; diff --git a/src/Engine.Graphics/VulkanPipeline.cs b/src/Engine.Graphics.Vulkan/VulkanPipeline.cs similarity index 100% rename from src/Engine.Graphics/VulkanPipeline.cs rename to src/Engine.Graphics.Vulkan/VulkanPipeline.cs diff --git a/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs b/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs new file mode 100644 index 0000000..201cd70 --- /dev/null +++ b/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs @@ -0,0 +1,35 @@ +using Engine.Core; +using Engine.Graphics; + +namespace Engine.Graphics.Vulkan; + +/// +/// Vulkan implementation of the render HAL context. +/// Creates and owns an for the Vulkan surface. +/// +public sealed class VulkanRenderContext : IRenderContext +{ + private readonly Sdl3Window _window; + private readonly VulkanContext _context; + private readonly Swapchain _swapchain; + + public IWindow Window => _window; + + public VulkanRenderContext(int width, int height, bool enableValidation) + { + _window = new Sdl3Window("Cortex Engine", width, height, vulkanSurface: true); + _context = new VulkanContext(_window, enableValidation); + _swapchain = new Swapchain(_context); + } + + public IRenderer CreateRenderer() => new VulkanRenderer(_context, _swapchain); + + public void Resize(int width, int height) => _swapchain.Recreate(width, height); + + public void Dispose() + { + _swapchain.Dispose(); + _context.Dispose(); + _window.Dispose(); + } +} diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs similarity index 97% rename from src/Engine.Graphics/MeshRenderer.cs rename to src/Engine.Graphics.Vulkan/VulkanRenderer.cs index 93550b5..7c20b50 100644 --- a/src/Engine.Graphics/MeshRenderer.cs +++ b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs @@ -13,10 +13,10 @@ using Engine.Core.Components; namespace Engine.Graphics; /// -/// Renders indexed meshes attached to ECS entities. +/// Vulkan implementation of the ECS world renderer. /// Uses Silk.NET.Vulkan and reads Mesh + Transform components from the ECS world. /// -public sealed unsafe class MeshRenderer : IDisposable +public sealed unsafe class VulkanRenderer : IRenderer { private readonly VulkanContext _context; private readonly Swapchain _swapchain; @@ -89,7 +89,7 @@ public sealed unsafe class MeshRenderer : IDisposable } } - public MeshRenderer(VulkanContext context, Swapchain swapchain) + public VulkanRenderer(VulkanContext context, Swapchain swapchain) { _context = context; _swapchain = swapchain; @@ -287,6 +287,11 @@ public sealed unsafe class MeshRenderer : IDisposable public bool IsScreenshotRequested => _screenshot.IsRequested; + /// + /// Provider that can asynchronously capture the current frame to a PNG byte array. + /// + public IScreenshotProvider ScreenshotProvider => _screenshot; + public void RenderWorld(World world) { var frame = _currentFrame % 2; @@ -340,10 +345,13 @@ public sealed unsafe class MeshRenderer : IDisposable _context.Vk.CmdSetViewport(cmd, 0, 1, &viewport); _context.Vk.CmdSetScissor(cmd, 0, 1, &scissor); - var camera = GetCamera(world); - var view = camera.GetViewMatrix(); - var proj = camera.GetProjectionMatrix(); - var drawCmd = cmd; + var camera = GetCamera(world); + var view = camera.GetViewMatrix(); + var proj = camera.GetProjectionMatrix(); + // Vulkan NDC Y points down; .NET's projection matrix assumes Y up, so flip Y. + proj.M22 = -proj.M22; + var drawCmd = cmd; + var frameConstants = BuildFrameConstants(world, camera); var frameConstantsBytes = new byte[sizeof(FrameConstants)]; diff --git a/src/Engine.Graphics/Engine.Graphics.csproj b/src/Engine.Graphics/Engine.Graphics.csproj index 52c1038..dc79689 100644 --- a/src/Engine.Graphics/Engine.Graphics.csproj +++ b/src/Engine.Graphics/Engine.Graphics.csproj @@ -18,16 +18,9 @@ - - - - - - - diff --git a/src/Engine.Graphics/IRenderContext.cs b/src/Engine.Graphics/IRenderContext.cs new file mode 100644 index 0000000..3ac5fca --- /dev/null +++ b/src/Engine.Graphics/IRenderContext.cs @@ -0,0 +1,27 @@ +using Engine.Core; + +namespace Engine.Graphics; + +/// +/// Abstraction over a graphics backend (Vulkan, Raylib, etc.). +/// Each backend owns its window and surface. The application retrieves +/// the window via for input and event polling. +/// +public interface IRenderContext : IDisposable +{ + /// + /// The window owned by this backend. The application uses this for + /// input polling, resize detection, and close requests. + /// + IWindow Window { get; } + + /// + /// Create a renderer that can draw the ECS world using this backend. + /// + IRenderer CreateRenderer(); + + /// + /// Notify the backend that the output surface has been resized. + /// + void Resize(int width, int height); +} diff --git a/src/Engine.Graphics/IRenderer.cs b/src/Engine.Graphics/IRenderer.cs new file mode 100644 index 0000000..601d6c0 --- /dev/null +++ b/src/Engine.Graphics/IRenderer.cs @@ -0,0 +1,31 @@ +using Engine.Core; +using Flecs.NET.Core; + +namespace Engine.Graphics; + +/// +/// Renders the ECS world and exposes screenshot capture. +/// Implemented by concrete graphics backends. +/// +public interface IRenderer : IDisposable +{ + /// + /// Render one frame of the ECS world and present it. + /// + void RenderWorld(World world); + + /// + /// Request a screenshot of the next rendered frame to be saved to disk. + /// + void RequestScreenshot(string outputPath); + + /// + /// True if a screenshot has been requested but not yet captured. + /// + bool IsScreenshotRequested { get; } + + /// + /// Provider that can asynchronously capture the current frame to PNG bytes. + /// + IScreenshotProvider ScreenshotProvider { get; } +} diff --git a/src/Engine.Graphics/Loaders/GltfLoader.cs b/src/Engine.Graphics/Loaders/GltfLoader.cs index 232885a..58451b9 100644 --- a/src/Engine.Graphics/Loaders/GltfLoader.cs +++ b/src/Engine.Graphics/Loaders/GltfLoader.cs @@ -81,14 +81,5 @@ public static class GltfLoader } private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c) - { - var ab = b - a; - var ac = c - a; - var normal = Vector3.Cross(ab, ac); - if (normal.LengthSquared() > 0.00001f) - normal = Vector3.Normalize(normal); - else - normal = Vector3.UnitY; - return normal; - } + => MeshMath.ComputeFaceNormal(a, b, c); } diff --git a/src/Engine.Graphics/Loaders/ObjLoader.cs b/src/Engine.Graphics/Loaders/ObjLoader.cs index 9112c6e..abcc180 100644 --- a/src/Engine.Graphics/Loaders/ObjLoader.cs +++ b/src/Engine.Graphics/Loaders/ObjLoader.cs @@ -84,14 +84,5 @@ public static class ObjLoader } private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c) - { - var ab = b - a; - var ac = c - a; - var normal = Vector3.Cross(ab, ac); - if (normal.LengthSquared() > 0.00001f) - normal = Vector3.Normalize(normal); - else - normal = Vector3.UnitY; - return normal; - } + => MeshMath.ComputeFaceNormal(a, b, c); } diff --git a/src/Engine.Graphics/MeshMath.cs b/src/Engine.Graphics/MeshMath.cs new file mode 100644 index 0000000..65a97bf --- /dev/null +++ b/src/Engine.Graphics/MeshMath.cs @@ -0,0 +1,25 @@ +using System.Numerics; + +namespace Engine.Graphics; + +/// +/// Shared mesh math utilities used by loaders and procedural generators. +/// +public static class MeshMath +{ + /// + /// Compute a flat face normal from three vertex positions. + /// Falls back to Vector3.UnitY for degenerate (zero-area) triangles. + /// + public static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c) + { + var ab = b - a; + var ac = c - a; + var normal = Vector3.Cross(ab, ac); + if (normal.LengthSquared() > 0.00001f) + normal = Vector3.Normalize(normal); + else + normal = Vector3.UnitY; + return normal; + } +} diff --git a/src/Engine.Graphics/ProceduralMesh.cs b/src/Engine.Graphics/ProceduralMesh.cs new file mode 100644 index 0000000..0486b6a --- /dev/null +++ b/src/Engine.Graphics/ProceduralMesh.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; + +namespace Engine.Graphics; + +/// +/// Procedural mesh generators for common primitive shapes. +/// All methods are pure CPU — no GPU/display dependencies. +/// +public static class ProceduralMesh +{ + /// + /// Generate a UV sphere mesh. + /// + /// Sphere radius. + /// Longitude segments (around the equator). + /// Latitude rings (from pole to pole). + /// Vertex color applied to all vertices. + public static Mesh CreateSphere(float radius, int segments, int rings, Vector3 color) + { + var vertices = new List(); + var indices = new List(); + + for (var ring = 0; ring <= rings; ring++) + { + var phi = MathF.PI * ring / rings; + var sinPhi = MathF.Sin(phi); + var cosPhi = MathF.Cos(phi); + + for (var seg = 0; seg <= segments; seg++) + { + var theta = 2.0f * MathF.PI * seg / segments; + var sinTheta = MathF.Sin(theta); + var cosTheta = MathF.Cos(theta); + + var x = radius * sinPhi * cosTheta; + var y = radius * cosPhi; + var z = radius * sinPhi * sinTheta; + var normal = Vector3.Normalize(new Vector3(x, y, z)); + + vertices.Add(new Vertex(new Vector3(x, y, z), color, normal)); + } + } + + for (var ring = 0; ring < rings; ring++) + { + for (var seg = 0; seg < segments; seg++) + { + var i0 = (uint)(ring * (segments + 1) + seg); + var i1 = i0 + 1; + var i2 = i0 + (uint)(segments + 1); + var i3 = i2 + 1; + + indices.Add(i0); indices.Add(i1); indices.Add(i2); + indices.Add(i1); indices.Add(i3); indices.Add(i2); + } + } + + return new Mesh(vertices.ToArray(), indices.ToArray()); + } + + /// + /// Generate a ground grid mesh at Y=0, consisting of thin quads. + /// + /// Number of grid lines on each side of the origin. + /// Distance between grid lines. + /// Vertex color applied to all vertices. + public static Mesh CreateGrid(int lines, float spacing, Vector3 color) + { + var vertices = new List(); + var indices = new List(); + var extent = lines * spacing; + var normal = Vector3.UnitY; + var halfWidth = 0.02f; + + for (var i = -lines; i <= lines; i++) + { + var offset = i * spacing; + + var baseIndex = (uint)vertices.Count; + vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal)); + vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal)); + vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal)); + vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal)); + indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2); + indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3); + + baseIndex = (uint)vertices.Count; + vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal)); + vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal)); + vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal)); + vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal)); + indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2); + indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3); + } + + return new Mesh(vertices.ToArray(), indices.ToArray()); + } +} diff --git a/src/Engine.Graphics/RenderBackendFactory.cs b/src/Engine.Graphics/RenderBackendFactory.cs new file mode 100644 index 0000000..972070b --- /dev/null +++ b/src/Engine.Graphics/RenderBackendFactory.cs @@ -0,0 +1,36 @@ +using Engine.Core; + +namespace Engine.Graphics; + +/// +/// Factory for creating concrete graphics backends by name. +/// Backends register themselves so the app only depends on the HAL interfaces. +/// Each backend creates and owns its own window. +/// +public static class RenderBackendFactory +{ + private static readonly Dictionary> _registry + = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Register a backend implementation under the given name. + /// The factory receives (width, height, enableValidation) and must create + /// its own window and render context. + /// + public static void Register(string name, Func factory) + { + _registry[name] = factory; + } + + /// + /// Create a backend instance for the given name. + /// The backend assembly must have registered itself before this is called. + /// + public static IRenderContext Create(string name, int width, int height, bool enableValidation) + { + if (!_registry.TryGetValue(name, out var factory)) + throw new NotSupportedException($"No graphics backend named '{name}' is registered."); + + return factory(width, height, enableValidation); + } +} diff --git a/tests/Engine.Tests/AiCommandProcessorTests.cs b/tests/Engine.Tests/AiCommandProcessorTests.cs new file mode 100644 index 0000000..d804562 --- /dev/null +++ b/tests/Engine.Tests/AiCommandProcessorTests.cs @@ -0,0 +1,173 @@ +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; +using Engine.AI; +using Flecs.NET.Core; + +namespace Engine.Tests; + +public class AiCommandProcessorTests +{ + private static (AiCommandProcessor, World) CreateProcessor() + { + var world = World.Create(); + var dummyMesh = new Mesh( + new[] { new Vertex(new Vector3(0, 0, 0), Vector3.One, Vector3.UnitY) }, + new uint[] { 0 }); + var processor = new AiCommandProcessor( + world, + _ => dummyMesh, + _ => { }); + return (processor, world); + } + + [Fact] + public void SpawnModel_Creates_Entity_With_Transform() + { + var (processor, world) = CreateProcessor(); + + var result = processor.Process(""" + { "type": "spawn_model", "name": "TestCube", "modelPath": "fake.obj", "position": [1, 2, 3] } + """); + + Assert.True(result.Success); + var entity = world.Lookup("TestCube"); + Assert.True((ulong)entity.Id != 0); + var transform = entity.Get(); + Assert.Equal(new Vector3(1, 2, 3), transform.Position); + } + + [Fact] + public void SetTransform_Updates_Position() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj" }"""); + + var result = processor.Process(""" + { "type": "set_transform", "name": "Test", "position": [5, 5, 5] } + """); + + Assert.True(result.Success); + var transform = world.Lookup("Test").Get(); + Assert.Equal(new Vector3(5, 5, 5), transform.Position); + } + + [Fact] + public void SetTransform_Partial_Update_Keeps_Other_Fields() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj", "position": [1, 1, 1], "scale": [2, 2, 2] }"""); + + processor.Process("""{ "type": "set_transform", "name": "Test", "position": [9, 9, 9] }"""); + + var transform = world.Lookup("Test").Get(); + Assert.Equal(new Vector3(9, 9, 9), transform.Position); + Assert.Equal(new Vector3(2, 2, 2), transform.Scale); + } + + [Fact] + public void SetMaterial_Updates_Albedo_And_Roughness() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj" }"""); + + var result = processor.Process(""" + { "type": "set_material", "name": "Test", "albedo": [1, 0, 0], "roughness": 0.8 } + """); + + Assert.True(result.Success); + var mat = world.Lookup("Test").Get(); + Assert.Equal(new Vector3(1, 0, 0), mat.Albedo); + Assert.Equal(0.8f, mat.Roughness); + } + + [Fact] + public void DeleteEntity_Removes_Entity() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "ToDelete", "modelPath": "x.obj" }"""); + + var result = processor.Process("""{ "type": "delete_entity", "name": "ToDelete" }"""); + + Assert.True(result.Success); + Assert.True((ulong)world.Lookup("ToDelete").Id == 0); + } + + [Fact] + public void ListEntities_Returns_Names() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "Alpha", "modelPath": "x.obj" }"""); + processor.Process("""{ "type": "spawn_model", "name": "Beta", "modelPath": "x.obj" }"""); + + var result = processor.Process("""{ "type": "list_entities" }"""); + + Assert.True(result.Success); + Assert.Contains("Alpha", result.Message); + Assert.Contains("Beta", result.Message); + } + + [Fact] + public void CaptureScreenshot_Calls_Callback() + { + var world = World.Create(); + var capturedPath = ""; + var processor = new AiCommandProcessor( + world, + _ => new Mesh(new[] { new Vertex(Vector3.Zero, Vector3.One, Vector3.UnitY) }, new uint[] { 0 }), + path => capturedPath = path); + + var result = processor.Process("""{ "type": "capture_screenshot", "outputPath": "test.png" }"""); + + Assert.True(result.Success); + Assert.Equal("test.png", capturedPath); + } + + [Fact] + public void GetWorldState_Returns_Json() + { + var (processor, world) = CreateProcessor(); + processor.Process("""{ "type": "spawn_model", "name": "StateTest", "modelPath": "x.obj", "position": [1, 2, 3] }"""); + + var result = processor.Process("""{ "type": "get_world_state" }"""); + + Assert.True(result.Success); + Assert.Contains("StateTest", result.Message); + Assert.Contains("position", result.Message); + } + + [Fact] + public void SetTransform_On_Nonexistent_Entity_Returns_Error() + { + var (processor, world) = CreateProcessor(); + + var result = processor.Process("""{ "type": "set_transform", "name": "Ghost", "position": [0, 0, 0] }"""); + + Assert.False(result.Success); + } + + [Fact] + public void Invalid_JSON_Returns_Error() + { + var (processor, world) = CreateProcessor(); + + var result = processor.Process("not valid json"); + + Assert.False(result.Success); + } + + [Fact] + public void ProcessBatch_Handles_Multiple_Commands() + { + var (processor, world) = CreateProcessor(); + + var results = processor.ProcessBatch(""" + { "type": "spawn_model", "name": "A", "modelPath": "x.obj" } + { "type": "spawn_model", "name": "B", "modelPath": "x.obj" } + """); + + Assert.Equal(2, results.Length); + Assert.True(results[0].Success); + Assert.True(results[1].Success); + } +} diff --git a/tests/Engine.Tests/CameraControllerTests.cs b/tests/Engine.Tests/CameraControllerTests.cs new file mode 100644 index 0000000..1f58fb1 --- /dev/null +++ b/tests/Engine.Tests/CameraControllerTests.cs @@ -0,0 +1,235 @@ +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; +using Flecs.NET.Core; + +namespace Engine.Tests; + +/// +/// Test double for IInputState — set properties before calling controller.Update(). +/// +internal sealed class FakeInputState : IInputState +{ + private readonly HashSet _down = new(); + private readonly HashSet _pressed = new(); + + public int MouseX { get; set; } + public int MouseY { get; set; } + public bool MouseLeft { get; set; } + public bool MouseRight { get; set; } + public bool MouseMiddle { get; set; } + public float MouseWheelDelta { get; set; } + + public void SetKeyDown(Key key) => _down.Add(key); + public void SetKeyPressed(Key key) + { + _down.Add(key); + _pressed.Add(key); + } + + public bool IsKeyDown(Key key) => _down.Contains(key); + public bool IsKeyPressed(Key key) => _pressed.Contains(key); + public bool IsKeyReleased(Key key) => false; + + public void BeginFrame() + { + _pressed.Clear(); + MouseWheelDelta = 0; + } +} + +public class FreeFlyCameraControllerTests +{ + private static (FreeFlyCameraController, Entity) CreateController(float yaw = 0f, float pitch = 0f) + { + var world = World.Create(); + var pos = new Vector3(0, 1, -10); + var dir = new Vector3( + MathF.Cos(pitch) * MathF.Sin(yaw), + -MathF.Sin(pitch), + MathF.Cos(pitch) * MathF.Cos(yaw)); + var cam = new Camera(pos, pos + dir, Vector3.UnitY); + var entity = world.Entity("TestCamera").Set(cam); + return (new FreeFlyCameraController(entity), entity); + } + + [Fact] + public void W_Moves_Forward() + { + var (controller, entity) = CreateController(yaw: 0f); + var input = new FakeInputState(); + input.SetKeyDown(Key.W); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.True(cam.Position.Z > -10f); + } + + [Fact] + public void S_Moves_Backward() + { + var (controller, entity) = CreateController(yaw: 0f); + var input = new FakeInputState(); + input.SetKeyDown(Key.S); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.True(cam.Position.Z < -10f); + } + + [Fact] + public void Shift_Boosts_Speed() + { + var (controller, entity) = CreateController(yaw: 0f); + var inputNormal = new FakeInputState(); + inputNormal.SetKeyDown(Key.W); + controller.Update(inputNormal, 1.0f); + var normalPos = entity.Get().Position; + + var (controller2, entity2) = CreateController(yaw: 0f); + var inputBoost = new FakeInputState(); + inputBoost.SetKeyDown(Key.W); + inputBoost.SetKeyDown(Key.LeftShift); + controller2.Update(inputBoost, 1.0f); + var boostPos = entity2.Get().Position; + + Assert.True(boostPos.Z > normalPos.Z); + } + + [Fact] + public void Q_Moves_Down() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.SetKeyDown(Key.Q); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.True(cam.Position.Y < 1f); + } + + [Fact] + public void E_Moves_Up() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.SetKeyDown(Key.E); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.True(cam.Position.Y > 1f); + } + + [Fact] + public void Mouse_Wheel_Adjusts_Speed() + { + var (controller, entity) = CreateController(yaw: 0f); + var input = new FakeInputState(); + input.SetKeyDown(Key.W); + input.MouseWheelDelta = 10f; + + controller.Update(input, 0.1f); + + input.BeginFrame(); + input.SetKeyDown(Key.W); + controller.Update(input, 1.0f); + var cam = entity.Get(); + + // With boosted speed, movement should be much larger than default 3 units + Assert.True(cam.Position.Z > -7f); + } + + [Fact] + public void Target_Follows_Position() + { + var (controller, entity) = CreateController(yaw: 0f); + var input = new FakeInputState(); + input.SetKeyDown(Key.W); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + var dir = Vector3.Normalize(cam.Target - cam.Position); + Assert.Equal(0f, dir.X, 0.01f); + Assert.Equal(0f, dir.Y, 0.01f); + } +} + +public class OrbitCameraControllerTests +{ + private static (OrbitCameraController, Entity) CreateController() + { + var world = World.Create(); + var target = new Vector3(0, 0.5f, 0); + var pos = new Vector3(0, 0.5f, -10); + var cam = new Camera(pos, target, Vector3.UnitY); + var entity = world.Entity("TestOrbitCamera").Set(cam); + return (new OrbitCameraController(entity, target), entity); + } + + [Fact] + public void W_Moves_Target_Forward() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.SetKeyDown(Key.W); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.True(cam.Target.Z > 0f); + } + + [Fact] + public void Zoom_Decreases_Distance() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.MouseWheelDelta = 1f; + + controller.Update(input, 0.1f); + + var cam = entity.Get(); + var dist = Vector3.Distance(cam.Position, cam.Target); + Assert.True(dist < 10f); + } + + [Fact] + public void Camera_Position_Orbits_Target() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.MouseRight = true; + input.MouseX = 100; + input.MouseY = 100; + + controller.Update(input, 0.1f); + + input.BeginFrame(); + input.MouseRight = true; + input.MouseX = 200; + input.MouseY = 100; + controller.Update(input, 0.1f); + + var cam = entity.Get(); + var dist = Vector3.Distance(cam.Position, cam.Target); + Assert.Equal(10f, dist, 1f); + } + + [Fact] + public void Target_Stays_At_Ground_Level_With_WASD() + { + var (controller, entity) = CreateController(); + var input = new FakeInputState(); + input.SetKeyDown(Key.W); + + controller.Update(input, 1.0f); + + var cam = entity.Get(); + Assert.Equal(0.5f, cam.Target.Y, 0.001f); + } +} diff --git a/tests/Engine.Tests/CameraTests.cs b/tests/Engine.Tests/CameraTests.cs new file mode 100644 index 0000000..c38cbbc --- /dev/null +++ b/tests/Engine.Tests/CameraTests.cs @@ -0,0 +1,65 @@ +using System.Numerics; +using Engine.Core.Components; + +namespace Engine.Tests; + +public class CameraTests +{ + [Fact] + public void View_Matrix_Transforms_Position_To_Origin() + { + var cam = new Camera( + new Vector3(0, 0, 10), + new Vector3(0, 0, 0), + Vector3.UnitY, + MathF.PI / 4f, + 16f / 9f, + 0.1f, + 100f); + + var view = cam.GetViewMatrix(); + var originInCameraSpace = Vector3.Transform(new Vector3(0, 0, 0), view); + + Assert.Equal(0f, originInCameraSpace.X, 0.001f); + Assert.Equal(0f, originInCameraSpace.Y, 0.001f); + Assert.Equal(-10f, originInCameraSpace.Z, 0.001f); + } + + [Fact] + public void Projection_Matrix_Has_Correct_Aspect_Ratio() + { + var cam = new Camera( + Vector3.Zero, + Vector3.UnitZ, + Vector3.UnitY, + MathF.PI / 4f, + 16f / 9f, + 0.1f, + 100f); + + var proj = cam.GetProjectionMatrix(); + + Assert.True(proj.M11 > 0); + Assert.True(proj.M22 > 0); + Assert.Equal(0f, proj.M41, 0.001f); + } + + [Fact] + public void Default_Material_Has_Expected_Values() + { + var mat = Material.Default; + + Assert.Equal(0.5f, mat.Roughness); + Assert.Equal(0.0f, mat.Metallic); + Assert.False(mat.HasTexture); + } + + [Fact] + public void Material_With_Texture_Path_Has_Texture_Flag() + { + var mat = new Material(texturePath: "Content/test.png"); + + Assert.True(mat.HasTexture); + Assert.Equal("Content/test.png", mat.TexturePath); + } +} diff --git a/tests/Engine.Tests/Engine.Tests.csproj b/tests/Engine.Tests/Engine.Tests.csproj new file mode 100644 index 0000000..978507e --- /dev/null +++ b/tests/Engine.Tests/Engine.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Engine.Tests/MeshAndLightTests.cs b/tests/Engine.Tests/MeshAndLightTests.cs new file mode 100644 index 0000000..1e4add4 --- /dev/null +++ b/tests/Engine.Tests/MeshAndLightTests.cs @@ -0,0 +1,40 @@ +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; + +namespace Engine.Tests; + +public class MeshAndLightTests +{ + [Fact] + public void Mesh_Stores_Vertices_And_Indices() + { + var vertices = new[] + { + new Vertex(new Vector3(0, 0, 0), Vector3.One, Vector3.UnitY), + new Vertex(new Vector3(1, 0, 0), Vector3.One, Vector3.UnitY), + new Vertex(new Vector3(1, 1, 0), Vector3.One, Vector3.UnitY), + }; + var indices = new uint[] { 0, 1, 2 }; + var mesh = new Mesh(vertices, indices); + + Assert.Equal(3, mesh.Vertices.Length); + Assert.Equal(3, mesh.Indices.Length); + } + + [Fact] + public void Light_Direction_Is_Normalized() + { + var light = new Light(new Vector3(0, 2, 0), Vector3.One, 1.0f); + + Assert.Equal(1f, light.Direction.Length(), 0.001f); + } + + [Fact] + public void Light_With_Zero_Direction_Defaults_To_UnitY() + { + var light = new Light(Vector3.Zero, Vector3.One, 1.0f); + + Assert.Equal(Vector3.UnitY, light.Direction); + } +} diff --git a/tests/Engine.Tests/MeshMathAndProceduralTests.cs b/tests/Engine.Tests/MeshMathAndProceduralTests.cs new file mode 100644 index 0000000..2b33d78 --- /dev/null +++ b/tests/Engine.Tests/MeshMathAndProceduralTests.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using Engine.Core; +using Engine.Graphics; + +namespace Engine.Tests; + +public class MeshMathTests +{ + [Fact] + public void Computes_Normal_For_CCW_Triangle() + { + var n = MeshMath.ComputeFaceNormal( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(0, 1, 0)); + + Assert.Equal(0f, n.X, 0.001f); + Assert.Equal(0f, n.Y, 0.001f); + Assert.Equal(1f, n.Z, 0.001f); + } + + [Fact] + public void Normal_Is_Unit_Length() + { + var n = MeshMath.ComputeFaceNormal( + new Vector3(0, 0, 0), + new Vector3(3, 0, 0), + new Vector3(0, 4, 0)); + + Assert.Equal(1f, n.Length(), 0.001f); + } + + [Fact] + public void Degenerate_Triangle_Falls_Back_To_UnitY() + { + var n = MeshMath.ComputeFaceNormal( + new Vector3(0, 0, 0), + new Vector3(1, 0, 0), + new Vector3(2, 0, 0)); + + Assert.Equal(Vector3.UnitY, n); + } +} + +public class ProceduralMeshTests +{ + [Fact] + public void Sphere_Has_Correct_Vertex_Count() + { + var mesh = ProceduralMesh.CreateSphere(1f, 16, 8, Vector3.One); + + Assert.Equal((8 + 1) * (16 + 1), mesh.Vertices.Length); + } + + [Fact] + public void Sphere_Has_Correct_Index_Count() + { + var mesh = ProceduralMesh.CreateSphere(1f, 16, 8, Vector3.One); + + Assert.Equal(8 * 16 * 6, mesh.Indices.Length); + } + + [Fact] + public void Sphere_Vertices_Lie_On_Surface() + { + const float radius = 2.5f; + var mesh = ProceduralMesh.CreateSphere(radius, 8, 4, Vector3.One); + + foreach (var v in mesh.Vertices) + Assert.Equal(radius, v.Position.Length(), 0.001f); + } + + [Fact] + public void Sphere_Normals_Are_Unit_Length() + { + var mesh = ProceduralMesh.CreateSphere(1f, 8, 4, Vector3.One); + + foreach (var v in mesh.Vertices) + Assert.Equal(1f, v.Normal.Length(), 0.001f); + } + + [Fact] + public void Sphere_Top_Pole_At_Positive_Y() + { + var mesh = ProceduralMesh.CreateSphere(1f, 8, 4, Vector3.One); + + Assert.Equal(1f, mesh.Vertices[0].Position.Y, 0.001f); + Assert.Equal(0f, mesh.Vertices[0].Position.X, 0.001f); + Assert.Equal(0f, mesh.Vertices[0].Position.Z, 0.001f); + } + + [Fact] + public void Grid_Has_Correct_Vertex_Count() + { + var mesh = ProceduralMesh.CreateGrid(5, 1f, Vector3.One); + + var expectedLines = 2 * 5 + 1; + Assert.Equal(expectedLines * 4 * 2, mesh.Vertices.Length); + } + + [Fact] + public void Grid_All_Normals_Point_Up() + { + var mesh = ProceduralMesh.CreateGrid(3, 1f, Vector3.One); + + foreach (var v in mesh.Vertices) + Assert.Equal(Vector3.UnitY, v.Normal); + } + + [Fact] + public void Grid_Extent_Matches_Lines_And_Spacing() + { + var mesh = ProceduralMesh.CreateGrid(10, 2f, Vector3.One); + + var maxPos = 10f * 2f; + Assert.True(mesh.Vertices.Any(v => v.Position.X <= -maxPos)); + Assert.True(mesh.Vertices.Any(v => v.Position.X >= maxPos)); + } +} diff --git a/tests/Engine.Tests/ObjLoaderTests.cs b/tests/Engine.Tests/ObjLoaderTests.cs new file mode 100644 index 0000000..7d258fb --- /dev/null +++ b/tests/Engine.Tests/ObjLoaderTests.cs @@ -0,0 +1,146 @@ +using System.IO; +using System.Numerics; +using Engine.Core; +using Engine.Core.Components; +using Engine.Graphics.Loaders; + +namespace Engine.Tests; + +public class ObjLoaderTests +{ + private static readonly string TempDir = Path.Combine(Path.GetTempPath(), "CortexEngineTests"); + private static string WriteTempObj(string content) + { + Directory.CreateDirectory(TempDir); + var path = Path.Combine(TempDir, $"test_{Guid.NewGuid():N}.obj"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void Loads_Single_Triangle() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 0 1 0 + f 1 2 3 + """); + + var mesh = ObjLoader.Load(path, new Vector3(1, 1, 1)); + + Assert.Equal(3, mesh.Vertices.Length); + Assert.Equal(3, mesh.Indices.Length); + } + + [Fact] + public void Triangulates_Quad_As_Fan() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 1 1 0 + v 0 1 0 + f 1 2 3 4 + """); + + var mesh = ObjLoader.Load(path); + + Assert.Equal(6, mesh.Vertices.Length); + Assert.Equal(6, mesh.Indices.Length); + } + + [Fact] + public void Parses_Face_With_Texcoord_Format() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 0 1 0 + vt 0 0 + vt 1 0 + vt 0 1 + f 1/1 2/2 3/3 + """); + + var mesh = ObjLoader.Load(path); + + Assert.Equal(3, mesh.Vertices.Length); + } + + [Fact] + public void Parses_Face_With_Normal_Format() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 0 1 0 + vn 0 0 1 + f 1//1 2//1 3//1 + """); + + var mesh = ObjLoader.Load(path); + + Assert.Equal(3, mesh.Vertices.Length); + } + + [Fact] + public void Computes_Face_Normal_For_Triangle() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 0 1 0 + f 1 2 3 + """); + + var mesh = ObjLoader.Load(path); + + var normal = mesh.Vertices[0].Normal; + Assert.Equal(0f, normal.X, 0.001f); + Assert.Equal(0f, normal.Y, 0.001f); + Assert.Equal(1f, normal.Z, 0.001f); + } + + [Fact] + public void Skips_Comments_And_Blank_Lines() + { + var path = WriteTempObj(""" + # This is a comment + + v 0 0 0 + # Another comment + v 1 0 0 + v 0 1 0 + + f 1 2 3 + """); + + var mesh = ObjLoader.Load(path); + + Assert.Equal(3, mesh.Vertices.Length); + } + + [Fact] + public void Throws_On_Empty_File() + { + var path = WriteTempObj("# just a comment\n"); + + Assert.Throws(() => ObjLoader.Load(path)); + } + + [Fact] + public void Default_Color_When_Not_Specified() + { + var path = WriteTempObj(""" + v 0 0 0 + v 1 0 0 + v 0 1 0 + f 1 2 3 + """); + + var mesh = ObjLoader.Load(path); + + Assert.Equal(0.7f, mesh.Vertices[0].Color.X, 0.001f); + } +} diff --git a/tests/Engine.Tests/RenderBackendFactoryTests.cs b/tests/Engine.Tests/RenderBackendFactoryTests.cs new file mode 100644 index 0000000..5d56ca4 --- /dev/null +++ b/tests/Engine.Tests/RenderBackendFactoryTests.cs @@ -0,0 +1,41 @@ +using Engine.Core; +using Engine.Graphics; + +namespace Engine.Tests; + +public class RenderBackendFactoryTests +{ + private sealed class FakeRenderContext : IRenderContext + { + public IWindow Window => null!; + public IRenderer CreateRenderer() => null!; + public void Resize(int width, int height) { } + public void Dispose() { } + } + + [Fact] + public void Create_Returns_Registered_Backend() + { + RenderBackendFactory.Register("fake-test", (_, _, _) => new FakeRenderContext()); + + var ctx = RenderBackendFactory.Create("fake-test", 800, 600, false); + + Assert.IsType(ctx); + } + + [Fact] + public void Create_Throws_For_Unknown_Backend() + { + Assert.Throws(() => + RenderBackendFactory.Create("nonexistent", 800, 600, false)); + } + + [Fact] + public void Register_Is_Case_Insensitive() + { + RenderBackendFactory.Register("CaseTest", (_, _, _) => new FakeRenderContext()); + + var ctx = RenderBackendFactory.Create("casetest", 1, 1, false); + Assert.IsType(ctx); + } +} diff --git a/tests/Engine.Tests/TimingTests.cs b/tests/Engine.Tests/TimingTests.cs new file mode 100644 index 0000000..a8aafdf --- /dev/null +++ b/tests/Engine.Tests/TimingTests.cs @@ -0,0 +1,65 @@ +using Engine.Core; + +namespace Engine.Tests; + +public class TimingTests +{ + [Fact] + public void Tick_Updates_DeltaTime() + { + var timing = new Timing(); + + timing.Tick(); + + Assert.True(timing.DeltaTime > 0); + } + + [Fact] + public void Tick_Updates_TotalTime() + { + var timing = new Timing(); + + timing.Tick(); + var time1 = timing.TotalTime; + timing.Tick(); + var time2 = timing.TotalTime; + + Assert.True(time2 > time1); + } + + [Fact] + public void ConsumeFixedStep_Returns_True_When_Accumulator_Exceeds_Step() + { + var timing = new Timing { FixedTimeStep = 0.001 }; + + timing.Tick(); + Thread.Sleep(5); + timing.Tick(); + + Assert.True(timing.ConsumeFixedStep()); + } + + [Fact] + public void ConsumeFixedStep_Returns_False_When_Below_Step() + { + var timing = new Timing { FixedTimeStep = 100.0 }; + + timing.Tick(); + + Assert.False(timing.ConsumeFixedStep()); + } + + [Fact] + public void ResetAccumulator_Clamps_To_Max() + { + var timing = new Timing { FixedTimeStep = 0.1 }; + + // Simulate a huge delta by ticking many times without consuming + for (var i = 0; i < 1000; i++) + timing.Tick(); + + timing.ResetAccumulator(); + + Assert.True(timing.FixedTimeAccumulator <= timing.FixedTimeStep * 5 + 0.001f); + } +} diff --git a/tests/Engine.Tests/TransformTests.cs b/tests/Engine.Tests/TransformTests.cs new file mode 100644 index 0000000..572d76a --- /dev/null +++ b/tests/Engine.Tests/TransformTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using Engine.Core.Components; + +namespace Engine.Tests; + +public class TransformTests +{ + [Fact] + public void Identity_Transform_Produces_Identity_Matrix() + { + var t = new Transform(Vector3.Zero, Quaternion.Identity, Vector3.One); + var m = t.GetMatrix(); + + Assert.Equal(Matrix4x4.Identity, m); + } + + [Fact] + public void Translation_Appears_In_Matrix() + { + var t = new Transform(new Vector3(1, 2, 3), Quaternion.Identity, Vector3.One); + var m = t.GetMatrix(); + + Assert.Equal(1f, m.M41); + Assert.Equal(2f, m.M42); + Assert.Equal(3f, m.M43); + } + + [Fact] + public void Scale_Affects_Matrix_Diagonal() + { + var t = new Transform(Vector3.Zero, Quaternion.Identity, new Vector3(2, 3, 4)); + var m = t.GetMatrix(); + + Assert.Equal(2f, m.M11); + Assert.Equal(3f, m.M22); + Assert.Equal(4f, m.M33); + } + + [Fact] + public void Rotation_Around_Y_Rotates_X_Axis() + { + var angle = MathF.PI / 2f; + var rot = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angle); + var t = new Transform(Vector3.Zero, rot, Vector3.One); + var m = t.GetMatrix(); + + var xAxis = new Vector3(m.M11, m.M21, m.M31); + Assert.Equal(0f, xAxis.X, 0.001f); + Assert.Equal(0f, xAxis.Y, 0.001f); + Assert.Equal(1f, xAxis.Z, 0.001f); + } +}