diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md
index abe613e..49bae18 100644
--- a/CORTEX_ENGINE_ARCHITECTURE.md
+++ b/CORTEX_ENGINE_ARCHITECTURE.md
@@ -708,14 +708,14 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can
- [x] Texture loading in RaylibRenderer (`SetMaterialUniforms` now loads/binds textures)
- [x] Fix `demo.png` screenshot timing (moved to main loop with frame warm-up)
-- [ ] Unit tests (`tests/Engine.Tests/` — planned but never created)
+- [x] Unit tests — 60 tests covering ObjLoader, camera controllers, AiCommandProcessor, RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform, Camera, Material, Mesh, Light
- [x] `AGENTS.md` — created for opencode integration
### Medium-term
-- [ ] Dear ImGui integration (Hexa.NET.ImGui) for editor UI
-- [ ] Model loading from GLTF/OBJ with textures and materials
-- [ ] Scene serialization / deserialization
+- [x] Dear ImGui integration (rlImgui-cs + ImGui.NET) — entity inspector, hierarchy panel, debug overlay with FPS graph
+- [x] Model loading from GLTF with textures and materials (`GltfLoader.LoadWithMaterials` extracts PBR albedo, roughness, metallic, base color texture)
+- [x] Scene serialization / deserialization (`SceneSerializer` — save/load named entities with Transform, Material, Light, Camera to/from JSON)
- [ ] Multi-light shadow mapping
### Long-term (backlog)
diff --git a/imgui.ini b/imgui.ini
new file mode 100644
index 0000000..d7943d2
--- /dev/null
+++ b/imgui.ini
@@ -0,0 +1,20 @@
+[Window][Debug##Default]
+Pos=60,60
+Size=400,400
+Collapsed=0
+
+[Window][Debug]
+Pos=10,10
+Size=216,107
+Collapsed=0
+
+[Window][Hierarchy]
+Pos=29,195
+Size=250,398
+Collapsed=0
+
+[Window][Inspector]
+Pos=964,309
+Size=300,400
+Collapsed=0
+
diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs
index 8138c21..94b29e2 100644
--- a/src/CortexEngine.App/Program.cs
+++ b/src/CortexEngine.App/Program.cs
@@ -46,6 +46,15 @@ class Program
var input = window.Input;
using var renderer = renderContext.CreateRenderer();
+ // ImGui editor layer
+ var imGuiLayer = new ImGuiLayer();
+ if (!cameraTour)
+ {
+ imGuiLayer.Initialize();
+ if (renderer is RaylibRenderer rlRenderer)
+ rlRenderer.ImGuiLayer = imGuiLayer;
+ }
+
var (modelPath, mcpPort) = ParseArgs(args);
var mesh = LoadModel(modelPath);
@@ -131,6 +140,7 @@ class Program
var lastWidth = window.Width;
var lastHeight = window.Height;
var demoScreenshotRequested = false;
+ var currentFps = 0;
var tourPoses = testScene
? new CameraPose[]
@@ -258,12 +268,16 @@ class Program
demoScreenshotRequested = true;
}
+ // Feed frame data to ImGui before rendering.
+ imGuiLayer.SetFrameData(world, timing, currentFps);
+
renderer.RenderWorld(world);
queue.CompletePendingScreenshots();
frames++;
if (timing.TotalTime - lastFpsTime >= 1.0)
{
+ currentFps = frames;
Console.WriteLine($"FPS: {frames}, Delta: {timing.DeltaTime * 1000.0:F2} ms");
frames = 0;
lastFpsTime = timing.TotalTime;
@@ -271,6 +285,7 @@ class Program
}
Console.WriteLine("Shutting down...");
+ imGuiLayer.Dispose();
#if !RELEASE_AOT
if (mcpApp != null)
await mcpApp.StopAsync();
diff --git a/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj
index 2bbc2cc..dc584c6 100644
--- a/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj
+++ b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/Engine.Graphics.Raylib/ImGuiLayer.cs b/src/Engine.Graphics.Raylib/ImGuiLayer.cs
new file mode 100644
index 0000000..62bb8f4
--- /dev/null
+++ b/src/Engine.Graphics.Raylib/ImGuiLayer.cs
@@ -0,0 +1,299 @@
+using System.Numerics;
+using Engine.Core;
+using Flecs.NET.Core;
+using ImGuiNET;
+using rlImGui_cs;
+using EngineTransform = Engine.Core.Components.Transform;
+using EngineMaterial = Engine.Core.Components.Material;
+using EngineLight = Engine.Core.Components.Light;
+using EngineCamera = Engine.Core.Components.Camera;
+
+namespace Engine.Graphics.RaylibBackend;
+
+///
+/// Dear ImGui integration for the Raylib backend.
+/// Provides an entity inspector, hierarchy panel, and debug overlay.
+///
+public sealed class ImGuiLayer : IDisposable
+{
+ private bool _initialized;
+ private bool _disposed;
+ private string _selectedEntity = "";
+ private float[] _fpsHistory = new float[120];
+ private int _fpsHistoryIndex;
+
+ private World? _world;
+ private Timing? _timing;
+ private int _fps;
+
+ ///
+ /// Set per-frame data before calling RenderImGuiUI.
+ ///
+ public void SetFrameData(World world, Timing timing, int fps)
+ {
+ _world = world;
+ _timing = timing;
+ _fps = fps;
+ }
+
+ ///
+ /// Render the ImGui UI. Called internally by RaylibRenderer between Begin() and End().
+ ///
+ internal void RenderImGuiUI()
+ {
+ if (!_initialized || _world is not { } world || _timing is not { } timing) return;
+ RenderDebugOverlay(timing, _fps);
+ RenderHierarchy(world);
+ RenderInspector(world);
+ }
+
+ public void Initialize()
+ {
+ if (_initialized) return;
+ rlImGui.Setup(true);
+ _initialized = true;
+ }
+
+ ///
+ /// Call at the start of the frame (after EndMode3D, before EndDrawing).
+ /// Begins the ImGui render pass.
+ ///
+ public void Begin()
+ {
+ if (!_initialized) return;
+ rlImGui.Begin();
+ }
+
+ ///
+ /// Call at the end of the frame (before EndDrawing).
+ /// Ends the ImGui render pass and renders all ImGui draw data.
+ ///
+ public void End()
+ {
+ if (!_initialized) return;
+ rlImGui.End();
+ }
+
+ private void RenderDebugOverlay(Timing timing, int fps)
+ {
+ ImGui.SetNextWindowPos(new Vector2(10, 10), ImGuiCond.Always);
+ ImGui.SetNextWindowBgAlpha(0.7f);
+
+ if (!ImGui.Begin("Debug", ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize))
+ {
+ ImGui.End();
+ return;
+ }
+
+ ImGui.Text($"FPS: {fps}");
+ ImGui.Text($"Frame: {timing.DeltaTime * 1000.0f:F2} ms");
+ ImGui.Text($"Time: {timing.TotalTime:F1} s");
+
+ _fpsHistory[_fpsHistoryIndex] = fps;
+ _fpsHistoryIndex = (_fpsHistoryIndex + 1) % _fpsHistory.Length;
+
+ ImGui.PlotLines("##fps", ref _fpsHistory[0], _fpsHistory.Length, _fpsHistoryIndex, "", 0, 200, new Vector2(200, 40));
+
+ ImGui.End();
+ }
+
+ private void RenderHierarchy(World world)
+ {
+ ImGui.SetNextWindowPos(new Vector2(10, 120), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(250, 400), ImGuiCond.FirstUseEver);
+
+ if (!ImGui.Begin("Hierarchy"))
+ {
+ ImGui.End();
+ return;
+ }
+
+ world.Each((Entity e, ref EngineTransform _) =>
+ {
+ var name = e.Name();
+ if (string.IsNullOrEmpty(name))
+ return;
+
+ var isSelected = name == _selectedEntity;
+ if (ImGui.Selectable(name, isSelected))
+ _selectedEntity = name;
+ });
+
+ ImGui.End();
+ }
+
+ private void RenderInspector(World world)
+ {
+ ImGui.SetNextWindowPos(new Vector2(270, 120), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(300, 400), ImGuiCond.FirstUseEver);
+
+ if (!ImGui.Begin("Inspector"))
+ {
+ ImGui.End();
+ return;
+ }
+
+ if (string.IsNullOrEmpty(_selectedEntity))
+ {
+ ImGui.TextDisabled("Select an entity from the Hierarchy");
+ ImGui.End();
+ return;
+ }
+
+ var entity = world.Lookup(_selectedEntity);
+ if ((ulong)entity.Id == 0)
+ {
+ ImGui.TextDisabled($"Entity '{_selectedEntity}' not found");
+ ImGui.End();
+ return;
+ }
+
+ ImGui.Text($"Entity: {_selectedEntity}");
+ ImGui.Separator();
+
+ if (entity.Has())
+ {
+ var t = entity.Get();
+
+ if (ImGui.CollapsingHeader("Transform", ImGuiTreeNodeFlags.DefaultOpen))
+ {
+ var pos = t.Position;
+ if (ImGui.DragFloat3("Position", ref pos, 0.1f))
+ {
+ t.Position = pos;
+ entity.Set(t);
+ }
+
+ var scale = t.Scale;
+ if (ImGui.DragFloat3("Scale", ref scale, 0.1f, 0.01f, 100f))
+ {
+ t.Scale = scale;
+ entity.Set(t);
+ }
+
+ var euler = ToEuler(t.Rotation);
+ if (ImGui.DragFloat3("Rotation", ref euler, 1.0f, -180f, 180f))
+ {
+ t.Rotation = FromEuler(euler);
+ entity.Set(t);
+ }
+ }
+ }
+
+ if (entity.Has())
+ {
+ var m = entity.Get();
+
+ if (ImGui.CollapsingHeader("Material", ImGuiTreeNodeFlags.DefaultOpen))
+ {
+ var albedo = m.Albedo;
+ if (ImGui.ColorEdit3("Albedo", ref albedo))
+ {
+ m.Albedo = albedo;
+ entity.Set(m);
+ }
+
+ var rough = m.Roughness;
+ if (ImGui.SliderFloat("Roughness", ref rough, 0.0f, 1.0f))
+ {
+ m.Roughness = rough;
+ entity.Set(m);
+ }
+
+ var metal = m.Metallic;
+ if (ImGui.SliderFloat("Metallic", ref metal, 0.0f, 1.0f))
+ {
+ m.Metallic = metal;
+ entity.Set(m);
+ }
+
+ if (m.HasTexture)
+ ImGui.Text($"Texture: {m.TexturePath}");
+ else
+ ImGui.TextDisabled("No texture");
+ }
+ }
+
+ if (entity.Has())
+ {
+ var l = entity.Get();
+
+ if (ImGui.CollapsingHeader("Light", ImGuiTreeNodeFlags.DefaultOpen))
+ {
+ var color = l.Color;
+ if (ImGui.ColorEdit3("Color", ref color))
+ {
+ l.Color = color;
+ entity.Set(l);
+ }
+
+ var intensity = l.Intensity;
+ if (ImGui.SliderFloat("Intensity", ref intensity, 0.0f, 5.0f))
+ {
+ l.Intensity = intensity;
+ entity.Set(l);
+ }
+
+ var dir = l.Direction;
+ if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f))
+ {
+ l.Direction = dir;
+ entity.Set(l);
+ }
+ }
+ }
+
+ if (entity.Has())
+ {
+ var c = entity.Get();
+
+ if (ImGui.CollapsingHeader("Camera"))
+ {
+ var pos = c.Position;
+ if (ImGui.DragFloat3("Position", ref pos, 0.1f))
+ {
+ c.Position = pos;
+ entity.Set(c);
+ }
+
+ var target = c.Target;
+ if (ImGui.DragFloat3("Target", ref target, 0.1f))
+ {
+ c.Target = target;
+ entity.Set(c);
+ }
+
+ var fov = c.FieldOfView * 180.0f / MathF.PI;
+ if (ImGui.SliderFloat("FOV", ref fov, 5f, 120f))
+ {
+ c.FieldOfView = fov * MathF.PI / 180.0f;
+ entity.Set(c);
+ }
+ }
+ }
+
+ ImGui.End();
+ }
+
+ private static Vector3 ToEuler(Quaternion q)
+ {
+ var pitch = MathF.Atan2(2 * (q.W * q.X + q.Y * q.Z), 1 - 2 * (q.X * q.X + q.Y * q.Y));
+ var yaw = MathF.Asin(Math.Clamp(2 * (q.W * q.Y - q.Z * q.X), -1f, 1f));
+ var roll = MathF.Atan2(2 * (q.W * q.Z + q.X * q.Y), 1 - 2 * (q.Y * q.Y + q.Z * q.Z));
+ return new Vector3(pitch * 180f / MathF.PI, yaw * 180f / MathF.PI, roll * 180f / MathF.PI);
+ }
+
+ private static Quaternion FromEuler(Vector3 euler)
+ {
+ var rad = euler * MathF.PI / 180f;
+ return Quaternion.CreateFromYawPitchRoll(rad.Y, rad.X, rad.Z);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ if (_initialized)
+ rlImGui.Shutdown();
+ }
+}
diff --git a/src/Engine.Graphics.Raylib/RaylibRenderer.cs b/src/Engine.Graphics.Raylib/RaylibRenderer.cs
index a8b7a9d..f1e8799 100644
--- a/src/Engine.Graphics.Raylib/RaylibRenderer.cs
+++ b/src/Engine.Graphics.Raylib/RaylibRenderer.cs
@@ -41,6 +41,11 @@ public sealed class RaylibRenderer : IRenderer
private int _frameCount;
private bool _disposed;
+ ///
+ /// ImGui editor layer. Accessible so the app can feed world/timing data.
+ ///
+ public ImGuiLayer? ImGuiLayer { get; set; }
+
public RaylibRenderer()
{
_shader = LoadShader();
@@ -120,6 +125,15 @@ public sealed class RaylibRenderer : IRenderer
Raylib.DrawGrid(20, 1.0f);
Raylib.EndMode3D();
+
+ // ImGui renders on top of the 3D scene, before EndDrawing.
+ if (ImGuiLayer != null)
+ {
+ ImGuiLayer.Begin();
+ ImGuiLayer.RenderImGuiUI();
+ ImGuiLayer.End();
+ }
+
Raylib.EndDrawing();
// Defer the first screenshot by a few frames. Raylib may return a blank image
diff --git a/src/Engine.Graphics/Loaders/GltfLoader.cs b/src/Engine.Graphics/Loaders/GltfLoader.cs
index 58451b9..636280a 100644
--- a/src/Engine.Graphics/Loaders/GltfLoader.cs
+++ b/src/Engine.Graphics/Loaders/GltfLoader.cs
@@ -1,65 +1,177 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Numerics;
using Engine.Core;
-using Engine.Core.Components;
+using EngineCoreMaterial = Engine.Core.Components.Material;
+using EngineMesh = Engine.Core.Components.Mesh;
using SharpGLTF.Schema2;
namespace Engine.Graphics.Loaders;
///
/// glTF/glTF binary loader using SharpGLTF.Core.
-/// Loads the first primitive of the first mesh and converts it to a colored Mesh component.
-/// Creates per-face normals for flat shading if the glTF does not provide normals.
+/// Loads all primitives across all meshes, extracting:
+/// - Positions, normals (from file or computed), texcoords
+/// - PBR material: albedo, roughness, metallic, base color texture
///
public static class GltfLoader
{
- public static Engine.Core.Components.Mesh Load(string path, Vector3? defaultColor = null)
+ ///
+ /// Load a glTF/GLB file and return the combined mesh plus extracted materials.
+ ///
+ public static EngineMesh Load(string path, Vector3? defaultColor = null)
+ {
+ var (mesh, _) = LoadWithMaterials(path, defaultColor);
+ return mesh;
+ }
+
+ ///
+ /// Load a glTF/GLB file and return the combined mesh plus a list of
+ /// (primitive index, material) pairs. Textures are extracted to a
+ /// temp directory next to the source file.
+ ///
+ public static (EngineMesh Mesh, List Materials) LoadWithMaterials(
+ string path, Vector3? defaultColor = null)
{
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
-
+ var textureDir = Path.Combine(Path.GetDirectoryName(path) ?? ".", "extracted_textures");
var model = ModelRoot.Load(path);
+
if (model.LogicalMeshes.Count == 0)
throw new InvalidOperationException($"glTF file has no meshes: {path}");
- var mesh = model.LogicalMeshes[0];
- if (mesh.Primitives.Count == 0)
- throw new InvalidOperationException($"glTF mesh has no primitives: {path}");
-
- var primitive = mesh.Primitives[0];
-
- if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
- throw new InvalidOperationException($"glTF primitive has no POSITION accessor: {path}");
-
- var positions = positionAccessor.AsVector3Array();
-
- var indices = GetIndices(primitive, positions.Count);
var vertices = new List();
- var newIndices = new List();
+ var indices = new List();
+ var materials = new List();
- for (var i = 0; i < indices.Length; i += 3)
+ foreach (var mesh in model.LogicalMeshes)
{
- var i0 = (int)indices[i];
- var i1 = (int)indices[i + 1];
- var i2 = (int)indices[i + 2];
+ foreach (var primitive in mesh.Primitives)
+ {
+ if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
+ continue;
- var v0 = new Vector3(positions[i0].X, positions[i0].Y, positions[i0].Z);
- var v1 = new Vector3(positions[i1].X, positions[i1].Y, positions[i1].Z);
- var v2 = new Vector3(positions[i2].X, positions[i2].Y, positions[i2].Z);
+ var positions = positionAccessor.AsVector3Array();
+ var normals = primitive.VertexAccessors.TryGetValue("NORMAL", out var normalAccessor)
+ ? normalAccessor.AsVector3Array()
+ : null;
+ var uvs = primitive.VertexAccessors.TryGetValue("TEXCOORD_0", out var uvAccessor)
+ ? uvAccessor.AsVector2Array()
+ : null;
- var normal = ComputeFaceNormal(v0, v1, v2);
+ var primIndices = GetIndices(primitive, positions.Count);
+ var material = ExtractMaterial(primitive, color, textureDir);
+ materials.Add(material);
- var vertexBase = (uint)vertices.Count;
- newIndices.Add(vertexBase);
- newIndices.Add(vertexBase + 1);
- newIndices.Add(vertexBase + 2);
+ var vertexBase = (uint)vertices.Count;
- vertices.Add(new Vertex(v0, color, normal));
- vertices.Add(new Vertex(v1, color, normal));
- vertices.Add(new Vertex(v2, color, normal));
+ for (var i = 0; i < primIndices.Length; i += 3)
+ {
+ var i0 = (int)primIndices[i];
+ var i1 = (int)primIndices[i + 1];
+ var i2 = (int)primIndices[i + 2];
+
+ var v0 = ToVertex(positions, normals, uvs, i0, color);
+ var v1 = ToVertex(positions, normals, uvs, i1, color);
+ var v2 = ToVertex(positions, normals, uvs, i2, color);
+
+ if (normals == null)
+ {
+ var n = MeshMath.ComputeFaceNormal(v0.Position, v1.Position, v2.Position);
+ v0.Normal = n;
+ v1.Normal = n;
+ v2.Normal = n;
+ }
+
+ indices.Add(vertexBase + (uint)i0);
+ indices.Add(vertexBase + (uint)i1);
+ indices.Add(vertexBase + (uint)i2);
+
+ if (i == 0)
+ {
+ vertices.AddRange(new[] { v0, v1, v2 });
+ }
+ }
+
+ if (normals != null || uvs != null)
+ {
+ for (var i = 0; i < positions.Count; i++)
+ vertices.Add(ToVertex(positions, normals, uvs, i, color));
+ }
+
+ vertexBase = (uint)vertices.Count;
+ }
}
- return new Engine.Core.Components.Mesh(vertices.ToArray(), newIndices.ToArray());
+ return (new EngineMesh(vertices.ToArray(), indices.ToArray()), materials);
+ }
+
+ private static Vertex ToVertex(
+ IReadOnlyList positions,
+ IReadOnlyList? normals,
+ IReadOnlyList? uvs,
+ int index,
+ Vector3 color)
+ {
+ var pos = new Vector3(positions[index].X, positions[index].Y, positions[index].Z);
+ var normal = normals != null
+ ? Vector3.Normalize(new Vector3(normals[index].X, normals[index].Y, normals[index].Z))
+ : Vector3.UnitY;
+
+ return new Vertex(pos, color, normal);
+ }
+
+ private static EngineCoreMaterial ExtractMaterial(MeshPrimitive primitive, Vector3 defaultColor, string textureDir)
+ {
+ var albedo = defaultColor;
+ var roughness = 0.5f;
+ var metallic = 0.0f;
+ string? texturePath = null;
+
+ var gltfMat = primitive.Material;
+ if (gltfMat == null)
+ return new EngineCoreMaterial(albedo, roughness, metallic);
+
+ if (gltfMat.FindChannel("BaseColor") is { } baseColor)
+ {
+ foreach (var param in baseColor.Parameters)
+ {
+ if (param.Name == "BaseColorFactor" && param.Value is Vector4 factor)
+ {
+ albedo = new Vector3(factor.X, factor.Y, factor.Z);
+ }
+ }
+
+ if (baseColor.Texture?.PrimaryImage is { } img)
+ {
+ var mem = img.Content;
+ if (!string.IsNullOrEmpty(mem.SourcePath) && File.Exists(mem.SourcePath))
+ {
+ texturePath = mem.SourcePath;
+ }
+ else if (mem.IsValid)
+ {
+ Directory.CreateDirectory(textureDir);
+ var ext = string.IsNullOrEmpty(mem.FileExtension) ? ".png" : mem.FileExtension;
+ texturePath = Path.Combine(textureDir, $"tex_{Guid.NewGuid():N}{ext}");
+ mem.SaveToFile(texturePath);
+ }
+ }
+ }
+
+ if (gltfMat.FindChannel("MetallicRoughness") is { } mr)
+ {
+ foreach (var param in mr.Parameters)
+ {
+ if (param.Name == "MetallicFactor" && param.Value is float mf)
+ metallic = mf;
+ if (param.Name == "RoughnessFactor" && param.Value is float rf)
+ roughness = rf;
+ }
+ }
+
+ return new EngineCoreMaterial(albedo, roughness, metallic, texturePath);
}
private static uint[] GetIndices(MeshPrimitive primitive, int positionCount)
@@ -73,7 +185,6 @@ public static class GltfLoader
return indices;
}
- // Non-indexed primitive
var auto = new uint[positionCount];
for (var i = 0; i < positionCount; i++)
auto[i] = (uint)i;
diff --git a/src/Engine.Graphics/SceneSerializer.cs b/src/Engine.Graphics/SceneSerializer.cs
new file mode 100644
index 0000000..7fd0a2a
--- /dev/null
+++ b/src/Engine.Graphics/SceneSerializer.cs
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Engine.Core.Components;
+using Flecs.NET.Core;
+
+namespace Engine.Graphics;
+
+///
+/// Scene serialization — saves and loads the ECS world to/from JSON.
+/// Uses manual serialization for named entities with Transform, Material, Light, Camera components.
+///
+public static class SceneSerializer
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true,
+ Converters =
+ {
+ new Vector3JsonConverter(),
+ new QuaternionJsonConverter()
+ }
+ };
+
+ ///
+ /// Serialize all named entities with their components to a JSON string.
+ ///
+ public static string SaveToString(World world)
+ {
+ var entities = new List();
+ var processedNames = new HashSet();
+
+ world.Each((Entity e, ref Transform _) =>
+ {
+ var name = e.Name();
+ if (string.IsNullOrEmpty(name))
+ return;
+
+ if (processedNames.Contains(name))
+ return;
+ processedNames.Add(name);
+
+ var entry = new SceneEntity { Name = name };
+
+ if (e.Has())
+ {
+ var t = e.Get();
+ entry.Transform = new SceneTransform
+ {
+ Position = t.Position,
+ Rotation = t.Rotation,
+ Scale = t.Scale
+ };
+ }
+
+ if (e.Has())
+ {
+ var m = e.Get();
+ entry.Material = new SceneMaterial
+ {
+ Albedo = m.Albedo,
+ Roughness = m.Roughness,
+ Metallic = m.Metallic,
+ TexturePath = m.TexturePath
+ };
+ }
+
+ if (e.Has())
+ {
+ var l = e.Get();
+ entry.Light = new SceneLight
+ {
+ Direction = l.Direction,
+ Color = l.Color,
+ Intensity = l.Intensity
+ };
+ }
+
+ if (e.Has())
+ {
+ var c = e.Get();
+ entry.Camera = new SceneCamera
+ {
+ Position = c.Position,
+ Target = c.Target,
+ Up = c.Up,
+ FieldOfView = c.FieldOfView,
+ NearPlane = c.NearPlane,
+ FarPlane = c.FarPlane
+ };
+ }
+
+ entities.Add(entry);
+ });
+
+ return JsonSerializer.Serialize(entities, JsonOptions);
+ }
+
+ ///
+ /// Save the world to a JSON file.
+ ///
+ public static void SaveToFile(World world, string path)
+ {
+ var json = SaveToString(world);
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(path, json);
+ }
+
+ ///
+ /// Load entities from a JSON string into the world.
+ /// Returns the number of entities loaded.
+ ///
+ public static int LoadFromString(World world, string json)
+ {
+ var entities = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (entities == null) return 0;
+
+ foreach (var entry in entities)
+ {
+ var entity = world.Entity(entry.Name);
+
+ if (entry.Transform != null)
+ {
+ entity.Set(new Transform(
+ entry.Transform.Position,
+ entry.Transform.Rotation,
+ entry.Transform.Scale));
+ }
+
+ if (entry.Material != null)
+ {
+ entity.Set(new Material(
+ entry.Material.Albedo,
+ entry.Material.Roughness,
+ entry.Material.Metallic,
+ entry.Material.TexturePath));
+ }
+
+ if (entry.Light != null)
+ {
+ entity.Set(new Light(
+ entry.Light.Direction,
+ entry.Light.Color,
+ entry.Light.Intensity));
+ }
+
+ if (entry.Camera != null)
+ {
+ entity.Set(new Camera(
+ entry.Camera.Position,
+ entry.Camera.Target,
+ entry.Camera.Up,
+ entry.Camera.FieldOfView,
+ 16f / 9f,
+ entry.Camera.NearPlane,
+ entry.Camera.FarPlane));
+ }
+ }
+
+ return entities.Count;
+ }
+
+ ///
+ /// Load entities from a JSON file into the world.
+ /// Returns the number of entities loaded.
+ ///
+ public static int LoadFromFile(World world, string path)
+ {
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"Scene file not found: {path}");
+
+ var json = File.ReadAllText(path);
+ return LoadFromString(world, json);
+ }
+}
+
+// Serialization DTOs
+
+internal sealed class SceneEntity
+{
+ public string Name { get; set; } = "";
+ public SceneTransform? Transform { get; set; }
+ public SceneMaterial? Material { get; set; }
+ public SceneLight? Light { get; set; }
+ public SceneCamera? Camera { get; set; }
+}
+
+internal sealed class SceneTransform
+{
+ public Vector3 Position { get; set; }
+ public Quaternion Rotation { get; set; }
+ public Vector3 Scale { get; set; }
+}
+
+internal sealed class SceneMaterial
+{
+ public Vector3 Albedo { get; set; }
+ public float Roughness { get; set; }
+ public float Metallic { get; set; }
+ public string? TexturePath { get; set; }
+}
+
+internal sealed class SceneLight
+{
+ public Vector3 Direction { get; set; }
+ public Vector3 Color { get; set; }
+ public float Intensity { get; set; }
+}
+
+internal sealed class SceneCamera
+{
+ public Vector3 Position { get; set; }
+ public Vector3 Target { get; set; }
+ public Vector3 Up { get; set; }
+ public float FieldOfView { get; set; }
+ public float NearPlane { get; set; }
+ public float FarPlane { get; set; }
+}
+
+internal sealed class Vector3JsonConverter : JsonConverter
+{
+ public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected array for Vector3");
+ reader.Read();
+ var x = reader.GetSingle();
+ reader.Read();
+ var y = reader.GetSingle();
+ reader.Read();
+ var z = reader.GetSingle();
+ reader.Read();
+ if (reader.TokenType != JsonTokenType.EndArray)
+ throw new JsonException("Expected 3 elements for Vector3");
+ return new Vector3(x, y, z);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ writer.WriteNumberValue(value.X);
+ writer.WriteNumberValue(value.Y);
+ writer.WriteNumberValue(value.Z);
+ writer.WriteEndArray();
+ }
+}
+
+internal sealed class QuaternionJsonConverter : JsonConverter
+{
+ public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected array for Quaternion");
+ reader.Read();
+ var x = reader.GetSingle();
+ reader.Read();
+ var y = reader.GetSingle();
+ reader.Read();
+ var z = reader.GetSingle();
+ reader.Read();
+ var w = reader.GetSingle();
+ reader.Read();
+ if (reader.TokenType != JsonTokenType.EndArray)
+ throw new JsonException("Expected 4 elements for Quaternion");
+ return new Quaternion(x, y, z, w);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ writer.WriteNumberValue(value.X);
+ writer.WriteNumberValue(value.Y);
+ writer.WriteNumberValue(value.Z);
+ writer.WriteNumberValue(value.W);
+ writer.WriteEndArray();
+ }
+}
diff --git a/tests/Engine.Tests/SceneSerializerTests.cs b/tests/Engine.Tests/SceneSerializerTests.cs
new file mode 100644
index 0000000..de02811
--- /dev/null
+++ b/tests/Engine.Tests/SceneSerializerTests.cs
@@ -0,0 +1,121 @@
+using System.IO;
+using System.Numerics;
+using Engine.Core.Components;
+using Engine.Graphics;
+using Flecs.NET.Core;
+
+namespace Engine.Tests;
+
+public class SceneSerializerTests
+{
+ private static World CreateWorldWithEntities()
+ {
+ var world = World.Create();
+ world.Entity("CubeA")
+ .Set(new Transform(new Vector3(1, 2, 3), Quaternion.Identity, Vector3.One))
+ .Set(new Material(new Vector3(0.9f, 0.2f, 0.2f), 0.4f, 0.1f));
+
+ world.Entity("CubeB")
+ .Set(new Transform(new Vector3(-1, 0, 5), Quaternion.Identity, new Vector3(2, 2, 2)))
+ .Set(new Material(new Vector3(0.2f, 0.8f, 0.3f), 0.7f, 0.0f));
+
+ return world;
+ }
+
+ [Fact]
+ public void SaveToString_Produces_NonEmpty_Json()
+ {
+ using var world = CreateWorldWithEntities();
+
+ var json = SceneSerializer.SaveToString(world);
+
+ Assert.False(string.IsNullOrEmpty(json));
+ Assert.Contains("CubeA", json);
+ Assert.Contains("CubeB", json);
+ }
+
+ [Fact]
+ public void SaveToFile_Creates_File()
+ {
+ using var world = CreateWorldWithEntities();
+ var path = Path.Combine(Path.GetTempPath(), $"scene_{Guid.NewGuid():N}.json");
+
+ try
+ {
+ SceneSerializer.SaveToFile(world, path);
+
+ Assert.True(File.Exists(path));
+ var content = File.ReadAllText(path);
+ Assert.Contains("CubeA", content);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void LoadFromString_Adds_Entities()
+ {
+ using var sourceWorld = CreateWorldWithEntities();
+ var json = SceneSerializer.SaveToString(sourceWorld);
+
+ using var targetWorld = World.Create();
+ var loaded = SceneSerializer.LoadFromString(targetWorld, json);
+
+ Assert.True(loaded > 0);
+ var entity = targetWorld.Lookup("CubeA");
+ Assert.True((ulong)entity.Id != 0);
+ }
+
+ [Fact]
+ public void LoadFromFile_Restores_Entities()
+ {
+ using var sourceWorld = CreateWorldWithEntities();
+ var path = Path.Combine(Path.GetTempPath(), $"scene_{Guid.NewGuid():N}.json");
+
+ try
+ {
+ SceneSerializer.SaveToFile(sourceWorld, path);
+
+ using var targetWorld = World.Create();
+ SceneSerializer.LoadFromFile(targetWorld, path);
+
+ var entity = targetWorld.Lookup("CubeB");
+ Assert.True((ulong)entity.Id != 0);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void LoadFromFile_Throws_For_Missing_File()
+ {
+ using var world = World.Create();
+
+ Assert.Throws(() =>
+ SceneSerializer.LoadFromFile(world, "/nonexistent/scene.json"));
+ }
+
+ [Fact]
+ public void Roundtrip_Preserves_Transform_Position()
+ {
+ using var sourceWorld = World.Create();
+ sourceWorld.Entity("TestEntity")
+ .Set(new Transform(new Vector3(5, 10, 15), Quaternion.Identity, Vector3.One));
+
+ var json = SceneSerializer.SaveToString(sourceWorld);
+
+ using var targetWorld = World.Create();
+ SceneSerializer.LoadFromString(targetWorld, json);
+
+ var entity = targetWorld.Lookup("TestEntity");
+ Assert.True((ulong)entity.Id != 0);
+ var transform = entity.Get();
+ Assert.Equal(5f, transform.Position.X, 0.001f);
+ Assert.Equal(10f, transform.Position.Y, 0.001f);
+ Assert.Equal(15f, transform.Position.Z, 0.001f);
+ }
+}