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
This commit is contained in:
emil28092005
2026-06-17 13:49:12 +03:00
parent 61f8c7065e
commit fb6e26a268
62 changed files with 22259 additions and 395 deletions
+66
View File
@@ -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
+22
View File
@@ -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
+169 -93
View File
@@ -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 <port>` — start the HTTP MCP server on `http://localhost:<port>/` (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 <port>` | 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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+19201
View File
File diff suppressed because it is too large Load Diff
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Run the Cortex Engine.
# Examples:
# ./scripts/run.sh # run with defaults
# ./scripts/run.sh --mcp-port 5000 # run with MCP HTTP server
# ./scripts/run.sh --camera-tour # capture screenshots and exit
# ./scripts/run.sh --mcp-stdio # run headless stdio MCP server
ENGINE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export DISPLAY="${DISPLAY:-:0}"
export DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}"
export PATH="$DOTNET_ROOT:$PATH"
cd "$ENGINE_DIR"
exec dotnet run --project "$ENGINE_DIR/src/CortexEngine.App/CortexEngine.App.csproj" -- "$@"
@@ -22,6 +22,8 @@
<ItemGroup>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Graphics.Raylib\Engine.Graphics.Raylib.csproj" />
<ProjectReference Include="..\Engine.Graphics.Vulkan\Engine.Graphics.Vulkan.csproj" />
<ProjectReference Include="..\Engine.AI\Engine.AI.csproj" />
</ItemGroup>
+243 -132
View File
@@ -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>();
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<Transform>();
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<Transform>();
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<Vertex>();
var indices = new List<uint>();
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<Rgba32>(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>();
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}°");
}
}
+51 -3
View File
@@ -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<AiCommandResult> tcs)> _queue = new();
private readonly AiCommandProcessor _processor;
private readonly IScreenshotProvider _screenshot;
private readonly JsonSerializerOptions _jsonOptions;
private (TaskCompletionSource<AiCommandResult> tcs, Task<byte[]> 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<AiCommand>(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;
}
/// <summary>
/// Completes any pending screenshot requests that have finished rendering.
/// Must be called on the main engine thread after the frame has been presented.
/// </summary>
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}"));
}
}
/// <summary>
/// Number of commands waiting to be processed.
/// </summary>
+1 -1
View File
@@ -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<string> CaptureScreenshot([Description("Optional output file path (default: screenshot_<timestamp>.png)")] string? outputPath = null)
{
var cmd = new CaptureScreenshotCommand { OutputPath = outputPath };
+1 -1
View File
@@ -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()),
+3 -1
View File
@@ -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;
}
+10 -11
View File
@@ -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;
+2 -2
View File
@@ -1,10 +1,10 @@
namespace Engine.Core;
/// <summary>
/// Common interface for camera controllers (orbit, free-fly, etc.).
/// Common interface for camera controllers (free-fly, etc.).
/// </summary>
public interface ICameraController
{
string Name { get; }
void Update(InputMapping input, float deltaTime);
void Update(IInputState input, float deltaTime);
}
+25
View File
@@ -0,0 +1,25 @@
namespace Engine.Core;
/// <summary>
/// Read-only query interface for keyboard and mouse input state.
/// Implemented by each windowing backend (SDL3, Raylib, etc.).
/// </summary>
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);
/// <summary>
/// Called at the start of each frame to clear per-frame edge state
/// (key-pressed, key-released, mouse-wheel delta).
/// </summary>
void BeginFrame();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Engine.Core;
/// <summary>
/// Provider that can capture the current rendered frame to a PNG byte array.
/// Implemented by the graphics subsystem and consumed by the AI layer.
/// </summary>
public interface IScreenshotProvider
{
/// <summary>
/// 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 <paramref name="outputPath"/> on disk.
/// </summary>
Task<byte[]> CaptureAsync(string outputPath);
}
+41
View File
@@ -0,0 +1,41 @@
namespace Engine.Core;
/// <summary>
/// Backend-agnostic window abstraction.
/// Each render backend (Vulkan+SDL3, Raylib+GLFW, etc.) owns and creates its own window.
/// The application retrieves the window from <see cref="Graphics.IRenderContext.Window"/>.
/// </summary>
public interface IWindow : IDisposable
{
int Width { get; }
int Height { get; }
bool ShouldClose { get; }
/// <summary>
/// Read-only input state populated during <see cref="PumpEvents"/>.
/// </summary>
IInputState Input { get; }
/// <summary>
/// Poll window events and update <see cref="Input"/>. Called once per frame
/// before reading input state or rendering.
/// </summary>
void PumpEvents();
/// <summary>
/// Request the window to close at the next frame boundary.
/// </summary>
void Close();
/// <summary>
/// Native window handle (e.g. <c>SDL_Window*</c>). Used by backends that need
/// the raw OS handle for surface creation. Returns 0 if not applicable.
/// </summary>
nint Handle { get; }
/// <summary>
/// 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.
/// </summary>
string[] GetRequiredVulkanExtensions();
}
+104 -14
View File
@@ -4,14 +4,14 @@ using SDL;
namespace Engine.Core;
/// <summary>
/// Minimal snapshot of current input state.
/// Populated by polling SDL events once per frame.
/// SDL3-backed implementation of <see cref="IInputState"/>.
/// Populated by polling SDL events via <see cref="ProcessEvent"/> once per frame.
/// </summary>
public sealed class InputMapping
public sealed class InputMapping : IInputState
{
private readonly HashSet<SDL_Keycode> _keysPressed = new();
private readonly HashSet<SDL_Keycode> _keysDown = new();
private readonly HashSet<SDL_Keycode> _keysReleased = new();
private readonly HashSet<Key> _keysPressed = new();
private readonly HashSet<Key> _keysDown = new();
private readonly HashSet<Key> _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
}
}
}
/// <summary>
/// Maps SDL3 keycodes to the backend-agnostic <see cref="Key"/> enum.
/// </summary>
internal static class SdlKeyMap
{
private static readonly Dictionary<SDL_Keycode, Key> _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;
}
+39
View File
@@ -0,0 +1,39 @@
namespace Engine.Core;
/// <summary>
/// Backend-agnostic key codes used by <see cref="IInputState"/> and camera controllers.
/// Each windowing backend (SDL3, Raylib, etc.) maps its native key codes to these values.
/// </summary>
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,
}
+66 -29
View File
@@ -1,68 +1,102 @@
using System;
using System.Numerics;
using Flecs.NET.Core;
using Engine.Core.Components;
namespace Engine.Core;
/// <summary>
/// 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.
/// </summary>
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<Components.Camera>();
_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<Camera>();
_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<Camera>();
var camera = _cameraEntity.Get<Components.Camera>();
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);
}
}
+17 -21
View File
@@ -6,51 +6,51 @@ using SDL;
namespace Engine.Core;
/// <summary>
/// A thin, disposable wrapper around an SDL3 window.
/// Handles creation, Vulkan surface discovery, and event polling.
/// SDL3-backed implementation of <see cref="IWindow"/>.
/// Creates a native window, polls SDL events, and exposes input via <see cref="Input"/>.
/// </summary>
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;
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>false</IsAotCompatible>
<AssemblyName>Engine.Graphics.Raylib</AssemblyName>
<RootNamespace>Engine.Graphics.Raylib</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>false</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Raylib-cs" Version="8.0.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
using Engine.Graphics;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Triggers registration of the Raylib backend with the HAL factory.
/// </summary>
public static class RaylibBackendRegistrar
{
static RaylibBackendRegistrar()
{
RenderBackendFactory.Register("raylib", (width, height, _) => new RaylibRenderContext(width, height));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IInputState"/>.
/// Queries Raylib's input functions directly each frame.
/// </summary>
public sealed class RaylibInputState : IInputState
{
private static readonly Key[] _allKeys = (Key[])Enum.GetValues(typeof(Key));
private readonly HashSet<Key> _keysDown = new();
private readonly HashSet<Key> _keysPressed = new();
private readonly HashSet<Key> _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;
}
/// <summary>
/// Poll Raylib input and update edge state. Called by <see cref="RaylibWindow.PumpEvents"/>.
/// </summary>
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,
};
}
@@ -0,0 +1,28 @@
using Engine.Core;
using Engine.Graphics;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib implementation of the render HAL context.
/// Creates and owns a <see cref="RaylibWindow"/> (GLFW-based).
/// No SDL3 dependency — the Raylib window handles both rendering and input.
/// </summary>
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();
}
@@ -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;
/// <summary>
/// Raylib implementation of the ECS world renderer.
/// Renders Mesh + Transform + Material entities with up to four directional lights.
/// </summary>
public sealed class RaylibRenderer : IRenderer
{
private readonly Shader _shader;
private readonly Dictionary<Entity, Raylib_cs.Model> _modelCache = new();
private readonly Dictionary<string, Texture2D> _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<EngineMaterial>() ? e.Get<EngineMaterial>() : 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<byte[]> CaptureAsync(string outputPath)
{
var tcs = new TaskCompletionSource<byte[]>(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<byte[]>? Tcs);
private sealed class RaylibScreenshotProvider : IScreenshotProvider
{
private readonly RaylibRenderer _renderer;
public RaylibScreenshotProvider(RaylibRenderer renderer)
{
_renderer = renderer;
}
public Task<byte[]> CaptureAsync(string outputPath) => _renderer.CaptureAsync(outputPath);
}
}
@@ -0,0 +1,54 @@
using System;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IWindow"/>.
/// Wraps Raylib's GLFW window creation, event polling, and input.
/// </summary>
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<string>();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Raylib.CloseWindow();
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
<AssemblyName>Engine.Graphics.Vulkan</AssemblyName>
<RootNamespace>Engine.Graphics.Vulkan</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Vulkan" Version="2.21.0" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.21.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
@@ -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.
/// </summary>
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<byte[]>? _captureTcs;
public ScreenshotCapture(VulkanContext context, Swapchain swapchain)
{
@@ -29,13 +33,31 @@ public sealed unsafe class ScreenshotCapture : IDisposable
}
/// <summary>
/// Request a screenshot to be captured on the next frame.
/// Request a screenshot to be captured on the next frame and saved to disk.
/// </summary>
public void Request(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = false;
_memoryOutput = null;
_captureTcs = null;
}
/// <summary>
/// Request a screenshot of the next rendered frame. The returned task completes once the
/// PNG bytes are available. The image is also saved to <paramref name="outputPath"/> on disk.
/// </summary>
public Task<byte[]> CaptureAsync(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = true;
_memoryOutput = new MemoryStream();
_captureTcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
return _captureTcs.Task;
}
/// <summary>
@@ -97,6 +119,7 @@ public sealed unsafe class ScreenshotCapture : IDisposable
/// <summary>
/// Save the captured pixels to disk. Must be called after the command buffer containing the readback has finished.
/// If the request was made with <see cref="CaptureAsync"/> the PNG bytes are also written to memory and the task is completed.
/// </summary>
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<Rgba32> CreateImage(void* mappedData, uint width, uint height, uint rowPitch, Format format)
{
var image = new SixLabors.ImageSharp.Image<Rgba32>((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<Rgba32>((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<Rgba32>((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)
@@ -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}");
@@ -0,0 +1,20 @@
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Triggers registration of the Vulkan backend with the HAL factory.
/// </summary>
public static class VulkanBackendRegistrar
{
static VulkanBackendRegistrar()
{
RenderBackendFactory.Register("vulkan", (width, height, enableValidation) => new VulkanRenderContext(width, height, enableValidation));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
@@ -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<string>(window.GetRequiredInstanceExtensions());
var requiredExtensions = new List<string>(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;
@@ -0,0 +1,35 @@
using Engine.Core;
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Vulkan implementation of the render HAL context.
/// Creates and owns an <see cref="Sdl3Window"/> for the Vulkan surface.
/// </summary>
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();
}
}
@@ -13,10 +13,10 @@ using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Provider that can asynchronously capture the current frame to a PNG byte array.
/// </summary>
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)];
@@ -18,16 +18,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Vulkan" Version="2.21.0" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.21.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
<ItemGroup>
+27
View File
@@ -0,0 +1,27 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// Abstraction over a graphics backend (Vulkan, Raylib, etc.).
/// Each backend owns its window and surface. The application retrieves
/// the window via <see cref="Window"/> for input and event polling.
/// </summary>
public interface IRenderContext : IDisposable
{
/// <summary>
/// The window owned by this backend. The application uses this for
/// input polling, resize detection, and close requests.
/// </summary>
IWindow Window { get; }
/// <summary>
/// Create a renderer that can draw the ECS world using this backend.
/// </summary>
IRenderer CreateRenderer();
/// <summary>
/// Notify the backend that the output surface has been resized.
/// </summary>
void Resize(int width, int height);
}
+31
View File
@@ -0,0 +1,31 @@
using Engine.Core;
using Flecs.NET.Core;
namespace Engine.Graphics;
/// <summary>
/// Renders the ECS world and exposes screenshot capture.
/// Implemented by concrete graphics backends.
/// </summary>
public interface IRenderer : IDisposable
{
/// <summary>
/// Render one frame of the ECS world and present it.
/// </summary>
void RenderWorld(World world);
/// <summary>
/// Request a screenshot of the next rendered frame to be saved to disk.
/// </summary>
void RequestScreenshot(string outputPath);
/// <summary>
/// True if a screenshot has been requested but not yet captured.
/// </summary>
bool IsScreenshotRequested { get; }
/// <summary>
/// Provider that can asynchronously capture the current frame to PNG bytes.
/// </summary>
IScreenshotProvider ScreenshotProvider { get; }
}
+1 -10
View File
@@ -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);
}
+1 -10
View File
@@ -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);
}
+25
View File
@@ -0,0 +1,25 @@
using System.Numerics;
namespace Engine.Graphics;
/// <summary>
/// Shared mesh math utilities used by loaders and procedural generators.
/// </summary>
public static class MeshMath
{
/// <summary>
/// Compute a flat face normal from three vertex positions.
/// Falls back to Vector3.UnitY for degenerate (zero-area) triangles.
/// </summary>
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;
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Procedural mesh generators for common primitive shapes.
/// All methods are pure CPU — no GPU/display dependencies.
/// </summary>
public static class ProceduralMesh
{
/// <summary>
/// Generate a UV sphere mesh.
/// </summary>
/// <param name="radius">Sphere radius.</param>
/// <param name="segments">Longitude segments (around the equator).</param>
/// <param name="rings">Latitude rings (from pole to pole).</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateSphere(float radius, int segments, int rings, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
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());
}
/// <summary>
/// Generate a ground grid mesh at Y=0, consisting of thin quads.
/// </summary>
/// <param name="lines">Number of grid lines on each side of the origin.</param>
/// <param name="spacing">Distance between grid lines.</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateGrid(int lines, float spacing, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
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());
}
}
@@ -0,0 +1,36 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// 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.
/// </summary>
public static class RenderBackendFactory
{
private static readonly Dictionary<string, Func<int, int, bool, IRenderContext>> _registry
= new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Register a backend implementation under the given name.
/// The factory receives (width, height, enableValidation) and must create
/// its own window and render context.
/// </summary>
public static void Register(string name, Func<int, int, bool, IRenderContext> factory)
{
_registry[name] = factory;
}
/// <summary>
/// Create a backend instance for the given name.
/// The backend assembly must have registered itself before this is called.
/// </summary>
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);
}
}
@@ -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<Transform>();
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<Transform>();
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<Transform>();
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<Material>();
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);
}
}
+235
View File
@@ -0,0 +1,235 @@
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
using Flecs.NET.Core;
namespace Engine.Tests;
/// <summary>
/// Test double for IInputState — set properties before calling controller.Update().
/// </summary>
internal sealed class FakeInputState : IInputState
{
private readonly HashSet<Key> _down = new();
private readonly HashSet<Key> _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<Camera>();
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<Camera>();
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<Camera>().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<Camera>().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<Camera>();
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<Camera>();
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<Camera>();
// 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<Camera>();
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<Camera>();
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<Camera>();
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<Camera>();
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<Camera>();
Assert.Equal(0.5f, cam.Target.Y, 0.001f);
}
}
+65
View File
@@ -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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Engine.Core\Engine.Core.csproj" />
<ProjectReference Include="..\..\src\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\..\src\Engine.AI\Engine.AI.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
+40
View File
@@ -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);
}
}
@@ -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));
}
}
+146
View File
@@ -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<InvalidOperationException>(() => 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);
}
}
@@ -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<FakeRenderContext>(ctx);
}
[Fact]
public void Create_Throws_For_Unknown_Backend()
{
Assert.Throws<NotSupportedException>(() =>
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<FakeRenderContext>(ctx);
}
}
+65
View File
@@ -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);
}
}
+52
View File
@@ -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);
}
}