feat: modular HAL, Raylib backend, PBR shading, textures, 60 unit tests

- Replace hardcoded SDL3 windowing with IWindow/IInputState/Key abstractions
- Each render backend owns its window (Raylib GLFW, SDL3 for Vulkan)
- Raylib backend: DrawModelEx, custom GLSL shader with Fresnel, ACES
  tonemapping, gamma correction, hemisphere ambient
- Fix backface culling, mesh memory (NativeMemory.Alloc), texture loading
- Camera controllers use backend-agnostic Key enum (inverted yaw/strafe)
- Demo scene: 8 cubes, 7 spheres, torus knot OBJ with checker texture
- Extract ProceduralMesh + MeshMath from Program.cs to Engine.Graphics
- Vulkan backend deferred (compiles, untested, IWindow-compatible)
- 60 unit tests: ObjLoader, camera controllers, AiCommandProcessor,
  RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform
- AGENTS.md for opencode integration
This commit is contained in:
emil28092005
2026-06-17 13:49:12 +03:00
parent 61f8c7065e
commit fb6e26a268
62 changed files with 22259 additions and 395 deletions
@@ -22,6 +22,8 @@
<ItemGroup>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Graphics.Raylib\Engine.Graphics.Raylib.csproj" />
<ProjectReference Include="..\Engine.Graphics.Vulkan\Engine.Graphics.Vulkan.csproj" />
<ProjectReference Include="..\Engine.AI\Engine.AI.csproj" />
</ItemGroup>
+243 -132
View File
@@ -3,10 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Numerics;
using Engine.AI;
using SDL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
#if !RELEASE_AOT
using Engine.AI.Mcp;
using Microsoft.AspNetCore.Builder;
@@ -15,6 +11,8 @@ using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Engine.Graphics.Loaders;
using Engine.Graphics.RaylibBackend;
using Engine.Graphics.Vulkan;
using Flecs.NET.Core;
namespace CortexEngine.App;
@@ -23,7 +21,7 @@ class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("Cortex Engine — Materials, Grid, Lighting, Orbit Camera...");
Console.WriteLine("Cortex Engine — Materials, Grid, Lighting, FreeFly Camera...");
try
{
@@ -33,27 +31,34 @@ class Program
return;
}
var cameraTour = args.Contains("--camera-tour");
var testScene = args.Contains("--test-scene");
if (testScene)
cameraTour = true;
using var world = World.Create();
using var window = new Sdl3Window("Cortex Engine", 1280, 720);
var timing = new Timing();
var input = new InputMapping();
using var vulkan = new VulkanContext(window, enableValidation: false);
using var swapchain = new Swapchain(vulkan);
using var renderer = new MeshRenderer(vulkan, swapchain);
RaylibBackendRegistrar.EnsureRegistered();
VulkanBackendRegistrar.EnsureRegistered();
using var renderContext = RenderBackendFactory.Create("raylib", 1280, 720, enableValidation: false);
var window = renderContext.Window;
var input = window.Input;
using var renderer = renderContext.CreateRenderer();
var (modelPath, mcpPort) = ParseArgs(args);
var mesh = LoadModel(modelPath);
var processor = new AiCommandProcessor(world, LoadModel, path => renderer.RequestScreenshot(path));
var queue = new AiCommandQueue(processor);
var queue = new AiCommandQueue(processor, renderer.ScreenshotProvider);
var cameraEntity = world.Entity("Camera")
.Set(new Transform(new Vector3(0.0f, 2.5f, -4.0f), Quaternion.Identity, Vector3.One))
.Set(new Transform(new Vector3(0.0f, 0.75f, -30.0f), Quaternion.Identity, Vector3.One))
.Set(new Camera(
new Vector3(0.0f, 2.5f, -4.0f),
new Vector3(0.0f, 0.75f, -30.0f),
new Vector3(0.0f, 0.5f, 0.0f),
Vector3.UnitY,
MathF.PI / 4.0f,
MathF.PI / 12.0f,
1280.0f / 720.0f,
0.1f,
100.0f));
@@ -75,42 +80,23 @@ class Program
.Set(new Light(new Vector3(0.0f, 1.0f, 0.0f), new Vector3(0.15f, 0.15f, 0.2f), 0.3f));
ICameraController[] cameraControllers =
[
new OrbitCameraController(cameraEntity, new Vector3(0.0f, 0.5f, 0.0f)),
new FreeFlyCameraController(cameraEntity)
];
{
new FreeFlyCameraController(cameraEntity),
new OrbitCameraController(cameraEntity, new Vector3(0.0f, 0.5f, 0.0f))
};
var activeControllerIndex = 0;
var cameraController = cameraControllers[activeControllerIndex];
Console.WriteLine($"Active camera controller: {cameraController.Name} (press F to toggle)");
var texturePath = GenerateCheckerboardTexture("Content/checkerboard.png", 256);
var model = world.Entity("Model")
.Set(new Transform(new Vector3(0.0f, 0.5f, 0.0f), Quaternion.Identity, new Vector3(0.5f)))
.Set(mesh)
.Set(new Material(new Vector3(0.9f, 0.6f, 0.3f), roughness: 0.4f, metallic: 0.1f));
var floor = world.Entity("Floor")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One))
.Set(CreateFloorMesh(20.0f, new Vector3(0.8f, 0.8f, 0.85f)))
.Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.9f, metallic: 0.0f, texturePath: texturePath));
var grid = world.Entity("Grid")
.Set(new Transform(new Vector3(0.0f, 0.01f, 0.0f), Quaternion.Identity, Vector3.One))
.Set(CreateGridMesh(20, 0.5f, new Vector3(0.5f, 0.5f, 0.55f)))
.Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f));
// Demo: local AI commands processed on the main thread.
Console.WriteLine("AI demo commands:");
Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message);
Console.WriteLine(processor.Process("""{ "type": "spawn_model", "name": "SecondCube", "modelPath": "Content/cube.obj", "position": [0.8, 0, 0], "scale": [0.3, 0.3, 0.3] }""").Message);
Console.WriteLine(processor.Process("""{ "type": "set_transform", "name": "SecondCube", "position": [0.8, 0.5, 0], "rotation": [0, 0, 0, 1], "scale": [0.3, 0.3, 0.3] }""").Message);
var secondCube = world.Lookup("SecondCube");
if ((ulong)secondCube.Id != 0)
secondCube.Set(new Material(new Vector3(0.3f, 0.7f, 0.9f), roughness: 0.3f, metallic: 0.2f));
Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message);
Console.WriteLine(processor.Process("""{ "type": "get_world_state" }""").Message);
Console.WriteLine(processor.Process("""{ "type": "capture_screenshot", "outputPath": "Screenshots/demo.png" }""").Message);
if (testScene)
{
Console.WriteLine("Calibration test scene enabled.");
CreateCalibrationScene(world, mesh);
}
else
{
CreateDemoScene(world, mesh);
}
#if !RELEASE_AOT
WebApplication? mcpApp = null;
@@ -144,11 +130,46 @@ class Program
var lastFpsTime = 0.0;
var lastWidth = window.Width;
var lastHeight = window.Height;
var demoScreenshotRequested = false;
var tourPoses = testScene
? new CameraPose[]
{
new("test_front", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_back", new Vector3(0.0f, 0.75f, 30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_left", new Vector3(-30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_right", new Vector3(30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_top", new Vector3(0.0f, 30.0f, 0.0f), new Vector3(0.0f, 0.0f, 0.0f), -Vector3.UnitZ),
new("test_shifted", new Vector3(15.0f, 0.75f, -22.5f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_rotated", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(2.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_yaw_15", new Vector3(7.76f, 0.75f, -28.98f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_yaw_30", new Vector3(15.0f, 0.75f, -25.98f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_yaw_45", new Vector3(21.21f, 0.75f, -21.21f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_yaw_90", new Vector3(30.0f, 0.75f, 0.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_pitch_45", new Vector3(0.0f, 21.96f, -21.21f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_close", new Vector3(0.0f, 0.75f, -15.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_far", new Vector3(0.0f, 0.75f, -60.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_farther", new Vector3(0.0f, 0.75f, -120.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("test_toward", new Vector3(0.0f, 0.75f, -20.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY)
}
: new CameraPose[]
{
new("front", new Vector3(0.0f, 0.75f, -30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("top", new Vector3(0.0f, 30.0f, 0.0f), new Vector3(0.0f, 0.0f, 0.0f), -Vector3.UnitZ),
new("side", new Vector3(30.0f, 0.75f, 4.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("close", new Vector3(1.0f, 0.75f, -5.0f), new Vector3(0.5f, 0.5f, 0.0f), Vector3.UnitY),
new("low", new Vector3(0.0f, 0.25f, -6.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY),
new("back", new Vector3(0.0f, 0.75f, 30.0f), new Vector3(0.0f, 0.5f, 0.0f), Vector3.UnitY)
};
var tourIndex = -1;
var tourSettleFrames = 0;
var tourScreenshotPending = false;
var tourDone = false;
while (!window.ShouldClose)
{
timing.Tick();
window.PumpEvents(input);
window.PumpEvents();
input.BeginFrame();
// Drain any commands that arrived from the MCP server.
@@ -160,27 +181,85 @@ class Program
{
lastWidth = window.Width;
lastHeight = window.Height;
swapchain.Recreate(lastWidth, lastHeight);
renderContext.Resize(lastWidth, lastHeight);
ref var camera = ref cameraEntity.Ensure<Camera>();
camera.AspectRatio = (float)lastWidth / lastHeight;
}
// Toggle camera controller with F.
if (input.IsKeyPressed(SDL_Keycode.SDLK_F))
// Toggle camera controller on F key press.
if (input.IsKeyPressed(Key.F))
{
activeControllerIndex = (activeControllerIndex + 1) % cameraControllers.Length;
Console.WriteLine($"Camera controller: {cameraControllers[activeControllerIndex].Name}");
cameraController = cameraControllers[activeControllerIndex];
Console.WriteLine($"Active camera controller: {cameraController.Name}");
}
// Update active camera controller from input.
cameraControllers[activeControllerIndex].Update(input, (float)timing.DeltaTime);
// Update the active camera controller from input, unless the camera tour is driving the pose.
if (!cameraTour)
cameraController.Update(input, (float)timing.DeltaTime);
// Slowly rotate the model so we can see it in 3D.
ref var modelTransform = ref model.Ensure<Transform>();
modelTransform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f)
* Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)timing.TotalTime * 0.25f);
if (cameraTour && !tourDone)
{
if (tourIndex < 0)
{
tourIndex = 0;
SetCameraPose(cameraEntity, tourPoses[tourIndex]);
tourSettleFrames = 0;
tourScreenshotPending = true;
}
// Hold the pose for a few frames to let the GPU settle, then screenshot.
if (tourScreenshotPending)
{
tourSettleFrames++;
if (tourSettleFrames >= 5)
{
var path = $"Screenshots/tour_{tourPoses[tourIndex].Name}.png";
renderer.RequestScreenshot(path);
Console.WriteLine($"Tour screenshot: {path}");
tourScreenshotPending = false;
}
}
// After the screenshot has been saved, advance to the next pose.
if (!tourScreenshotPending && !renderer.IsScreenshotRequested)
{
tourIndex++;
if (tourIndex >= tourPoses.Length)
{
tourDone = true;
Console.WriteLine("Camera tour complete.");
window.Close();
}
else
{
SetCameraPose(cameraEntity, tourPoses[tourIndex]);
tourSettleFrames = 0;
tourScreenshotPending = true;
}
}
}
// Slowly rotate the model so we can see it in 3D, unless the camera tour or test scene is active.
if (!cameraTour && !testScene)
{
var modelEntity = world.Lookup("CubeCenter");
if (modelEntity.Id != 0)
{
ref var modelTransform = ref modelEntity.Ensure<Transform>();
modelTransform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f);
}
}
// Capture a demo screenshot after the scene warms up (non-tour mode only).
if (!demoScreenshotRequested && !cameraTour && frames >= 15)
{
renderer.RequestScreenshot("Screenshots/demo.png");
demoScreenshotRequested = true;
}
renderer.RenderWorld(world);
queue.CompletePendingScreenshots();
frames++;
if (timing.TotalTime - lastFpsTime >= 1.0)
@@ -206,6 +285,98 @@ class Program
}
}
private static void CreateDemoScene(World world, Mesh mesh)
{
var sphere = ProceduralMesh.CreateSphere(0.5f, 32, 16, new Vector3(0.8f, 0.8f, 0.8f));
var torusKnot = ObjLoader.Load("Content/torusknot.obj", new Vector3(0.8f, 0.8f, 0.8f));
var cubes = new (string name, Vector3 pos, Vector3 color, float scale, float rough, float metal)[]
{
("CubeCenter", new Vector3(0, 0.5f, 0), new Vector3(0.9f, 0.6f, 0.3f), 0.5f, 0.3f, 0.1f),
("CubeRed", new Vector3(2, 0.5f, 0), new Vector3(0.85f, 0.15f, 0.15f), 0.5f, 0.4f, 0.2f),
("CubeGreen", new Vector3(-2, 0.5f, 0), new Vector3(0.2f, 0.8f, 0.3f), 0.5f, 0.5f, 0.0f),
("CubeBlue", new Vector3(0, 0.5f, 3), new Vector3(0.2f, 0.4f, 0.9f), 0.6f, 0.2f, 0.3f),
("CubeYellow", new Vector3(0, 0.5f, -3), new Vector3(0.95f, 0.85f, 0.2f), 0.5f, 0.6f, 0.0f),
("CubeOrange", new Vector3(-3, 0.5f, 3), new Vector3(0.95f, 0.5f, 0.1f), 0.45f, 0.5f, 0.1f),
("CubeWide", new Vector3(-1.5f, 0.5f, -1.5f), new Vector3(0.5f, 0.5f, 0.6f), 0.8f, 0.8f, 0.0f),
("CubeSmallGold", new Vector3(5, 0.3f, -2), new Vector3(1.0f, 0.8f, 0.3f), 0.3f, 0.15f, 1.0f),
};
foreach (var (name, pos, color, scale, rough, metal) in cubes)
{
world.Entity(name)
.Set(new Transform(pos, Quaternion.Identity, new Vector3(scale)))
.Set(mesh)
.Set(new Material(color, roughness: rough, metallic: metal));
}
var spheres = new (string name, Vector3 pos, Vector3 color, float scale, float rough, float metal)[]
{
("SphereGold", new Vector3(-5, 0.5f, -2), new Vector3(1.0f, 0.85f, 0.4f), 1.0f, 0.1f, 1.0f),
("SphereChrome", new Vector3(-6, 0.5f, 0), new Vector3(0.9f, 0.9f, 0.95f), 1.0f, 0.05f, 1.0f),
("SphereRed", new Vector3(-5, 0.5f, 2), new Vector3(0.9f, 0.1f, 0.1f), 1.0f, 0.4f, 0.0f),
("SphereBlue", new Vector3(5, 0.5f, 2), new Vector3(0.1f, 0.3f, 0.9f), 1.0f, 0.2f, 0.5f),
("SphereGreen", new Vector3(6, 0.5f, 0), new Vector3(0.1f, 0.8f, 0.3f), 1.0f, 0.7f, 0.0f),
("SphereWhite", new Vector3(5, 0.5f, -4), new Vector3(0.95f, 0.95f, 0.95f), 1.0f, 0.3f, 0.0f),
("SphereRough", new Vector3(3, 0.5f, 5), new Vector3(0.6f, 0.4f, 0.2f), 1.0f, 0.9f, 0.0f),
};
foreach (var (name, pos, color, scale, rough, metal) in spheres)
{
world.Entity(name)
.Set(new Transform(pos, Quaternion.Identity, new Vector3(scale)))
.Set(sphere)
.Set(new Material(color, roughness: rough, metallic: metal));
}
// Torus knot with checker texture
world.Entity("TorusKnot")
.Set(new Transform(new Vector3(0, 2.0f, 0), Quaternion.Identity, new Vector3(1.5f)))
.Set(torusKnot)
.Set(new Material(new Vector3(0.9f, 0.9f, 0.9f), roughness: 0.25f, metallic: 0.6f, texturePath: "Content/checker.png"));
// Textured cube
world.Entity("CubeTextured")
.Set(new Transform(new Vector3(-4, 0.5f, -3), Quaternion.Identity, new Vector3(0.7f)))
.Set(mesh)
.Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.4f, metallic: 0.0f, texturePath: "Content/checker.png"));
world.Entity("Grid")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One))
.Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f)))
.Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f));
}
private static void CreateCalibrationScene(World world, Mesh mesh)
{
// Colored cubes at known world positions for visual analysis of perspective and camera movement.
var positions = new (string name, Vector3 pos, Vector3 color)[]
{
("CubeOrigin", new Vector3(0.0f, 0.5f, 0.0f), new Vector3(1.0f, 1.0f, 1.0f)), // white at origin
("CubeRight", new Vector3(2.0f, 0.5f, 0.0f), new Vector3(1.0f, 0.0f, 0.0f)), // red +X
("CubeLeft", new Vector3(-2.0f, 0.5f, 0.0f), new Vector3(0.0f, 1.0f, 0.0f)), // green -X
("CubeFront", new Vector3(0.0f, 0.5f, 2.0f), new Vector3(0.0f, 0.0f, 1.0f)), // blue +Z
("CubeBack", new Vector3(0.0f, 0.5f, -2.0f), new Vector3(1.0f, 1.0f, 0.0f)), // yellow -Z
("CubeUp", new Vector3(0.0f, 2.5f, 0.0f), new Vector3(1.0f, 0.0f, 1.0f)), // magenta +Y
("CubeFar", new Vector3(0.0f, 0.5f, 8.0f), new Vector3(0.0f, 1.0f, 1.0f)), // cyan far +Z
("CubeFarLeft", new Vector3(-5.0f, 0.5f, 5.0f), new Vector3(0.5f, 0.5f, 1.0f)) // light blue far corner
};
foreach (var (name, pos, color) in positions)
{
world.Entity(name)
.Set(new Transform(pos, Quaternion.Identity, new Vector3(0.5f)))
.Set(mesh)
.Set(new Material(color, roughness: 0.5f, metallic: 0.1f));
}
// A large reference grid at Y=0.
world.Entity("Grid")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One))
.Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f)))
.Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f));
}
private static Mesh LoadModel(string path)
{
return path.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)
@@ -214,57 +385,6 @@ class Program
: ObjLoader.Load(path, new Vector3(0.7f, 0.6f, 0.5f));
}
private static Mesh CreateGridMesh(int lines, float spacing, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
var extent = lines * spacing;
var normal = Vector3.UnitY;
var halfWidth = 0.02f;
for (var i = -lines; i <= lines; i++)
{
var offset = i * spacing;
// Line parallel to X axis as a thin quad.
var baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
// Line parallel to Z axis as a thin quad.
baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
private static Mesh CreateFloorMesh(float size, Vector3 color)
{
var half = size / 2.0f;
var normal = Vector3.UnitY;
var vertices = new Vertex[]
{
new(new Vector3(-half, 0, -half), color, normal),
new(new Vector3(half, 0, -half), color, normal),
new(new Vector3(half, 0, half), color, normal),
new(new Vector3(-half, 0, half), color, normal)
};
var indices = new uint[] { 0, 1, 2, 0, 2, 3 };
return new Mesh(vertices, indices);
}
private static (string modelPath, int mcpPort) ParseArgs(string[] args)
{
var modelPath = FindModelPath(args);
@@ -314,28 +434,6 @@ class Program
throw new FileNotFoundException("No model file found. Pass a .obj/.gltf/.glb path as argument or place Content/cube.obj next to the executable.");
}
private static string GenerateCheckerboardTexture(string path, int size)
{
var tileSize = size / 8;
using var image = new Image<Rgba32>(size, size);
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
var tileX = x / tileSize;
var tileY = y / tileSize;
var isDark = (tileX + tileY) % 2 == 0;
image[x, y] = isDark
? new Rgba32(60, 60, 70, 255)
: new Rgba32(160, 160, 170, 255);
}
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
image.SaveAsPng(path);
return path;
}
private static void RunMcpStdioServer()
{
Console.WriteLine("Starting headless stdio MCP server...");
@@ -344,4 +442,17 @@ class Program
var server = new Engine.AI.Stdio.McpStdioServer(processor);
server.Run();
}
private readonly record struct CameraPose(string Name, Vector3 Position, Vector3 Target, Vector3 Up, float Fov = MathF.PI / 12.0f);
private static void SetCameraPose(Entity cameraEntity, CameraPose pose)
{
ref var camera = ref cameraEntity.Ensure<Camera>();
camera.Position = pose.Position;
camera.Target = pose.Target;
camera.Up = pose.Up;
camera.FieldOfView = pose.Fov;
cameraEntity.Set(camera);
Console.WriteLine($"Camera pose '{pose.Name}': pos={pose.Position}, target={pose.Target}, up={pose.Up}, fov={pose.Fov * 180f / MathF.PI:F0}°");
}
}
+51 -3
View File
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Engine.AI.Commands;
using Engine.Core;
namespace Engine.AI;
@@ -12,11 +13,14 @@ public sealed class AiCommandQueue
{
private readonly ConcurrentQueue<(string commandJson, TaskCompletionSource<AiCommandResult> tcs)> _queue = new();
private readonly AiCommandProcessor _processor;
private readonly IScreenshotProvider _screenshot;
private readonly JsonSerializerOptions _jsonOptions;
private (TaskCompletionSource<AiCommandResult> tcs, Task<byte[]> screenshotTask, string path)? _pendingScreenshot;
public AiCommandQueue(AiCommandProcessor processor)
public AiCommandQueue(AiCommandProcessor processor, IScreenshotProvider screenshot)
{
_processor = processor;
_screenshot = screenshot;
_jsonOptions = processor.JsonOptions;
}
@@ -49,13 +53,57 @@ public sealed class AiCommandQueue
int processed = 0;
while (_queue.TryDequeue(out var item))
{
var result = _processor.Process(item.commandJson);
item.tcs.TrySetResult(result);
var command = JsonSerializer.Deserialize<AiCommand>(item.commandJson, _jsonOptions);
if (command is CaptureScreenshotCommand screenshotCommand)
{
// Screenshot commands are handled asynchronously because the frame must be rendered
// before the PNG bytes are available. CompletePendingScreenshots must be called after
// the renderer has presented the frame.
if (_pendingScreenshot.HasValue)
{
item.tcs.TrySetResult(AiCommandResult.Error("Another screenshot request is already pending."));
continue;
}
var path = screenshotCommand.OutputPath ?? $"screenshot_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}.png";
var screenshotTask = _screenshot.CaptureAsync(path);
_pendingScreenshot = (item.tcs, screenshotTask, path);
}
else
{
var result = _processor.Process(item.commandJson);
item.tcs.TrySetResult(result);
}
processed++;
}
return processed;
}
/// <summary>
/// Completes any pending screenshot requests that have finished rendering.
/// Must be called on the main engine thread after the frame has been presented.
/// </summary>
public void CompletePendingScreenshots()
{
if (_pendingScreenshot == null || !_pendingScreenshot.Value.screenshotTask.IsCompleted)
return;
var (tcs, screenshotTask, path) = _pendingScreenshot.Value;
_pendingScreenshot = null;
try
{
var bytes = screenshotTask.Result;
var base64 = Convert.ToBase64String(bytes);
var json = $"{{\"path\":{JsonSerializer.Serialize(path)},\"base64\":{JsonSerializer.Serialize(base64)}}}";
tcs.TrySetResult(AiCommandResult.Ok(json));
}
catch (Exception ex)
{
tcs.TrySetResult(AiCommandResult.Error($"Screenshot capture failed: {ex.Message}"));
}
}
/// <summary>
/// Number of commands waiting to be processed.
/// </summary>
+1 -1
View File
@@ -70,7 +70,7 @@ public sealed class EngineMcpTools
return EnqueueAndReturnMessage(cmd);
}
[McpServerTool, Description("Capture a screenshot of the current rendered frame and save it to disk.")]
[McpServerTool, Description("Capture a screenshot of the current rendered frame and return it as a base64-encoded PNG. The image is also saved to disk.")]
public Task<string> CaptureScreenshot([Description("Optional output file path (default: screenshot_<timestamp>.png)")] string? outputPath = null)
{
var cmd = new CaptureScreenshotCommand { OutputPath = outputPath };
+1 -1
View File
@@ -55,7 +55,7 @@ public sealed class McpStdioServer
"List all named entities in the ECS world.",
new JsonSchemaBuilder().Build()),
["CaptureScreenshot"] = new(
"Capture a screenshot of the current rendered frame and save it to disk.",
"Capture a screenshot of the current rendered frame and save it to disk. (No graphics context in stdio mode; returns requested path.)",
new JsonSchemaBuilder()
.AddOptionalString("outputPath")
.Build()),
+3 -1
View File
@@ -13,7 +13,9 @@ public record struct Light
public Light(Vector3 direction, Vector3 color, float intensity = 1.0f)
{
Direction = Vector3.Normalize(direction);
Direction = direction.LengthSquared() > 0.0001f
? Vector3.Normalize(direction)
: Vector3.UnitY;
Color = color;
Intensity = intensity;
}
+10 -11
View File
@@ -1,7 +1,6 @@
using System;
using System.Numerics;
using Flecs.NET.Core;
using SDL;
using Engine.Core.Components;
namespace Engine.Core;
@@ -37,30 +36,30 @@ public sealed class FreeFlyCameraController : ICameraController
_yaw = MathF.Atan2(forward.X, forward.Z);
}
public void Update(InputMapping input, float deltaTime)
public void Update(IInputState input, float deltaTime)
{
var move = Vector3.Zero;
var forward = new Vector3(MathF.Sin(_yaw), 0.0f, MathF.Cos(_yaw));
var right = new Vector3(MathF.Cos(_yaw), 0.0f, -MathF.Sin(_yaw));
var right = new Vector3(-MathF.Cos(_yaw), 0.0f, MathF.Sin(_yaw));
var up = Vector3.UnitY;
if (input.IsKeyDown(SDL_Keycode.SDLK_W))
if (input.IsKeyDown(Key.W))
move += forward;
if (input.IsKeyDown(SDL_Keycode.SDLK_S))
if (input.IsKeyDown(Key.S))
move -= forward;
if (input.IsKeyDown(SDL_Keycode.SDLK_A))
if (input.IsKeyDown(Key.A))
move -= right;
if (input.IsKeyDown(SDL_Keycode.SDLK_D))
if (input.IsKeyDown(Key.D))
move += right;
if (input.IsKeyDown(SDL_Keycode.SDLK_E))
if (input.IsKeyDown(Key.E))
move += up;
if (input.IsKeyDown(SDL_Keycode.SDLK_Q))
if (input.IsKeyDown(Key.Q))
move -= up;
if (move.LengthSquared() > 0.0f)
{
move = Vector3.Normalize(move);
var speed = input.IsKeyDown(SDL_Keycode.SDLK_LSHIFT) ? _fastSpeed : _speed;
var speed = input.IsKeyDown(Key.LeftShift) ? _fastSpeed : _speed;
_position += move * speed * deltaTime;
}
@@ -82,7 +81,7 @@ public sealed class FreeFlyCameraController : ICameraController
{
var dx = input.MouseX - _lastMouseX;
var dy = input.MouseY - _lastMouseY;
_yaw += dx * _mouseSensitivity;
_yaw -= dx * _mouseSensitivity;
_pitch += dy * _mouseSensitivity;
_pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f);
_lastMouseX = input.MouseX;
+2 -2
View File
@@ -1,10 +1,10 @@
namespace Engine.Core;
/// <summary>
/// Common interface for camera controllers (orbit, free-fly, etc.).
/// Common interface for camera controllers (free-fly, etc.).
/// </summary>
public interface ICameraController
{
string Name { get; }
void Update(InputMapping input, float deltaTime);
void Update(IInputState input, float deltaTime);
}
+25
View File
@@ -0,0 +1,25 @@
namespace Engine.Core;
/// <summary>
/// Read-only query interface for keyboard and mouse input state.
/// Implemented by each windowing backend (SDL3, Raylib, etc.).
/// </summary>
public interface IInputState
{
int MouseX { get; }
int MouseY { get; }
bool MouseLeft { get; }
bool MouseRight { get; }
bool MouseMiddle { get; }
float MouseWheelDelta { get; }
bool IsKeyDown(Key key);
bool IsKeyPressed(Key key);
bool IsKeyReleased(Key key);
/// <summary>
/// Called at the start of each frame to clear per-frame edge state
/// (key-pressed, key-released, mouse-wheel delta).
/// </summary>
void BeginFrame();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Engine.Core;
/// <summary>
/// Provider that can capture the current rendered frame to a PNG byte array.
/// Implemented by the graphics subsystem and consumed by the AI layer.
/// </summary>
public interface IScreenshotProvider
{
/// <summary>
/// Request a screenshot of the next rendered frame.
/// The returned task completes once the frame has been rendered and the PNG bytes are available.
/// The image is also saved to <paramref name="outputPath"/> on disk.
/// </summary>
Task<byte[]> CaptureAsync(string outputPath);
}
+41
View File
@@ -0,0 +1,41 @@
namespace Engine.Core;
/// <summary>
/// Backend-agnostic window abstraction.
/// Each render backend (Vulkan+SDL3, Raylib+GLFW, etc.) owns and creates its own window.
/// The application retrieves the window from <see cref="Graphics.IRenderContext.Window"/>.
/// </summary>
public interface IWindow : IDisposable
{
int Width { get; }
int Height { get; }
bool ShouldClose { get; }
/// <summary>
/// Read-only input state populated during <see cref="PumpEvents"/>.
/// </summary>
IInputState Input { get; }
/// <summary>
/// Poll window events and update <see cref="Input"/>. Called once per frame
/// before reading input state or rendering.
/// </summary>
void PumpEvents();
/// <summary>
/// Request the window to close at the next frame boundary.
/// </summary>
void Close();
/// <summary>
/// Native window handle (e.g. <c>SDL_Window*</c>). Used by backends that need
/// the raw OS handle for surface creation. Returns 0 if not applicable.
/// </summary>
nint Handle { get; }
/// <summary>
/// Vulkan instance extensions required by this window (e.g. VK_KHR_xlib_surface).
/// Returns an empty array if the windowing system does not support Vulkan.
/// </summary>
string[] GetRequiredVulkanExtensions();
}
+104 -14
View File
@@ -4,14 +4,14 @@ using SDL;
namespace Engine.Core;
/// <summary>
/// Minimal snapshot of current input state.
/// Populated by polling SDL events once per frame.
/// SDL3-backed implementation of <see cref="IInputState"/>.
/// Populated by polling SDL events via <see cref="ProcessEvent"/> once per frame.
/// </summary>
public sealed class InputMapping
public sealed class InputMapping : IInputState
{
private readonly HashSet<SDL_Keycode> _keysPressed = new();
private readonly HashSet<SDL_Keycode> _keysDown = new();
private readonly HashSet<SDL_Keycode> _keysReleased = new();
private readonly HashSet<Key> _keysPressed = new();
private readonly HashSet<Key> _keysDown = new();
private readonly HashSet<Key> _keysReleased = new();
public int MouseX { get; private set; }
public int MouseY { get; private set; }
@@ -32,15 +32,23 @@ public sealed class InputMapping
switch ((SDL_EventType)evt.type)
{
case SDL_EventType.SDL_EVENT_KEY_DOWN:
if (!_keysDown.Contains((SDL_Keycode)evt.key.key))
_keysPressed.Add((SDL_Keycode)evt.key.key);
_keysDown.Add((SDL_Keycode)evt.key.key);
{
var key = SdlKeyMap.ToKey((SDL_Keycode)evt.key.key);
if (key == Key.Unknown) break;
if (!_keysDown.Contains(key))
_keysPressed.Add(key);
_keysDown.Add(key);
break;
}
case SDL_EventType.SDL_EVENT_KEY_UP:
_keysDown.Remove((SDL_Keycode)evt.key.key);
_keysReleased.Add((SDL_Keycode)evt.key.key);
{
var key = SdlKeyMap.ToKey((SDL_Keycode)evt.key.key);
if (key == Key.Unknown) break;
_keysDown.Remove(key);
_keysReleased.Add(key);
break;
}
case SDL_EventType.SDL_EVENT_MOUSE_MOTION:
MouseX = (int)evt.motion.x;
@@ -61,9 +69,9 @@ public sealed class InputMapping
}
}
public bool IsKeyDown(SDL_Keycode key) => _keysDown.Contains(key);
public bool IsKeyPressed(SDL_Keycode key) => _keysPressed.Contains(key);
public bool IsKeyReleased(SDL_Keycode key) => _keysReleased.Contains(key);
public bool IsKeyDown(Key key) => _keysDown.Contains(key);
public bool IsKeyPressed(Key key) => _keysPressed.Contains(key);
public bool IsKeyReleased(Key key) => _keysReleased.Contains(key);
private void SetMouseButton(byte button, bool pressed)
{
@@ -75,3 +83,85 @@ public sealed class InputMapping
}
}
}
/// <summary>
/// Maps SDL3 keycodes to the backend-agnostic <see cref="Key"/> enum.
/// </summary>
internal static class SdlKeyMap
{
private static readonly Dictionary<SDL_Keycode, Key> _map = new()
{
{ SDL_Keycode.SDLK_SPACE, Key.Space },
{ SDL_Keycode.SDLK_ESCAPE, Key.Escape },
{ SDL_Keycode.SDLK_RETURN, Key.Enter },
{ SDL_Keycode.SDLK_TAB, Key.Tab },
{ SDL_Keycode.SDLK_BACKSPACE, Key.Backspace },
{ SDL_Keycode.SDLK_INSERT, Key.Insert },
{ SDL_Keycode.SDLK_DELETE, Key.Delete },
{ SDL_Keycode.SDLK_HOME, Key.Home },
{ SDL_Keycode.SDLK_END, Key.End },
{ SDL_Keycode.SDLK_PAGEUP, Key.PageUp },
{ SDL_Keycode.SDLK_PAGEDOWN, Key.PageDown },
{ SDL_Keycode.SDLK_LEFT, Key.Left },
{ SDL_Keycode.SDLK_RIGHT, Key.Right },
{ SDL_Keycode.SDLK_UP, Key.Up },
{ SDL_Keycode.SDLK_DOWN, Key.Down },
{ SDL_Keycode.SDLK_A, Key.A },
{ SDL_Keycode.SDLK_B, Key.B },
{ SDL_Keycode.SDLK_C, Key.C },
{ SDL_Keycode.SDLK_D, Key.D },
{ SDL_Keycode.SDLK_E, Key.E },
{ SDL_Keycode.SDLK_F, Key.F },
{ SDL_Keycode.SDLK_G, Key.G },
{ SDL_Keycode.SDLK_H, Key.H },
{ SDL_Keycode.SDLK_I, Key.I },
{ SDL_Keycode.SDLK_J, Key.J },
{ SDL_Keycode.SDLK_K, Key.K },
{ SDL_Keycode.SDLK_L, Key.L },
{ SDL_Keycode.SDLK_M, Key.M },
{ SDL_Keycode.SDLK_N, Key.N },
{ SDL_Keycode.SDLK_O, Key.O },
{ SDL_Keycode.SDLK_P, Key.P },
{ SDL_Keycode.SDLK_Q, Key.Q },
{ SDL_Keycode.SDLK_R, Key.R },
{ SDL_Keycode.SDLK_S, Key.S },
{ SDL_Keycode.SDLK_T, Key.T },
{ SDL_Keycode.SDLK_U, Key.U },
{ SDL_Keycode.SDLK_V, Key.V },
{ SDL_Keycode.SDLK_W, Key.W },
{ SDL_Keycode.SDLK_X, Key.X },
{ SDL_Keycode.SDLK_Y, Key.Y },
{ SDL_Keycode.SDLK_Z, Key.Z },
{ SDL_Keycode.SDLK_0, Key.Zero },
{ SDL_Keycode.SDLK_1, Key.One },
{ SDL_Keycode.SDLK_2, Key.Two },
{ SDL_Keycode.SDLK_3, Key.Three },
{ SDL_Keycode.SDLK_4, Key.Four },
{ SDL_Keycode.SDLK_5, Key.Five },
{ SDL_Keycode.SDLK_6, Key.Six },
{ SDL_Keycode.SDLK_7, Key.Seven },
{ SDL_Keycode.SDLK_8, Key.Eight },
{ SDL_Keycode.SDLK_9, Key.Nine },
{ SDL_Keycode.SDLK_F1, Key.F1 },
{ SDL_Keycode.SDLK_F2, Key.F2 },
{ SDL_Keycode.SDLK_F3, Key.F3 },
{ SDL_Keycode.SDLK_F4, Key.F4 },
{ SDL_Keycode.SDLK_F5, Key.F5 },
{ SDL_Keycode.SDLK_F6, Key.F6 },
{ SDL_Keycode.SDLK_F7, Key.F7 },
{ SDL_Keycode.SDLK_F8, Key.F8 },
{ SDL_Keycode.SDLK_F9, Key.F9 },
{ SDL_Keycode.SDLK_F10, Key.F10 },
{ SDL_Keycode.SDLK_F11, Key.F11 },
{ SDL_Keycode.SDLK_F12, Key.F12 },
{ SDL_Keycode.SDLK_LSHIFT, Key.LeftShift },
{ SDL_Keycode.SDLK_LCTRL, Key.LeftControl },
{ SDL_Keycode.SDLK_LALT, Key.LeftAlt },
{ SDL_Keycode.SDLK_RSHIFT, Key.RightShift },
{ SDL_Keycode.SDLK_RCTRL, Key.RightControl },
{ SDL_Keycode.SDLK_RALT, Key.RightAlt },
};
public static Key ToKey(SDL_Keycode sdlKey) =>
_map.TryGetValue(sdlKey, out var key) ? key : Key.Unknown;
}
+39
View File
@@ -0,0 +1,39 @@
namespace Engine.Core;
/// <summary>
/// Backend-agnostic key codes used by <see cref="IInputState"/> and camera controllers.
/// Each windowing backend (SDL3, Raylib, etc.) maps its native key codes to these values.
/// </summary>
public enum Key
{
Unknown = 0,
Space,
Escape,
Enter,
Tab,
Backspace,
Insert,
Delete,
Home,
End,
PageUp,
PageDown,
Left,
Right,
Up,
Down,
A, B, C, D, E, F, G, H, I, J, K, L, M,
N, O, P, Q, R, S, T, U, V, W, X, Y, Z,
Zero, One, Two, Three, Four, Five, Six, Seven, Eight, Nine,
F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12,
LeftShift,
LeftControl,
LeftAlt,
RightShift,
RightControl,
RightAlt,
}
+66 -29
View File
@@ -1,68 +1,102 @@
using System;
using System.Numerics;
using Flecs.NET.Core;
using Engine.Core.Components;
namespace Engine.Core;
/// <summary>
/// Orbit camera controller. Right mouse drag rotates around the target,
/// mouse wheel zooms in/out.
/// Orbit camera controller. Rotates the camera around a fixed target point.
/// Right mouse drag rotates; mouse wheel zooms; WASD moves the target on the ground plane.
/// </summary>
public sealed class OrbitCameraController : ICameraController
{
private readonly Entity _cameraEntity;
private Vector3 _target;
private float _distance;
private float _yaw;
private float _pitch;
private readonly Vector3 _target;
private float _speed = 3.0f;
private float _fastSpeed = 8.0f;
private float _mouseSensitivity = 0.005f;
private float _zoomSensitivity = 0.1f;
private int _lastMouseX;
private int _lastMouseY;
private bool _isDragging;
private bool _wasRightMouseDown;
public string Name => "Orbit";
public OrbitCameraController(Entity cameraEntity, Vector3? target = null)
public OrbitCameraController(Entity cameraEntity, Vector3 target)
{
_cameraEntity = cameraEntity;
var camera = cameraEntity.Get<Components.Camera>();
_target = target ?? Vector3.Zero;
_distance = Vector3.Distance(camera.Position, _target);
_target = target;
var direction = Vector3.Normalize(camera.Position - _target);
_pitch = MathF.Asin(-direction.Y);
_yaw = MathF.Atan2(direction.X, direction.Z);
var camera = cameraEntity.Get<Camera>();
_distance = Vector3.Distance(camera.Position, target);
var forward = Vector3.Normalize(target - camera.Position);
_pitch = MathF.Asin(-forward.Y);
_yaw = MathF.Atan2(forward.X, forward.Z);
// Clamp pitch to avoid gimbal-lock and sudden flips.
_pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f);
}
public void Update(InputMapping input, float deltaTime)
public void Update(IInputState input, float deltaTime)
{
var move = Vector3.Zero;
var forward = new Vector3(MathF.Sin(_yaw), 0.0f, MathF.Cos(_yaw));
var right = new Vector3(-MathF.Cos(_yaw), 0.0f, MathF.Sin(_yaw));
var up = Vector3.UnitY;
if (input.IsKeyDown(Key.W))
move += forward;
if (input.IsKeyDown(Key.S))
move -= forward;
if (input.IsKeyDown(Key.A))
move -= right;
if (input.IsKeyDown(Key.D))
move += right;
if (input.IsKeyDown(Key.E))
move += up;
if (input.IsKeyDown(Key.Q))
move -= up;
if (move.LengthSquared() > 0.0f)
{
move = Vector3.Normalize(move);
var speed = input.IsKeyDown(Key.LeftShift) ? _fastSpeed : _speed;
_target += move * speed * deltaTime;
}
if (input.MouseWheelDelta != 0)
{
_distance *= 1.0f - input.MouseWheelDelta * _zoomSensitivity;
_distance = Math.Clamp(_distance, 1.0f, 200.0f);
}
if (input.MouseRight)
{
if (!_isDragging)
if (!_wasRightMouseDown)
{
_isDragging = true;
_lastMouseX = input.MouseX;
_lastMouseY = input.MouseY;
_wasRightMouseDown = true;
}
else
{
var dx = input.MouseX - _lastMouseX;
var dy = input.MouseY - _lastMouseY;
_yaw -= dx * 0.005f;
_pitch -= dy * 0.005f;
_pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.1f, MathF.PI / 2.0f - 0.1f);
_yaw -= dx * _mouseSensitivity;
_pitch += dy * _mouseSensitivity;
_pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.01f, MathF.PI / 2.0f - 0.01f);
_lastMouseX = input.MouseX;
_lastMouseY = input.MouseY;
}
}
else
{
_isDragging = false;
}
if (input.MouseWheelDelta != 0)
{
_distance *= 1.0f - input.MouseWheelDelta * 0.1f;
_distance = Math.Clamp(_distance, 0.5f, 50.0f);
_wasRightMouseDown = false;
}
UpdateCamera();
@@ -70,13 +104,16 @@ public sealed class OrbitCameraController : ICameraController
private void UpdateCamera()
{
var x = _distance * MathF.Cos(_pitch) * MathF.Sin(_yaw);
var y = _distance * MathF.Sin(_pitch);
var z = _distance * MathF.Cos(_pitch) * MathF.Cos(_yaw);
var camera = _cameraEntity.Get<Camera>();
var camera = _cameraEntity.Get<Components.Camera>();
camera.Position = _target + new Vector3(x, y, z);
var direction = new Vector3(
MathF.Cos(_pitch) * MathF.Sin(_yaw),
-MathF.Sin(_pitch),
MathF.Cos(_pitch) * MathF.Cos(_yaw));
camera.Position = _target - direction * _distance;
camera.Target = _target;
camera.Up = Vector3.UnitY;
_cameraEntity.Set(camera);
}
}
+17 -21
View File
@@ -6,51 +6,51 @@ using SDL;
namespace Engine.Core;
/// <summary>
/// A thin, disposable wrapper around an SDL3 window.
/// Handles creation, Vulkan surface discovery, and event polling.
/// SDL3-backed implementation of <see cref="IWindow"/>.
/// Creates a native window, polls SDL events, and exposes input via <see cref="Input"/>.
/// </summary>
public sealed unsafe class Sdl3Window : IDisposable
public sealed unsafe class Sdl3Window : IWindow
{
private readonly SDL_Window* _window;
private readonly InputMapping _input = new();
private bool _disposed;
public int Width { get; private set; }
public int Height { get; private set; }
public nint Handle => (nint)_window;
public bool ShouldClose { get; private set; }
public IInputState Input => _input;
public nint Handle => (nint)_window;
public Sdl3Window(string title, int width, int height)
public void Close() => ShouldClose = true;
public Sdl3Window(string title, int width, int height, bool vulkanSurface = true)
{
Width = width;
Height = height;
if (!SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO))
{
throw new InvalidOperationException($"SDL_Init failed: {SDL3.SDL_GetError()}");
}
var flags = SDL_WindowFlags.SDL_WINDOW_RESIZABLE;
if (vulkanSurface)
flags |= SDL_WindowFlags.SDL_WINDOW_VULKAN;
var titleBytes = Encoding.UTF8.GetBytes(title + '\0');
fixed (byte* titlePtr = titleBytes)
{
_window = SDL3.SDL_CreateWindow(
titlePtr,
width,
height,
SDL_WindowFlags.SDL_WINDOW_VULKAN | SDL_WindowFlags.SDL_WINDOW_RESIZABLE);
_window = SDL3.SDL_CreateWindow(titlePtr, width, height, flags);
}
if (_window == null)
{
throw new InvalidOperationException($"SDL_CreateWindow failed: {SDL3.SDL_GetError()}");
}
}
public void PumpEvents(InputMapping? input = null)
public void PumpEvents()
{
SDL_Event evt;
while (SDL3.SDL_PollEvent(&evt))
{
input?.ProcessEvent(evt);
_input.ProcessEvent(evt);
switch ((SDL_EventType)evt.type)
{
@@ -71,20 +71,16 @@ public sealed unsafe class Sdl3Window : IDisposable
}
}
public string[] GetRequiredInstanceExtensions()
public string[] GetRequiredVulkanExtensions()
{
uint count;
var extensionsPtr = SDL3.SDL_Vulkan_GetInstanceExtensions(&count);
if (extensionsPtr == null)
{
throw new InvalidOperationException($"SDL_Vulkan_GetInstanceExtensions failed: {SDL3.SDL_GetError()}");
}
var result = new string[count];
for (var i = 0; i < count; i++)
{
result[i] = SDL3.PtrToStringUTF8(extensionsPtr[i]) ?? string.Empty;
}
return result;
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>false</IsAotCompatible>
<AssemblyName>Engine.Graphics.Raylib</AssemblyName>
<RootNamespace>Engine.Graphics.Raylib</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>false</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Raylib-cs" Version="8.0.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
using Engine.Graphics;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Triggers registration of the Raylib backend with the HAL factory.
/// </summary>
public static class RaylibBackendRegistrar
{
static RaylibBackendRegistrar()
{
RenderBackendFactory.Register("raylib", (width, height, _) => new RaylibRenderContext(width, height));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IInputState"/>.
/// Queries Raylib's input functions directly each frame.
/// </summary>
public sealed class RaylibInputState : IInputState
{
private static readonly Key[] _allKeys = (Key[])Enum.GetValues(typeof(Key));
private readonly HashSet<Key> _keysDown = new();
private readonly HashSet<Key> _keysPressed = new();
private readonly HashSet<Key> _keysReleased = new();
private float _mouseWheelDelta;
private bool _wheelConsumed;
public int MouseX => Raylib.GetMouseX();
public int MouseY => Raylib.GetMouseY();
public bool MouseLeft => Raylib.IsMouseButtonDown(MouseButton.Left);
public bool MouseRight => Raylib.IsMouseButtonDown(MouseButton.Right);
public bool MouseMiddle => Raylib.IsMouseButtonDown(MouseButton.Middle);
public float MouseWheelDelta
{
get
{
if (!_wheelConsumed)
{
_mouseWheelDelta = Raylib.GetMouseWheelMove();
_wheelConsumed = true;
}
return _mouseWheelDelta;
}
}
public void BeginFrame()
{
_keysPressed.Clear();
_keysReleased.Clear();
_mouseWheelDelta = 0;
_wheelConsumed = false;
}
/// <summary>
/// Poll Raylib input and update edge state. Called by <see cref="RaylibWindow.PumpEvents"/>.
/// </summary>
public void Poll()
{
_keysPressed.Clear();
_keysReleased.Clear();
foreach (var key in _allKeys)
{
if (key == Key.Unknown) continue;
var rlKey = ToRaylibKey(key);
if (rlKey == KeyboardKey.Null) continue;
var isDown = Raylib.IsKeyDown(rlKey);
var wasDown = _keysDown.Contains(key);
if (isDown && !wasDown)
_keysPressed.Add(key);
if (!isDown && wasDown)
_keysReleased.Add(key);
if (isDown)
_keysDown.Add(key);
else
_keysDown.Remove(key);
}
}
public bool IsKeyDown(Key key) => _keysDown.Contains(key);
public bool IsKeyPressed(Key key) => _keysPressed.Contains(key);
public bool IsKeyReleased(Key key) => _keysReleased.Contains(key);
private static KeyboardKey ToRaylibKey(Key key) => key switch
{
Key.Space => KeyboardKey.Space,
Key.Escape => KeyboardKey.Escape,
Key.Enter => KeyboardKey.Enter,
Key.Tab => KeyboardKey.Tab,
Key.Backspace => KeyboardKey.Backspace,
Key.Insert => KeyboardKey.Insert,
Key.Delete => KeyboardKey.Delete,
Key.Home => KeyboardKey.Home,
Key.End => KeyboardKey.End,
Key.PageUp => KeyboardKey.PageUp,
Key.PageDown => KeyboardKey.PageDown,
Key.Left => KeyboardKey.Left,
Key.Right => KeyboardKey.Right,
Key.Up => KeyboardKey.Up,
Key.Down => KeyboardKey.Down,
Key.A => KeyboardKey.A,
Key.B => KeyboardKey.B,
Key.C => KeyboardKey.C,
Key.D => KeyboardKey.D,
Key.E => KeyboardKey.E,
Key.F => KeyboardKey.F,
Key.G => KeyboardKey.G,
Key.H => KeyboardKey.H,
Key.I => KeyboardKey.I,
Key.J => KeyboardKey.J,
Key.K => KeyboardKey.K,
Key.L => KeyboardKey.L,
Key.M => KeyboardKey.M,
Key.N => KeyboardKey.N,
Key.O => KeyboardKey.O,
Key.P => KeyboardKey.P,
Key.Q => KeyboardKey.Q,
Key.R => KeyboardKey.R,
Key.S => KeyboardKey.S,
Key.T => KeyboardKey.T,
Key.U => KeyboardKey.U,
Key.V => KeyboardKey.V,
Key.W => KeyboardKey.W,
Key.X => KeyboardKey.X,
Key.Y => KeyboardKey.Y,
Key.Z => KeyboardKey.Z,
Key.Zero => KeyboardKey.Zero,
Key.One => KeyboardKey.One,
Key.Two => KeyboardKey.Two,
Key.Three => KeyboardKey.Three,
Key.Four => KeyboardKey.Four,
Key.Five => KeyboardKey.Five,
Key.Six => KeyboardKey.Six,
Key.Seven => KeyboardKey.Seven,
Key.Eight => KeyboardKey.Eight,
Key.Nine => KeyboardKey.Nine,
Key.F1 => KeyboardKey.F1,
Key.F2 => KeyboardKey.F2,
Key.F3 => KeyboardKey.F3,
Key.F4 => KeyboardKey.F4,
Key.F5 => KeyboardKey.F5,
Key.F6 => KeyboardKey.F6,
Key.F7 => KeyboardKey.F7,
Key.F8 => KeyboardKey.F8,
Key.F9 => KeyboardKey.F9,
Key.F10 => KeyboardKey.F10,
Key.F11 => KeyboardKey.F11,
Key.F12 => KeyboardKey.F12,
Key.LeftShift => KeyboardKey.LeftShift,
Key.LeftControl => KeyboardKey.LeftControl,
Key.LeftAlt => KeyboardKey.LeftAlt,
Key.RightShift => KeyboardKey.RightShift,
Key.RightControl => KeyboardKey.RightControl,
Key.RightAlt => KeyboardKey.RightAlt,
_ => KeyboardKey.Null,
};
}
@@ -0,0 +1,28 @@
using Engine.Core;
using Engine.Graphics;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib implementation of the render HAL context.
/// Creates and owns a <see cref="RaylibWindow"/> (GLFW-based).
/// No SDL3 dependency — the Raylib window handles both rendering and input.
/// </summary>
public sealed class RaylibRenderContext : IRenderContext
{
private readonly RaylibWindow _window;
public IWindow Window => _window;
public RaylibRenderContext(int width, int height, bool enableValidation = false)
{
_window = new RaylibWindow("Cortex Engine", width, height);
}
public IRenderer CreateRenderer() => new RaylibRenderer();
public void Resize(int width, int height) => Raylib.SetWindowSize(width, height);
public void Dispose() => _window.Dispose();
}
@@ -0,0 +1,503 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Engine.Core;
using Engine.Core.Components;
using EngineMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using EngineTransform = Engine.Core.Components.Transform;
using Flecs.NET.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib implementation of the ECS world renderer.
/// Renders Mesh + Transform + Material entities with up to four directional lights.
/// </summary>
public sealed class RaylibRenderer : IRenderer
{
private readonly Shader _shader;
private readonly Dictionary<Entity, Raylib_cs.Model> _modelCache = new();
private readonly Dictionary<string, Texture2D> _textureCache = new();
private readonly int _materialColorLoc;
private readonly int _useTextureLoc;
private readonly int _roughnessLoc;
private readonly int _metallicLoc;
private readonly int _ambientLoc;
private readonly int _viewPosLoc;
private readonly int _lightCountLoc;
private readonly int _lightDirLoc;
private readonly int _lightIntensityLoc;
private readonly int _lightColorLoc;
private readonly float[] _lightDirs = new float[12]; // 4 lights * 3 floats
private readonly float[] _lightIntensities = new float[4];
private readonly float[] _lightColors = new float[12]; // 4 lights * 3 floats
private ScreenshotRequest? _pendingScreenshot;
private int _frameCount;
private bool _disposed;
public RaylibRenderer()
{
_shader = LoadShader();
_materialColorLoc = Raylib.GetShaderLocation(_shader, "materialColor");
_useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture");
_roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness");
_metallicLoc = Raylib.GetShaderLocation(_shader, "metallic");
_ambientLoc = Raylib.GetShaderLocation(_shader, "ambientColor");
_viewPosLoc = Raylib.GetShaderLocation(_shader, "viewPos");
_lightCountLoc = Raylib.GetShaderLocation(_shader, "lightCount");
_lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs");
_lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities");
_lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors");
}
public void RequestScreenshot(string outputPath)
{
_pendingScreenshot = new ScreenshotRequest(outputPath, null);
}
public bool IsScreenshotRequested => _pendingScreenshot != null;
public IScreenshotProvider ScreenshotProvider => new RaylibScreenshotProvider(this);
public void RenderWorld(World world)
{
var camera = GetCamera(world);
Raylib.BeginDrawing();
Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.BeginMode3D(ToRaylib(camera));
Rlgl.DisableBackfaceCulling();
// Frame-level uniforms: SetShaderValue calls glUseProgram internally,
// so these don't need BeginShaderMode. DrawModelEx rebinds the same shader
// (set on the model's material), so the values persist for the draw call.
CollectLights(world);
SetFrameLights();
Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3);
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{
if (e.Name() == "Grid")
return;
var material = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default;
var model = GetOrUploadModel(e, mesh);
var modelMatrix = transform.GetMatrix();
if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position))
{
var axis = Vector3.UnitY;
var angle = 0.0f;
var q = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
if (MathF.Abs(q.W) < 0.9999999f)
{
angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f));
var s = MathF.Sqrt(1.0f - q.W * q.W);
if (s > 0.0001f)
axis = new Vector3(q.X / s, q.Y / s, q.Z / s);
else
axis = new Vector3(q.X, q.Y, q.Z);
}
// Set per-entity uniforms right before the draw.
// DrawModelEx binds the model's material shader (= _shader) and
// immediately issues the draw, so these values are live during rendering.
SetMaterialUniforms(material, model);
Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White);
}
});
Rlgl.EnableBackfaceCulling();
Raylib.DrawGrid(20, 1.0f);
Raylib.EndMode3D();
Raylib.EndDrawing();
// Defer the first screenshot by a few frames. Raylib may return a blank image
// if the window/GPU has not finished presenting the first frame.
if (_pendingScreenshot is { } request && _frameCount >= 10)
{
CaptureScreenshot(request);
_pendingScreenshot = null;
}
_frameCount++;
}
private Camera3D ToRaylib(Camera camera)
{
return new Camera3D
{
Position = camera.Position,
Target = camera.Target,
Up = camera.Up,
FovY = camera.FieldOfView * 180.0f / MathF.PI,
Projection = CameraProjection.Perspective
};
}
private Camera GetCamera(World world)
{
var width = Raylib.GetScreenWidth();
var height = Raylib.GetScreenHeight();
var aspect = height > 0 ? (float)width / height : 16f / 9f;
var camera = new Camera(
new Vector3(0.0f, 0.75f, -30.0f),
new Vector3(0.0f, 0.5f, 0.0f),
Vector3.UnitY,
MathF.PI / 12.0f,
aspect,
0.1f,
100.0f);
world.Each((Entity e, ref Camera cam) =>
{
camera = cam;
});
camera.AspectRatio = aspect;
return camera;
}
private void CollectLights(World world)
{
var count = 0;
world.Each((Entity e, ref Light light) =>
{
if (count >= 4)
return;
_lightDirs[count * 3 + 0] = light.Direction.X;
_lightDirs[count * 3 + 1] = light.Direction.Y;
_lightDirs[count * 3 + 2] = light.Direction.Z;
_lightIntensities[count] = light.Intensity;
_lightColors[count * 3 + 0] = light.Color.X;
_lightColors[count * 3 + 1] = light.Color.Y;
_lightColors[count * 3 + 2] = light.Color.Z;
count++;
});
if (count == 0)
{
_lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f;
_lightIntensities[0] = 1.0f;
_lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f;
count = 1;
}
for (var i = count; i < 4; i++)
{
_lightDirs[i * 3 + 0] = 0;
_lightDirs[i * 3 + 1] = 0;
_lightDirs[i * 3 + 2] = 0;
_lightIntensities[i] = 0.0f;
_lightColors[i * 3 + 0] = 0;
_lightColors[i * 3 + 1] = 0;
_lightColors[i * 3 + 2] = 0;
}
Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int);
Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4);
Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4);
Raylib.SetShaderValueV(_shader, _lightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 4);
}
private void SetFrameLights()
{
Raylib.SetShaderValue(_shader, _ambientLoc, new float[] { 0.35f, 0.35f, 0.4f }, ShaderUniformDataType.Vec3);
}
private unsafe void SetMaterialUniforms(EngineMaterial material, Raylib_cs.Model model)
{
Raylib.SetShaderValue(_shader, _materialColorLoc, new float[] { material.Albedo.X, material.Albedo.Y, material.Albedo.Z, 1.0f }, ShaderUniformDataType.Vec4);
Raylib.SetShaderValue(_shader, _roughnessLoc, material.Roughness, ShaderUniformDataType.Float);
Raylib.SetShaderValue(_shader, _metallicLoc, material.Metallic, ShaderUniformDataType.Float);
if (material.HasTexture && File.Exists(material.TexturePath!))
{
Raylib.SetShaderValue(_shader, _useTextureLoc, 1, ShaderUniformDataType.Int);
var texture = GetOrLoadTexture(material.TexturePath!);
Raylib.SetMaterialTexture(ref model.Materials[0], MaterialMapIndex.Albedo, texture);
}
else
{
Raylib.SetShaderValue(_shader, _useTextureLoc, 0, ShaderUniformDataType.Int);
}
}
private unsafe Raylib_cs.Model GetOrUploadModel(Entity e, EngineMesh mesh)
{
if (_modelCache.TryGetValue(e, out var model))
return model;
// Use Raylib's native mesh generation when possible — the manual UploadMesh
// + LoadModelFromMesh path is unreliable for larger meshes because
// LoadModelFromMesh reads CPU-side vertex pointers after UploadMesh.
// For custom meshes (from OBJ/GLTF loaders), keep the CPU data alive.
var raylibMesh = UploadRaylibMesh(mesh);
model = Raylib.LoadModelFromMesh(raylibMesh);
for (var i = 0; i < model.MaterialCount; i++)
{
model.Materials[i].Shader = _shader;
}
_modelCache[e] = model;
return model;
}
private unsafe Raylib_cs.Mesh UploadRaylibMesh(EngineMesh mesh)
{
var vertexCount = mesh.Vertices.Length;
var triangleCount = mesh.Indices.Length / 3;
var raylibMesh = new Raylib_cs.Mesh
{
VertexCount = vertexCount,
TriangleCount = triangleCount
};
var positionSize = vertexCount * 3 * sizeof(float);
var normalSize = vertexCount * 3 * sizeof(float);
var colorSize = vertexCount * 4;
var texcoordSize = vertexCount * 2 * sizeof(float);
var indexSize = mesh.Indices.Length * sizeof(ushort);
// Use NativeMemory.Alloc so Raylib's UnloadMesh can free with RL_FREE (free).
var positionPtr = (float*)NativeMemory.Alloc((nuint)positionSize, 4);
var normalPtr = (float*)NativeMemory.Alloc((nuint)normalSize, 4);
var colorPtr = (byte*)NativeMemory.Alloc((nuint)colorSize, 1);
var texcoordPtr = (float*)NativeMemory.Alloc((nuint)texcoordSize, 4);
var indexPtr = (ushort*)NativeMemory.Alloc((nuint)indexSize, 2);
for (var i = 0; i < vertexCount; i++)
{
var v = mesh.Vertices[i];
positionPtr[i * 3 + 0] = v.Position.X;
positionPtr[i * 3 + 1] = v.Position.Y;
positionPtr[i * 3 + 2] = v.Position.Z;
normalPtr[i * 3 + 0] = v.Normal.X;
normalPtr[i * 3 + 1] = v.Normal.Y;
normalPtr[i * 3 + 2] = v.Normal.Z;
colorPtr[i * 4 + 0] = (byte)Math.Clamp(v.Color.X * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 1] = (byte)Math.Clamp(v.Color.Y * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 2] = (byte)Math.Clamp(v.Color.Z * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 3] = 255;
texcoordPtr[i * 2 + 0] = v.Position.X;
texcoordPtr[i * 2 + 1] = v.Position.Z;
}
for (var i = 0; i < mesh.Indices.Length; i++)
indexPtr[i] = (ushort)mesh.Indices[i];
raylibMesh.Vertices = positionPtr;
raylibMesh.Normals = normalPtr;
raylibMesh.Colors = colorPtr;
raylibMesh.TexCoords = texcoordPtr;
raylibMesh.Indices = indexPtr;
Raylib.UploadMesh(ref raylibMesh, false);
// Keep CPU-side data alive — LoadModelFromMesh reads these pointers
// to compute the bounding box. They will be freed when the model is unloaded.
return raylibMesh;
}
private Texture2D GetOrLoadTexture(string path)
{
if (_textureCache.TryGetValue(path, out var texture))
return texture;
texture = Raylib.LoadTexture(path);
Raylib.SetTextureWrap(texture, TextureWrap.Repeat);
Raylib.SetTextureFilter(texture, TextureFilter.Trilinear);
_textureCache[path] = texture;
return texture;
}
private unsafe void CaptureScreenshot(ScreenshotRequest request)
{
var image = Raylib.LoadImageFromScreen();
try
{
var directory = Path.GetDirectoryName(request.Path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
Raylib.ExportImage(image, request.Path);
if (request.Tcs != null)
{
var size = 0;
var fileType = stackalloc byte[] { (byte)'.', (byte)'p', (byte)'n', (byte)'g', 0 };
var data = Raylib.ExportImageToMemory(image, (sbyte*)fileType, &size);
var bytes = new byte[size];
fixed (byte* p = bytes)
{
Buffer.MemoryCopy(data, p, size, size);
}
Raylib.MemFree(data);
request.Tcs.TrySetResult(bytes);
}
Console.WriteLine($"Screenshot saved: {request.Path}");
}
finally
{
Raylib.UnloadImage(image);
}
}
private Task<byte[]> CaptureAsync(string outputPath)
{
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingScreenshot = new ScreenshotRequest(outputPath, tcs);
return tcs.Task;
}
private static Shader LoadShader()
{
const string VertexSource = @"#version 330 core
in vec3 vertexPosition;
in vec2 vertexTexCoord;
in vec3 vertexNormal;
in vec4 vertexColor;
uniform mat4 mvp;
uniform mat4 matModel;
out vec3 vNormal;
out vec3 vWorldPos;
out vec4 vColor;
out vec2 vTexCoord;
void main()
{
vec4 worldPos = matModel * vec4(vertexPosition, 1.0);
vWorldPos = worldPos.xyz;
vNormal = mat3(transpose(inverse(matModel))) * vertexNormal;
vColor = vertexColor;
vTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}";
const string FragmentSource = @"#version 330 core
in vec3 vNormal;
in vec3 vWorldPos;
in vec4 vColor;
in vec2 vTexCoord;
out vec4 finalColor;
uniform vec4 materialColor;
uniform int useTexture;
uniform sampler2D texture0;
uniform float roughness;
uniform float metallic;
uniform vec3 viewPos;
uniform vec3 ambientColor;
uniform int lightCount;
uniform vec3 lightDirs[4];
uniform float lightIntensities[4];
uniform vec3 lightColors[4];
vec3 ACESFilm(vec3 x)
{
const float a = 2.51; const float b = 0.03; const float c = 2.43; const float d = 0.59; const float e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
void main()
{
vec3 normal = normalize(vNormal);
vec3 albedo = vColor.rgb * materialColor.rgb;
if (useTexture != 0)
{
vec2 uv = vTexCoord * 4.0;
albedo *= texture(texture0, uv).rgb;
}
vec3 viewDir = normalize(viewPos - vWorldPos);
float rough = clamp(roughness, 0.05, 1.0);
float metal = clamp(metallic, 0.0, 1.0);
// Hemisphere ambient: low ambient for visible shading contrast
vec3 skyColor = ambientColor;
vec3 groundColor = ambientColor * 0.2;
float hemisphere = 0.5 + 0.5 * normal.y;
vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4;
vec3 F0 = mix(vec3(0.04), albedo, metal);
float shininess = mix(8.0, 256.0, 1.0 - rough);
for (int i = 0; i < lightCount; i++)
{
vec3 L = normalize(-lightDirs[i]);
vec3 H = normalize(L + viewDir);
float NdotL = max(dot(normal, L), 0.0);
float NdotH = max(dot(normal, H), 0.0);
float NdotV = max(dot(normal, viewDir), 0.0);
float HdotV = max(dot(H, viewDir), 0.0);
float diff = NdotL;
float spec = pow(NdotH, shininess);
// Schlick Fresnel
float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - HdotV, 5.0);
vec3 specularColor = mix(vec3(fresnel), albedo * fresnel, metal);
vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5;
vec3 specular = specularColor * spec * lightIntensities[i];
// Energy conservation
diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5));
result += diffuse + specular;
}
// ACES tonemapping + gamma correction
result = ACESFilm(result * 1.2);
result = pow(result, vec3(1.0 / 2.2));
finalColor = vec4(result, 1.0);
}";
return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var model in _modelCache.Values)
Raylib.UnloadModel(model);
_modelCache.Clear();
foreach (var texture in _textureCache.Values)
Raylib.UnloadTexture(texture);
_textureCache.Clear();
Raylib.UnloadShader(_shader);
}
private readonly record struct ScreenshotRequest(string Path, TaskCompletionSource<byte[]>? Tcs);
private sealed class RaylibScreenshotProvider : IScreenshotProvider
{
private readonly RaylibRenderer _renderer;
public RaylibScreenshotProvider(RaylibRenderer renderer)
{
_renderer = renderer;
}
public Task<byte[]> CaptureAsync(string outputPath) => _renderer.CaptureAsync(outputPath);
}
}
@@ -0,0 +1,54 @@
using System;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IWindow"/>.
/// Wraps Raylib's GLFW window creation, event polling, and input.
/// </summary>
public sealed class RaylibWindow : IWindow
{
private readonly RaylibInputState _input = new();
private bool _shouldClose;
private bool _disposed;
public int Width => Raylib.GetScreenWidth();
public int Height => Raylib.GetScreenHeight();
public bool ShouldClose => _shouldClose;
public IInputState Input => _input;
public nint Handle => 0;
public RaylibWindow(string title, int width, int height)
{
Raylib.SetConfigFlags(ConfigFlags.VSyncHint);
Raylib.InitWindow(width, height, title);
Raylib.SetTargetFPS(0);
// Present a blank frame so the window is visible immediately.
Raylib.BeginDrawing();
Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.EndDrawing();
}
public void PumpEvents()
{
_input.Poll();
_shouldClose = Raylib.WindowShouldClose() || _shouldClose;
if (Raylib.IsKeyPressed(KeyboardKey.Escape))
_shouldClose = true;
}
public void Close() => _shouldClose = true;
public string[] GetRequiredVulkanExtensions() => Array.Empty<string>();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Raylib.CloseWindow();
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
<AssemblyName>Engine.Graphics.Vulkan</AssemblyName>
<RootNamespace>Engine.Graphics.Vulkan</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Vulkan" Version="2.21.0" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.21.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,6 @@
using System;
using System.IO;
using Engine.Core;
using Silk.NET.Core;
using Silk.NET.Vulkan;
using SixLabors.ImageSharp;
@@ -11,7 +12,7 @@ namespace Engine.Graphics;
/// Captures the current swapchain image to a PNG file on disk.
/// Used by AI agents to visually inspect the running engine.
/// </summary>
public sealed unsafe class ScreenshotCapture : IDisposable
public sealed unsafe class ScreenshotCapture : IDisposable, IScreenshotProvider
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
@@ -21,6 +22,9 @@ public sealed unsafe class ScreenshotCapture : IDisposable
private bool _requested;
private string _outputPath = string.Empty;
private bool _ready;
private bool _captureToMemory;
private MemoryStream? _memoryOutput;
private TaskCompletionSource<byte[]>? _captureTcs;
public ScreenshotCapture(VulkanContext context, Swapchain swapchain)
{
@@ -29,13 +33,31 @@ public sealed unsafe class ScreenshotCapture : IDisposable
}
/// <summary>
/// Request a screenshot to be captured on the next frame.
/// Request a screenshot to be captured on the next frame and saved to disk.
/// </summary>
public void Request(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = false;
_memoryOutput = null;
_captureTcs = null;
}
/// <summary>
/// Request a screenshot of the next rendered frame. The returned task completes once the
/// PNG bytes are available. The image is also saved to <paramref name="outputPath"/> on disk.
/// </summary>
public Task<byte[]> CaptureAsync(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = true;
_memoryOutput = new MemoryStream();
_captureTcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
return _captureTcs.Task;
}
/// <summary>
@@ -97,6 +119,7 @@ public sealed unsafe class ScreenshotCapture : IDisposable
/// <summary>
/// Save the captured pixels to disk. Must be called after the command buffer containing the readback has finished.
/// If the request was made with <see cref="CaptureAsync"/> the PNG bytes are also written to memory and the task is completed.
/// </summary>
public void Save(uint width, uint height, Format format)
{
@@ -128,67 +151,67 @@ public sealed unsafe class ScreenshotCapture : IDisposable
Console.WriteLine($"Screenshot saved: {_outputPath}");
_requested = false;
_ready = false;
_captureToMemory = false;
_memoryOutput = null;
_captureTcs = null;
}
private void SavePixels(void* mappedData, uint width, uint height, uint rowPitch, Format format)
{
using var image = CreateImage(mappedData, width, height, rowPitch, format);
image.SaveAsPng(_outputPath);
if (_captureToMemory && _memoryOutput != null)
{
image.SaveAsPng(_memoryOutput);
var bytes = _memoryOutput.ToArray();
_captureTcs?.TrySetResult(bytes);
}
}
private SixLabors.ImageSharp.Image<Rgba32> CreateImage(void* mappedData, uint width, uint height, uint rowPitch, Format format)
{
var image = new SixLabors.ImageSharp.Image<Rgba32>((int)width, (int)height);
var src = (byte*)mappedData;
if (format == Format.B8G8R8A8Unorm || format == Format.B8G8R8A8Srgb)
{
SaveBgra(mappedData, width, height, rowPitch);
return;
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var b = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var r = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
return image;
}
if (format == Format.R8G8B8A8Unorm || format == Format.R8G8B8A8Srgb)
{
SaveRgba(mappedData, width, height, rowPitch);
return;
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var r = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var b = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
return image;
}
image.Dispose();
throw new NotSupportedException($"Screenshot format {format} is not supported.");
}
private void SaveBgra(void* mappedData, uint width, uint height, uint rowPitch)
{
using var image = new SixLabors.ImageSharp.Image<Rgba32>((int)width, (int)height);
var src = (byte*)mappedData;
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var b = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var r = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
image.SaveAsPng(_outputPath);
}
private void SaveRgba(void* mappedData, uint width, uint height, uint rowPitch)
{
using var image = new SixLabors.ImageSharp.Image<Rgba32>((int)width, (int)height);
var src = (byte*)mappedData;
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var r = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var b = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
image.SaveAsPng(_outputPath);
}
private void EnsureStagingBuffer(ulong size)
{
if (_stagingSize >= size)
@@ -12,7 +12,7 @@ public static class ShaderLoader
public static byte[] Load(string name)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"Engine.Graphics.Shaders.{name}";
var resourceName = $"Engine.Graphics.Vulkan.Shaders.{name}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded shader resource not found: {resourceName}");
@@ -0,0 +1,20 @@
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Triggers registration of the Vulkan backend with the HAL factory.
/// </summary>
public static class VulkanBackendRegistrar
{
static VulkanBackendRegistrar()
{
RenderBackendFactory.Register("vulkan", (width, height, enableValidation) => new VulkanRenderContext(width, height, enableValidation));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
@@ -33,7 +33,7 @@ public sealed unsafe class VulkanContext : IDisposable
public uint PresentFamilyIndex { get; private set; }
public CommandPool CommandPool { get; private set; }
public VulkanContext(Sdl3Window window, bool enableValidation = true)
public VulkanContext(IWindow window, bool enableValidation = true)
{
Vk = Vk.GetApi();
CreateInstance(window, enableValidation);
@@ -62,9 +62,9 @@ public sealed unsafe class VulkanContext : IDisposable
CommandPool = commandPool;
}
private void CreateInstance(Sdl3Window window, bool enableValidation)
private void CreateInstance(IWindow window, bool enableValidation)
{
var requiredExtensions = new List<string>(window.GetRequiredInstanceExtensions());
var requiredExtensions = new List<string>(window.GetRequiredVulkanExtensions());
if (enableValidation)
{
requiredExtensions.Add("VK_EXT_debug_utils");
@@ -128,7 +128,7 @@ public sealed unsafe class VulkanContext : IDisposable
KhrSwapchain = khrSwapchain;
}
private void CreateSurface(Sdl3Window window)
private void CreateSurface(IWindow window)
{
var sdlInstance = (SDL.VkInstance_T*)Instance.Handle;
var sdlSurface = (SDL.VkSurfaceKHR_T*)null;
@@ -0,0 +1,35 @@
using Engine.Core;
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Vulkan implementation of the render HAL context.
/// Creates and owns an <see cref="Sdl3Window"/> for the Vulkan surface.
/// </summary>
public sealed class VulkanRenderContext : IRenderContext
{
private readonly Sdl3Window _window;
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
public IWindow Window => _window;
public VulkanRenderContext(int width, int height, bool enableValidation)
{
_window = new Sdl3Window("Cortex Engine", width, height, vulkanSurface: true);
_context = new VulkanContext(_window, enableValidation);
_swapchain = new Swapchain(_context);
}
public IRenderer CreateRenderer() => new VulkanRenderer(_context, _swapchain);
public void Resize(int width, int height) => _swapchain.Recreate(width, height);
public void Dispose()
{
_swapchain.Dispose();
_context.Dispose();
_window.Dispose();
}
}
@@ -13,10 +13,10 @@ using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Renders indexed meshes attached to ECS entities.
/// Vulkan implementation of the ECS world renderer.
/// Uses Silk.NET.Vulkan and reads Mesh + Transform components from the ECS world.
/// </summary>
public sealed unsafe class MeshRenderer : IDisposable
public sealed unsafe class VulkanRenderer : IRenderer
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
@@ -89,7 +89,7 @@ public sealed unsafe class MeshRenderer : IDisposable
}
}
public MeshRenderer(VulkanContext context, Swapchain swapchain)
public VulkanRenderer(VulkanContext context, Swapchain swapchain)
{
_context = context;
_swapchain = swapchain;
@@ -287,6 +287,11 @@ public sealed unsafe class MeshRenderer : IDisposable
public bool IsScreenshotRequested => _screenshot.IsRequested;
/// <summary>
/// Provider that can asynchronously capture the current frame to a PNG byte array.
/// </summary>
public IScreenshotProvider ScreenshotProvider => _screenshot;
public void RenderWorld(World world)
{
var frame = _currentFrame % 2;
@@ -340,10 +345,13 @@ public sealed unsafe class MeshRenderer : IDisposable
_context.Vk.CmdSetViewport(cmd, 0, 1, &viewport);
_context.Vk.CmdSetScissor(cmd, 0, 1, &scissor);
var camera = GetCamera(world);
var view = camera.GetViewMatrix();
var proj = camera.GetProjectionMatrix();
var drawCmd = cmd;
var camera = GetCamera(world);
var view = camera.GetViewMatrix();
var proj = camera.GetProjectionMatrix();
// Vulkan NDC Y points down; .NET's projection matrix assumes Y up, so flip Y.
proj.M22 = -proj.M22;
var drawCmd = cmd;
var frameConstants = BuildFrameConstants(world, camera);
var frameConstantsBytes = new byte[sizeof(FrameConstants)];
@@ -18,16 +18,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Vulkan" Version="2.21.0" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.21.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
<ItemGroup>
+27
View File
@@ -0,0 +1,27 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// Abstraction over a graphics backend (Vulkan, Raylib, etc.).
/// Each backend owns its window and surface. The application retrieves
/// the window via <see cref="Window"/> for input and event polling.
/// </summary>
public interface IRenderContext : IDisposable
{
/// <summary>
/// The window owned by this backend. The application uses this for
/// input polling, resize detection, and close requests.
/// </summary>
IWindow Window { get; }
/// <summary>
/// Create a renderer that can draw the ECS world using this backend.
/// </summary>
IRenderer CreateRenderer();
/// <summary>
/// Notify the backend that the output surface has been resized.
/// </summary>
void Resize(int width, int height);
}
+31
View File
@@ -0,0 +1,31 @@
using Engine.Core;
using Flecs.NET.Core;
namespace Engine.Graphics;
/// <summary>
/// Renders the ECS world and exposes screenshot capture.
/// Implemented by concrete graphics backends.
/// </summary>
public interface IRenderer : IDisposable
{
/// <summary>
/// Render one frame of the ECS world and present it.
/// </summary>
void RenderWorld(World world);
/// <summary>
/// Request a screenshot of the next rendered frame to be saved to disk.
/// </summary>
void RequestScreenshot(string outputPath);
/// <summary>
/// True if a screenshot has been requested but not yet captured.
/// </summary>
bool IsScreenshotRequested { get; }
/// <summary>
/// Provider that can asynchronously capture the current frame to PNG bytes.
/// </summary>
IScreenshotProvider ScreenshotProvider { get; }
}
+1 -10
View File
@@ -81,14 +81,5 @@ public static class GltfLoader
}
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
{
var ab = b - a;
var ac = c - a;
var normal = Vector3.Cross(ab, ac);
if (normal.LengthSquared() > 0.00001f)
normal = Vector3.Normalize(normal);
else
normal = Vector3.UnitY;
return normal;
}
=> MeshMath.ComputeFaceNormal(a, b, c);
}
+1 -10
View File
@@ -84,14 +84,5 @@ public static class ObjLoader
}
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
{
var ab = b - a;
var ac = c - a;
var normal = Vector3.Cross(ab, ac);
if (normal.LengthSquared() > 0.00001f)
normal = Vector3.Normalize(normal);
else
normal = Vector3.UnitY;
return normal;
}
=> MeshMath.ComputeFaceNormal(a, b, c);
}
+25
View File
@@ -0,0 +1,25 @@
using System.Numerics;
namespace Engine.Graphics;
/// <summary>
/// Shared mesh math utilities used by loaders and procedural generators.
/// </summary>
public static class MeshMath
{
/// <summary>
/// Compute a flat face normal from three vertex positions.
/// Falls back to Vector3.UnitY for degenerate (zero-area) triangles.
/// </summary>
public static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
{
var ab = b - a;
var ac = c - a;
var normal = Vector3.Cross(ab, ac);
if (normal.LengthSquared() > 0.00001f)
normal = Vector3.Normalize(normal);
else
normal = Vector3.UnitY;
return normal;
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Procedural mesh generators for common primitive shapes.
/// All methods are pure CPU — no GPU/display dependencies.
/// </summary>
public static class ProceduralMesh
{
/// <summary>
/// Generate a UV sphere mesh.
/// </summary>
/// <param name="radius">Sphere radius.</param>
/// <param name="segments">Longitude segments (around the equator).</param>
/// <param name="rings">Latitude rings (from pole to pole).</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateSphere(float radius, int segments, int rings, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
for (var ring = 0; ring <= rings; ring++)
{
var phi = MathF.PI * ring / rings;
var sinPhi = MathF.Sin(phi);
var cosPhi = MathF.Cos(phi);
for (var seg = 0; seg <= segments; seg++)
{
var theta = 2.0f * MathF.PI * seg / segments;
var sinTheta = MathF.Sin(theta);
var cosTheta = MathF.Cos(theta);
var x = radius * sinPhi * cosTheta;
var y = radius * cosPhi;
var z = radius * sinPhi * sinTheta;
var normal = Vector3.Normalize(new Vector3(x, y, z));
vertices.Add(new Vertex(new Vector3(x, y, z), color, normal));
}
}
for (var ring = 0; ring < rings; ring++)
{
for (var seg = 0; seg < segments; seg++)
{
var i0 = (uint)(ring * (segments + 1) + seg);
var i1 = i0 + 1;
var i2 = i0 + (uint)(segments + 1);
var i3 = i2 + 1;
indices.Add(i0); indices.Add(i1); indices.Add(i2);
indices.Add(i1); indices.Add(i3); indices.Add(i2);
}
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
/// <summary>
/// Generate a ground grid mesh at Y=0, consisting of thin quads.
/// </summary>
/// <param name="lines">Number of grid lines on each side of the origin.</param>
/// <param name="spacing">Distance between grid lines.</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateGrid(int lines, float spacing, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
var extent = lines * spacing;
var normal = Vector3.UnitY;
var halfWidth = 0.02f;
for (var i = -lines; i <= lines; i++)
{
var offset = i * spacing;
var baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
}
@@ -0,0 +1,36 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// Factory for creating concrete graphics backends by name.
/// Backends register themselves so the app only depends on the HAL interfaces.
/// Each backend creates and owns its own window.
/// </summary>
public static class RenderBackendFactory
{
private static readonly Dictionary<string, Func<int, int, bool, IRenderContext>> _registry
= new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Register a backend implementation under the given name.
/// The factory receives (width, height, enableValidation) and must create
/// its own window and render context.
/// </summary>
public static void Register(string name, Func<int, int, bool, IRenderContext> factory)
{
_registry[name] = factory;
}
/// <summary>
/// Create a backend instance for the given name.
/// The backend assembly must have registered itself before this is called.
/// </summary>
public static IRenderContext Create(string name, int width, int height, bool enableValidation)
{
if (!_registry.TryGetValue(name, out var factory))
throw new NotSupportedException($"No graphics backend named '{name}' is registered.");
return factory(width, height, enableValidation);
}
}