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:
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+16
@@ -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
@@ -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}°");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+71
-48
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user