feat: modular HAL, Raylib backend, PBR shading, textures, 60 unit tests
- Replace hardcoded SDL3 windowing with IWindow/IInputState/Key abstractions - Each render backend owns its window (Raylib GLFW, SDL3 for Vulkan) - Raylib backend: DrawModelEx, custom GLSL shader with Fresnel, ACES tonemapping, gamma correction, hemisphere ambient - Fix backface culling, mesh memory (NativeMemory.Alloc), texture loading - Camera controllers use backend-agnostic Key enum (inverted yaw/strafe) - Demo scene: 8 cubes, 7 spheres, torus knot OBJ with checker texture - Extract ProceduralMesh + MeshMath from Program.cs to Engine.Graphics - Vulkan backend deferred (compiles, untested, IWindow-compatible) - 60 unit tests: ObjLoader, camera controllers, AiCommandProcessor, RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform - AGENTS.md for opencode integration
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
using Engine.AI;
|
||||
using Flecs.NET.Core;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class AiCommandProcessorTests
|
||||
{
|
||||
private static (AiCommandProcessor, World) CreateProcessor()
|
||||
{
|
||||
var world = World.Create();
|
||||
var dummyMesh = new Mesh(
|
||||
new[] { new Vertex(new Vector3(0, 0, 0), Vector3.One, Vector3.UnitY) },
|
||||
new uint[] { 0 });
|
||||
var processor = new AiCommandProcessor(
|
||||
world,
|
||||
_ => dummyMesh,
|
||||
_ => { });
|
||||
return (processor, world);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpawnModel_Creates_Entity_With_Transform()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
|
||||
var result = processor.Process("""
|
||||
{ "type": "spawn_model", "name": "TestCube", "modelPath": "fake.obj", "position": [1, 2, 3] }
|
||||
""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
var entity = world.Lookup("TestCube");
|
||||
Assert.True((ulong)entity.Id != 0);
|
||||
var transform = entity.Get<Transform>();
|
||||
Assert.Equal(new Vector3(1, 2, 3), transform.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTransform_Updates_Position()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj" }""");
|
||||
|
||||
var result = processor.Process("""
|
||||
{ "type": "set_transform", "name": "Test", "position": [5, 5, 5] }
|
||||
""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
var transform = world.Lookup("Test").Get<Transform>();
|
||||
Assert.Equal(new Vector3(5, 5, 5), transform.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTransform_Partial_Update_Keeps_Other_Fields()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj", "position": [1, 1, 1], "scale": [2, 2, 2] }""");
|
||||
|
||||
processor.Process("""{ "type": "set_transform", "name": "Test", "position": [9, 9, 9] }""");
|
||||
|
||||
var transform = world.Lookup("Test").Get<Transform>();
|
||||
Assert.Equal(new Vector3(9, 9, 9), transform.Position);
|
||||
Assert.Equal(new Vector3(2, 2, 2), transform.Scale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetMaterial_Updates_Albedo_And_Roughness()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "Test", "modelPath": "x.obj" }""");
|
||||
|
||||
var result = processor.Process("""
|
||||
{ "type": "set_material", "name": "Test", "albedo": [1, 0, 0], "roughness": 0.8 }
|
||||
""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
var mat = world.Lookup("Test").Get<Material>();
|
||||
Assert.Equal(new Vector3(1, 0, 0), mat.Albedo);
|
||||
Assert.Equal(0.8f, mat.Roughness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteEntity_Removes_Entity()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "ToDelete", "modelPath": "x.obj" }""");
|
||||
|
||||
var result = processor.Process("""{ "type": "delete_entity", "name": "ToDelete" }""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True((ulong)world.Lookup("ToDelete").Id == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListEntities_Returns_Names()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "Alpha", "modelPath": "x.obj" }""");
|
||||
processor.Process("""{ "type": "spawn_model", "name": "Beta", "modelPath": "x.obj" }""");
|
||||
|
||||
var result = processor.Process("""{ "type": "list_entities" }""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("Alpha", result.Message);
|
||||
Assert.Contains("Beta", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureScreenshot_Calls_Callback()
|
||||
{
|
||||
var world = World.Create();
|
||||
var capturedPath = "";
|
||||
var processor = new AiCommandProcessor(
|
||||
world,
|
||||
_ => new Mesh(new[] { new Vertex(Vector3.Zero, Vector3.One, Vector3.UnitY) }, new uint[] { 0 }),
|
||||
path => capturedPath = path);
|
||||
|
||||
var result = processor.Process("""{ "type": "capture_screenshot", "outputPath": "test.png" }""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("test.png", capturedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWorldState_Returns_Json()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
processor.Process("""{ "type": "spawn_model", "name": "StateTest", "modelPath": "x.obj", "position": [1, 2, 3] }""");
|
||||
|
||||
var result = processor.Process("""{ "type": "get_world_state" }""");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("StateTest", result.Message);
|
||||
Assert.Contains("position", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTransform_On_Nonexistent_Entity_Returns_Error()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
|
||||
var result = processor.Process("""{ "type": "set_transform", "name": "Ghost", "position": [0, 0, 0] }""");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_JSON_Returns_Error()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
|
||||
var result = processor.Process("not valid json");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessBatch_Handles_Multiple_Commands()
|
||||
{
|
||||
var (processor, world) = CreateProcessor();
|
||||
|
||||
var results = processor.ProcessBatch("""
|
||||
{ "type": "spawn_model", "name": "A", "modelPath": "x.obj" }
|
||||
{ "type": "spawn_model", "name": "B", "modelPath": "x.obj" }
|
||||
""");
|
||||
|
||||
Assert.Equal(2, results.Length);
|
||||
Assert.True(results[0].Success);
|
||||
Assert.True(results[1].Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
using Flecs.NET.Core;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IInputState — set properties before calling controller.Update().
|
||||
/// </summary>
|
||||
internal sealed class FakeInputState : IInputState
|
||||
{
|
||||
private readonly HashSet<Key> _down = new();
|
||||
private readonly HashSet<Key> _pressed = new();
|
||||
|
||||
public int MouseX { get; set; }
|
||||
public int MouseY { get; set; }
|
||||
public bool MouseLeft { get; set; }
|
||||
public bool MouseRight { get; set; }
|
||||
public bool MouseMiddle { get; set; }
|
||||
public float MouseWheelDelta { get; set; }
|
||||
|
||||
public void SetKeyDown(Key key) => _down.Add(key);
|
||||
public void SetKeyPressed(Key key)
|
||||
{
|
||||
_down.Add(key);
|
||||
_pressed.Add(key);
|
||||
}
|
||||
|
||||
public bool IsKeyDown(Key key) => _down.Contains(key);
|
||||
public bool IsKeyPressed(Key key) => _pressed.Contains(key);
|
||||
public bool IsKeyReleased(Key key) => false;
|
||||
|
||||
public void BeginFrame()
|
||||
{
|
||||
_pressed.Clear();
|
||||
MouseWheelDelta = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public class FreeFlyCameraControllerTests
|
||||
{
|
||||
private static (FreeFlyCameraController, Entity) CreateController(float yaw = 0f, float pitch = 0f)
|
||||
{
|
||||
var world = World.Create();
|
||||
var pos = new Vector3(0, 1, -10);
|
||||
var dir = new Vector3(
|
||||
MathF.Cos(pitch) * MathF.Sin(yaw),
|
||||
-MathF.Sin(pitch),
|
||||
MathF.Cos(pitch) * MathF.Cos(yaw));
|
||||
var cam = new Camera(pos, pos + dir, Vector3.UnitY);
|
||||
var entity = world.Entity("TestCamera").Set(cam);
|
||||
return (new FreeFlyCameraController(entity), entity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void W_Moves_Forward()
|
||||
{
|
||||
var (controller, entity) = CreateController(yaw: 0f);
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.W);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.True(cam.Position.Z > -10f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S_Moves_Backward()
|
||||
{
|
||||
var (controller, entity) = CreateController(yaw: 0f);
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.S);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.True(cam.Position.Z < -10f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shift_Boosts_Speed()
|
||||
{
|
||||
var (controller, entity) = CreateController(yaw: 0f);
|
||||
var inputNormal = new FakeInputState();
|
||||
inputNormal.SetKeyDown(Key.W);
|
||||
controller.Update(inputNormal, 1.0f);
|
||||
var normalPos = entity.Get<Camera>().Position;
|
||||
|
||||
var (controller2, entity2) = CreateController(yaw: 0f);
|
||||
var inputBoost = new FakeInputState();
|
||||
inputBoost.SetKeyDown(Key.W);
|
||||
inputBoost.SetKeyDown(Key.LeftShift);
|
||||
controller2.Update(inputBoost, 1.0f);
|
||||
var boostPos = entity2.Get<Camera>().Position;
|
||||
|
||||
Assert.True(boostPos.Z > normalPos.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Q_Moves_Down()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.Q);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.True(cam.Position.Y < 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void E_Moves_Up()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.E);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.True(cam.Position.Y > 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mouse_Wheel_Adjusts_Speed()
|
||||
{
|
||||
var (controller, entity) = CreateController(yaw: 0f);
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.W);
|
||||
input.MouseWheelDelta = 10f;
|
||||
|
||||
controller.Update(input, 0.1f);
|
||||
|
||||
input.BeginFrame();
|
||||
input.SetKeyDown(Key.W);
|
||||
controller.Update(input, 1.0f);
|
||||
var cam = entity.Get<Camera>();
|
||||
|
||||
// With boosted speed, movement should be much larger than default 3 units
|
||||
Assert.True(cam.Position.Z > -7f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_Follows_Position()
|
||||
{
|
||||
var (controller, entity) = CreateController(yaw: 0f);
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.W);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
var dir = Vector3.Normalize(cam.Target - cam.Position);
|
||||
Assert.Equal(0f, dir.X, 0.01f);
|
||||
Assert.Equal(0f, dir.Y, 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrbitCameraControllerTests
|
||||
{
|
||||
private static (OrbitCameraController, Entity) CreateController()
|
||||
{
|
||||
var world = World.Create();
|
||||
var target = new Vector3(0, 0.5f, 0);
|
||||
var pos = new Vector3(0, 0.5f, -10);
|
||||
var cam = new Camera(pos, target, Vector3.UnitY);
|
||||
var entity = world.Entity("TestOrbitCamera").Set(cam);
|
||||
return (new OrbitCameraController(entity, target), entity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void W_Moves_Target_Forward()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.W);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.True(cam.Target.Z > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zoom_Decreases_Distance()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.MouseWheelDelta = 1f;
|
||||
|
||||
controller.Update(input, 0.1f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
var dist = Vector3.Distance(cam.Position, cam.Target);
|
||||
Assert.True(dist < 10f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Camera_Position_Orbits_Target()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.MouseRight = true;
|
||||
input.MouseX = 100;
|
||||
input.MouseY = 100;
|
||||
|
||||
controller.Update(input, 0.1f);
|
||||
|
||||
input.BeginFrame();
|
||||
input.MouseRight = true;
|
||||
input.MouseX = 200;
|
||||
input.MouseY = 100;
|
||||
controller.Update(input, 0.1f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
var dist = Vector3.Distance(cam.Position, cam.Target);
|
||||
Assert.Equal(10f, dist, 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_Stays_At_Ground_Level_With_WASD()
|
||||
{
|
||||
var (controller, entity) = CreateController();
|
||||
var input = new FakeInputState();
|
||||
input.SetKeyDown(Key.W);
|
||||
|
||||
controller.Update(input, 1.0f);
|
||||
|
||||
var cam = entity.Get<Camera>();
|
||||
Assert.Equal(0.5f, cam.Target.Y, 0.001f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core.Components;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class CameraTests
|
||||
{
|
||||
[Fact]
|
||||
public void View_Matrix_Transforms_Position_To_Origin()
|
||||
{
|
||||
var cam = new Camera(
|
||||
new Vector3(0, 0, 10),
|
||||
new Vector3(0, 0, 0),
|
||||
Vector3.UnitY,
|
||||
MathF.PI / 4f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
100f);
|
||||
|
||||
var view = cam.GetViewMatrix();
|
||||
var originInCameraSpace = Vector3.Transform(new Vector3(0, 0, 0), view);
|
||||
|
||||
Assert.Equal(0f, originInCameraSpace.X, 0.001f);
|
||||
Assert.Equal(0f, originInCameraSpace.Y, 0.001f);
|
||||
Assert.Equal(-10f, originInCameraSpace.Z, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_Matrix_Has_Correct_Aspect_Ratio()
|
||||
{
|
||||
var cam = new Camera(
|
||||
Vector3.Zero,
|
||||
Vector3.UnitZ,
|
||||
Vector3.UnitY,
|
||||
MathF.PI / 4f,
|
||||
16f / 9f,
|
||||
0.1f,
|
||||
100f);
|
||||
|
||||
var proj = cam.GetProjectionMatrix();
|
||||
|
||||
Assert.True(proj.M11 > 0);
|
||||
Assert.True(proj.M22 > 0);
|
||||
Assert.Equal(0f, proj.M41, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_Material_Has_Expected_Values()
|
||||
{
|
||||
var mat = Material.Default;
|
||||
|
||||
Assert.Equal(0.5f, mat.Roughness);
|
||||
Assert.Equal(0.0f, mat.Metallic);
|
||||
Assert.False(mat.HasTexture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Material_With_Texture_Path_Has_Texture_Flag()
|
||||
{
|
||||
var mat = new Material(texturePath: "Content/test.png");
|
||||
|
||||
Assert.True(mat.HasTexture);
|
||||
Assert.Equal("Content/test.png", mat.TexturePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Engine.Core\Engine.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Engine.Graphics\Engine.Graphics.csproj" />
|
||||
<ProjectReference Include="..\..\src\Engine.AI\Engine.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class MeshAndLightTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mesh_Stores_Vertices_And_Indices()
|
||||
{
|
||||
var vertices = new[]
|
||||
{
|
||||
new Vertex(new Vector3(0, 0, 0), Vector3.One, Vector3.UnitY),
|
||||
new Vertex(new Vector3(1, 0, 0), Vector3.One, Vector3.UnitY),
|
||||
new Vertex(new Vector3(1, 1, 0), Vector3.One, Vector3.UnitY),
|
||||
};
|
||||
var indices = new uint[] { 0, 1, 2 };
|
||||
var mesh = new Mesh(vertices, indices);
|
||||
|
||||
Assert.Equal(3, mesh.Vertices.Length);
|
||||
Assert.Equal(3, mesh.Indices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Light_Direction_Is_Normalized()
|
||||
{
|
||||
var light = new Light(new Vector3(0, 2, 0), Vector3.One, 1.0f);
|
||||
|
||||
Assert.Equal(1f, light.Direction.Length(), 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Light_With_Zero_Direction_Defaults_To_UnitY()
|
||||
{
|
||||
var light = new Light(Vector3.Zero, Vector3.One, 1.0f);
|
||||
|
||||
Assert.Equal(Vector3.UnitY, light.Direction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Graphics;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class MeshMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Computes_Normal_For_CCW_Triangle()
|
||||
{
|
||||
var n = MeshMath.ComputeFaceNormal(
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(1, 0, 0),
|
||||
new Vector3(0, 1, 0));
|
||||
|
||||
Assert.Equal(0f, n.X, 0.001f);
|
||||
Assert.Equal(0f, n.Y, 0.001f);
|
||||
Assert.Equal(1f, n.Z, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normal_Is_Unit_Length()
|
||||
{
|
||||
var n = MeshMath.ComputeFaceNormal(
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(3, 0, 0),
|
||||
new Vector3(0, 4, 0));
|
||||
|
||||
Assert.Equal(1f, n.Length(), 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Degenerate_Triangle_Falls_Back_To_UnitY()
|
||||
{
|
||||
var n = MeshMath.ComputeFaceNormal(
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(1, 0, 0),
|
||||
new Vector3(2, 0, 0));
|
||||
|
||||
Assert.Equal(Vector3.UnitY, n);
|
||||
}
|
||||
}
|
||||
|
||||
public class ProceduralMeshTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sphere_Has_Correct_Vertex_Count()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateSphere(1f, 16, 8, Vector3.One);
|
||||
|
||||
Assert.Equal((8 + 1) * (16 + 1), mesh.Vertices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sphere_Has_Correct_Index_Count()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateSphere(1f, 16, 8, Vector3.One);
|
||||
|
||||
Assert.Equal(8 * 16 * 6, mesh.Indices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sphere_Vertices_Lie_On_Surface()
|
||||
{
|
||||
const float radius = 2.5f;
|
||||
var mesh = ProceduralMesh.CreateSphere(radius, 8, 4, Vector3.One);
|
||||
|
||||
foreach (var v in mesh.Vertices)
|
||||
Assert.Equal(radius, v.Position.Length(), 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sphere_Normals_Are_Unit_Length()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateSphere(1f, 8, 4, Vector3.One);
|
||||
|
||||
foreach (var v in mesh.Vertices)
|
||||
Assert.Equal(1f, v.Normal.Length(), 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sphere_Top_Pole_At_Positive_Y()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateSphere(1f, 8, 4, Vector3.One);
|
||||
|
||||
Assert.Equal(1f, mesh.Vertices[0].Position.Y, 0.001f);
|
||||
Assert.Equal(0f, mesh.Vertices[0].Position.X, 0.001f);
|
||||
Assert.Equal(0f, mesh.Vertices[0].Position.Z, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_Has_Correct_Vertex_Count()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateGrid(5, 1f, Vector3.One);
|
||||
|
||||
var expectedLines = 2 * 5 + 1;
|
||||
Assert.Equal(expectedLines * 4 * 2, mesh.Vertices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_All_Normals_Point_Up()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateGrid(3, 1f, Vector3.One);
|
||||
|
||||
foreach (var v in mesh.Vertices)
|
||||
Assert.Equal(Vector3.UnitY, v.Normal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_Extent_Matches_Lines_And_Spacing()
|
||||
{
|
||||
var mesh = ProceduralMesh.CreateGrid(10, 2f, Vector3.One);
|
||||
|
||||
var maxPos = 10f * 2f;
|
||||
Assert.True(mesh.Vertices.Any(v => v.Position.X <= -maxPos));
|
||||
Assert.True(mesh.Vertices.Any(v => v.Position.X >= maxPos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
using Engine.Graphics.Loaders;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class ObjLoaderTests
|
||||
{
|
||||
private static readonly string TempDir = Path.Combine(Path.GetTempPath(), "CortexEngineTests");
|
||||
private static string WriteTempObj(string content)
|
||||
{
|
||||
Directory.CreateDirectory(TempDir);
|
||||
var path = Path.Combine(TempDir, $"test_{Guid.NewGuid():N}.obj");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loads_Single_Triangle()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
f 1 2 3
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path, new Vector3(1, 1, 1));
|
||||
|
||||
Assert.Equal(3, mesh.Vertices.Length);
|
||||
Assert.Equal(3, mesh.Indices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Triangulates_Quad_As_Fan()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 1 1 0
|
||||
v 0 1 0
|
||||
f 1 2 3 4
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
Assert.Equal(6, mesh.Vertices.Length);
|
||||
Assert.Equal(6, mesh.Indices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_Face_With_Texcoord_Format()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 0 1
|
||||
f 1/1 2/2 3/3
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
Assert.Equal(3, mesh.Vertices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_Face_With_Normal_Format()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
vn 0 0 1
|
||||
f 1//1 2//1 3//1
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
Assert.Equal(3, mesh.Vertices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Computes_Face_Normal_For_Triangle()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
f 1 2 3
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
var normal = mesh.Vertices[0].Normal;
|
||||
Assert.Equal(0f, normal.X, 0.001f);
|
||||
Assert.Equal(0f, normal.Y, 0.001f);
|
||||
Assert.Equal(1f, normal.Z, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_Comments_And_Blank_Lines()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
# This is a comment
|
||||
|
||||
v 0 0 0
|
||||
# Another comment
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
|
||||
f 1 2 3
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
Assert.Equal(3, mesh.Vertices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_On_Empty_File()
|
||||
{
|
||||
var path = WriteTempObj("# just a comment\n");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ObjLoader.Load(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_Color_When_Not_Specified()
|
||||
{
|
||||
var path = WriteTempObj("""
|
||||
v 0 0 0
|
||||
v 1 0 0
|
||||
v 0 1 0
|
||||
f 1 2 3
|
||||
""");
|
||||
|
||||
var mesh = ObjLoader.Load(path);
|
||||
|
||||
Assert.Equal(0.7f, mesh.Vertices[0].Color.X, 0.001f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Engine.Core;
|
||||
using Engine.Graphics;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class RenderBackendFactoryTests
|
||||
{
|
||||
private sealed class FakeRenderContext : IRenderContext
|
||||
{
|
||||
public IWindow Window => null!;
|
||||
public IRenderer CreateRenderer() => null!;
|
||||
public void Resize(int width, int height) { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_Returns_Registered_Backend()
|
||||
{
|
||||
RenderBackendFactory.Register("fake-test", (_, _, _) => new FakeRenderContext());
|
||||
|
||||
var ctx = RenderBackendFactory.Create("fake-test", 800, 600, false);
|
||||
|
||||
Assert.IsType<FakeRenderContext>(ctx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_Throws_For_Unknown_Backend()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
RenderBackendFactory.Create("nonexistent", 800, 600, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_Is_Case_Insensitive()
|
||||
{
|
||||
RenderBackendFactory.Register("CaseTest", (_, _, _) => new FakeRenderContext());
|
||||
|
||||
var ctx = RenderBackendFactory.Create("casetest", 1, 1, false);
|
||||
Assert.IsType<FakeRenderContext>(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Engine.Core;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class TimingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tick_Updates_DeltaTime()
|
||||
{
|
||||
var timing = new Timing();
|
||||
|
||||
timing.Tick();
|
||||
|
||||
Assert.True(timing.DeltaTime > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_Updates_TotalTime()
|
||||
{
|
||||
var timing = new Timing();
|
||||
|
||||
timing.Tick();
|
||||
var time1 = timing.TotalTime;
|
||||
timing.Tick();
|
||||
var time2 = timing.TotalTime;
|
||||
|
||||
Assert.True(time2 > time1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumeFixedStep_Returns_True_When_Accumulator_Exceeds_Step()
|
||||
{
|
||||
var timing = new Timing { FixedTimeStep = 0.001 };
|
||||
|
||||
timing.Tick();
|
||||
Thread.Sleep(5);
|
||||
timing.Tick();
|
||||
|
||||
Assert.True(timing.ConsumeFixedStep());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumeFixedStep_Returns_False_When_Below_Step()
|
||||
{
|
||||
var timing = new Timing { FixedTimeStep = 100.0 };
|
||||
|
||||
timing.Tick();
|
||||
|
||||
Assert.False(timing.ConsumeFixedStep());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAccumulator_Clamps_To_Max()
|
||||
{
|
||||
var timing = new Timing { FixedTimeStep = 0.1 };
|
||||
|
||||
// Simulate a huge delta by ticking many times without consuming
|
||||
for (var i = 0; i < 1000; i++)
|
||||
timing.Tick();
|
||||
|
||||
timing.ResetAccumulator();
|
||||
|
||||
Assert.True(timing.FixedTimeAccumulator <= timing.FixedTimeStep * 5 + 0.001f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Numerics;
|
||||
using Engine.Core.Components;
|
||||
|
||||
namespace Engine.Tests;
|
||||
|
||||
public class TransformTests
|
||||
{
|
||||
[Fact]
|
||||
public void Identity_Transform_Produces_Identity_Matrix()
|
||||
{
|
||||
var t = new Transform(Vector3.Zero, Quaternion.Identity, Vector3.One);
|
||||
var m = t.GetMatrix();
|
||||
|
||||
Assert.Equal(Matrix4x4.Identity, m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Translation_Appears_In_Matrix()
|
||||
{
|
||||
var t = new Transform(new Vector3(1, 2, 3), Quaternion.Identity, Vector3.One);
|
||||
var m = t.GetMatrix();
|
||||
|
||||
Assert.Equal(1f, m.M41);
|
||||
Assert.Equal(2f, m.M42);
|
||||
Assert.Equal(3f, m.M43);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scale_Affects_Matrix_Diagonal()
|
||||
{
|
||||
var t = new Transform(Vector3.Zero, Quaternion.Identity, new Vector3(2, 3, 4));
|
||||
var m = t.GetMatrix();
|
||||
|
||||
Assert.Equal(2f, m.M11);
|
||||
Assert.Equal(3f, m.M22);
|
||||
Assert.Equal(4f, m.M33);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rotation_Around_Y_Rotates_X_Axis()
|
||||
{
|
||||
var angle = MathF.PI / 2f;
|
||||
var rot = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angle);
|
||||
var t = new Transform(Vector3.Zero, rot, Vector3.One);
|
||||
var m = t.GetMatrix();
|
||||
|
||||
var xAxis = new Vector3(m.M11, m.M21, m.M31);
|
||||
Assert.Equal(0f, xAxis.X, 0.001f);
|
||||
Assert.Equal(0f, xAxis.Y, 0.001f);
|
||||
Assert.Equal(1f, xAxis.Z, 0.001f);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user