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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user