feat: Dear ImGui editor, GLTF materials, scene serialization
- ImGuiLayer: entity inspector (Transform/Material/Light/Camera editing), hierarchy panel, debug overlay with FPS graph (rlImgui-cs + ImGui.NET) - GltfLoader.LoadWithMaterials: extracts PBR albedo, roughness, metallic, base color texture from glTF files - SceneSerializer: save/load ECS world to/from JSON with custom Vector3/Quaternion converters, 6 tests - ImGui disabled in tour/test mode to keep screenshots clean - 66/66 tests passing
This commit is contained in:
@@ -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] Texture loading in RaylibRenderer (`SetMaterialUniforms` now loads/binds textures)
|
||||||
- [x] Fix `demo.png` screenshot timing (moved to main loop with frame warm-up)
|
- [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
|
- [x] `AGENTS.md` — created for opencode integration
|
||||||
|
|
||||||
### Medium-term
|
### Medium-term
|
||||||
|
|
||||||
- [ ] Dear ImGui integration (Hexa.NET.ImGui) for editor UI
|
- [x] Dear ImGui integration (rlImgui-cs + ImGui.NET) — entity inspector, hierarchy panel, debug overlay with FPS graph
|
||||||
- [ ] Model loading from GLTF/OBJ with textures and materials
|
- [x] Model loading from GLTF with textures and materials (`GltfLoader.LoadWithMaterials` extracts PBR albedo, roughness, metallic, base color texture)
|
||||||
- [ ] Scene serialization / deserialization
|
- [x] Scene serialization / deserialization (`SceneSerializer` — save/load named entities with Transform, Material, Light, Camera to/from JSON)
|
||||||
- [ ] Multi-light shadow mapping
|
- [ ] Multi-light shadow mapping
|
||||||
|
|
||||||
### Long-term (backlog)
|
### Long-term (backlog)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -46,6 +46,15 @@ class Program
|
|||||||
var input = window.Input;
|
var input = window.Input;
|
||||||
using var renderer = renderContext.CreateRenderer();
|
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 (modelPath, mcpPort) = ParseArgs(args);
|
||||||
var mesh = LoadModel(modelPath);
|
var mesh = LoadModel(modelPath);
|
||||||
|
|
||||||
@@ -131,6 +140,7 @@ class Program
|
|||||||
var lastWidth = window.Width;
|
var lastWidth = window.Width;
|
||||||
var lastHeight = window.Height;
|
var lastHeight = window.Height;
|
||||||
var demoScreenshotRequested = false;
|
var demoScreenshotRequested = false;
|
||||||
|
var currentFps = 0;
|
||||||
|
|
||||||
var tourPoses = testScene
|
var tourPoses = testScene
|
||||||
? new CameraPose[]
|
? new CameraPose[]
|
||||||
@@ -258,12 +268,16 @@ class Program
|
|||||||
demoScreenshotRequested = true;
|
demoScreenshotRequested = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feed frame data to ImGui before rendering.
|
||||||
|
imGuiLayer.SetFrameData(world, timing, currentFps);
|
||||||
|
|
||||||
renderer.RenderWorld(world);
|
renderer.RenderWorld(world);
|
||||||
queue.CompletePendingScreenshots();
|
queue.CompletePendingScreenshots();
|
||||||
|
|
||||||
frames++;
|
frames++;
|
||||||
if (timing.TotalTime - lastFpsTime >= 1.0)
|
if (timing.TotalTime - lastFpsTime >= 1.0)
|
||||||
{
|
{
|
||||||
|
currentFps = frames;
|
||||||
Console.WriteLine($"FPS: {frames}, Delta: {timing.DeltaTime * 1000.0:F2} ms");
|
Console.WriteLine($"FPS: {frames}, Delta: {timing.DeltaTime * 1000.0:F2} ms");
|
||||||
frames = 0;
|
frames = 0;
|
||||||
lastFpsTime = timing.TotalTime;
|
lastFpsTime = timing.TotalTime;
|
||||||
@@ -271,6 +285,7 @@ class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("Shutting down...");
|
Console.WriteLine("Shutting down...");
|
||||||
|
imGuiLayer.Dispose();
|
||||||
#if !RELEASE_AOT
|
#if !RELEASE_AOT
|
||||||
if (mcpApp != null)
|
if (mcpApp != null)
|
||||||
await mcpApp.StopAsync();
|
await mcpApp.StopAsync();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<PackageReference Include="Raylib-cs" Version="8.0.0" />
|
<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.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="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
|
||||||
|
<PackageReference Include="rlImgui-cs" Version="3.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dear ImGui integration for the Raylib backend.
|
||||||
|
/// Provides an entity inspector, hierarchy panel, and debug overlay.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set per-frame data before calling RenderImGuiUI.
|
||||||
|
/// </summary>
|
||||||
|
public void SetFrameData(World world, Timing timing, int fps)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_timing = timing;
|
||||||
|
_fps = fps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render the ImGui UI. Called internally by RaylibRenderer between Begin() and End().
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call at the start of the frame (after EndMode3D, before EndDrawing).
|
||||||
|
/// Begins the ImGui render pass.
|
||||||
|
/// </summary>
|
||||||
|
public void Begin()
|
||||||
|
{
|
||||||
|
if (!_initialized) return;
|
||||||
|
rlImGui.Begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call at the end of the frame (before EndDrawing).
|
||||||
|
/// Ends the ImGui render pass and renders all ImGui draw data.
|
||||||
|
/// </summary>
|
||||||
|
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<EngineTransform>())
|
||||||
|
{
|
||||||
|
var t = entity.Get<EngineTransform>();
|
||||||
|
|
||||||
|
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<EngineMaterial>())
|
||||||
|
{
|
||||||
|
var m = entity.Get<EngineMaterial>();
|
||||||
|
|
||||||
|
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<EngineLight>())
|
||||||
|
{
|
||||||
|
var l = entity.Get<EngineLight>();
|
||||||
|
|
||||||
|
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<EngineCamera>())
|
||||||
|
{
|
||||||
|
var c = entity.Get<EngineCamera>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ public sealed class RaylibRenderer : IRenderer
|
|||||||
private int _frameCount;
|
private int _frameCount;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ImGui editor layer. Accessible so the app can feed world/timing data.
|
||||||
|
/// </summary>
|
||||||
|
public ImGuiLayer? ImGuiLayer { get; set; }
|
||||||
|
|
||||||
public RaylibRenderer()
|
public RaylibRenderer()
|
||||||
{
|
{
|
||||||
_shader = LoadShader();
|
_shader = LoadShader();
|
||||||
@@ -120,6 +125,15 @@ public sealed class RaylibRenderer : IRenderer
|
|||||||
Raylib.DrawGrid(20, 1.0f);
|
Raylib.DrawGrid(20, 1.0f);
|
||||||
|
|
||||||
Raylib.EndMode3D();
|
Raylib.EndMode3D();
|
||||||
|
|
||||||
|
// ImGui renders on top of the 3D scene, before EndDrawing.
|
||||||
|
if (ImGuiLayer != null)
|
||||||
|
{
|
||||||
|
ImGuiLayer.Begin();
|
||||||
|
ImGuiLayer.RenderImGuiUI();
|
||||||
|
ImGuiLayer.End();
|
||||||
|
}
|
||||||
|
|
||||||
Raylib.EndDrawing();
|
Raylib.EndDrawing();
|
||||||
|
|
||||||
// Defer the first screenshot by a few frames. Raylib may return a blank image
|
// Defer the first screenshot by a few frames. Raylib may return a blank image
|
||||||
|
|||||||
@@ -1,65 +1,177 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using Engine.Core;
|
using Engine.Core;
|
||||||
using Engine.Core.Components;
|
using EngineCoreMaterial = Engine.Core.Components.Material;
|
||||||
|
using EngineMesh = Engine.Core.Components.Mesh;
|
||||||
using SharpGLTF.Schema2;
|
using SharpGLTF.Schema2;
|
||||||
|
|
||||||
namespace Engine.Graphics.Loaders;
|
namespace Engine.Graphics.Loaders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// glTF/glTF binary loader using SharpGLTF.Core.
|
/// glTF/glTF binary loader using SharpGLTF.Core.
|
||||||
/// Loads the first primitive of the first mesh and converts it to a colored Mesh component.
|
/// Loads all primitives across all meshes, extracting:
|
||||||
/// Creates per-face normals for flat shading if the glTF does not provide normals.
|
/// - Positions, normals (from file or computed), texcoords
|
||||||
|
/// - PBR material: albedo, roughness, metallic, base color texture
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GltfLoader
|
public static class GltfLoader
|
||||||
{
|
{
|
||||||
public static Engine.Core.Components.Mesh Load(string path, Vector3? defaultColor = null)
|
/// <summary>
|
||||||
|
/// Load a glTF/GLB file and return the combined mesh plus extracted materials.
|
||||||
|
/// </summary>
|
||||||
|
public static EngineMesh Load(string path, Vector3? defaultColor = null)
|
||||||
|
{
|
||||||
|
var (mesh, _) = LoadWithMaterials(path, defaultColor);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static (EngineMesh Mesh, List<EngineCoreMaterial> Materials) LoadWithMaterials(
|
||||||
|
string path, Vector3? defaultColor = null)
|
||||||
{
|
{
|
||||||
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
|
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);
|
var model = ModelRoot.Load(path);
|
||||||
|
|
||||||
if (model.LogicalMeshes.Count == 0)
|
if (model.LogicalMeshes.Count == 0)
|
||||||
throw new InvalidOperationException($"glTF file has no meshes: {path}");
|
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<Vertex>();
|
var vertices = new List<Vertex>();
|
||||||
var newIndices = new List<uint>();
|
var indices = new List<uint>();
|
||||||
|
var materials = new List<EngineCoreMaterial>();
|
||||||
|
|
||||||
for (var i = 0; i < indices.Length; i += 3)
|
foreach (var mesh in model.LogicalMeshes)
|
||||||
{
|
{
|
||||||
var i0 = (int)indices[i];
|
foreach (var primitive in mesh.Primitives)
|
||||||
var i1 = (int)indices[i + 1];
|
{
|
||||||
var i2 = (int)indices[i + 2];
|
if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
|
||||||
|
continue;
|
||||||
|
|
||||||
var v0 = new Vector3(positions[i0].X, positions[i0].Y, positions[i0].Z);
|
var positions = positionAccessor.AsVector3Array();
|
||||||
var v1 = new Vector3(positions[i1].X, positions[i1].Y, positions[i1].Z);
|
var normals = primitive.VertexAccessors.TryGetValue("NORMAL", out var normalAccessor)
|
||||||
var v2 = new Vector3(positions[i2].X, positions[i2].Y, positions[i2].Z);
|
? 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;
|
var vertexBase = (uint)vertices.Count;
|
||||||
newIndices.Add(vertexBase);
|
|
||||||
newIndices.Add(vertexBase + 1);
|
|
||||||
newIndices.Add(vertexBase + 2);
|
|
||||||
|
|
||||||
vertices.Add(new Vertex(v0, color, normal));
|
for (var i = 0; i < primIndices.Length; i += 3)
|
||||||
vertices.Add(new Vertex(v1, color, normal));
|
{
|
||||||
vertices.Add(new Vertex(v2, color, normal));
|
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<Vector3> positions,
|
||||||
|
IReadOnlyList<Vector3>? normals,
|
||||||
|
IReadOnlyList<Vector2>? 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)
|
private static uint[] GetIndices(MeshPrimitive primitive, int positionCount)
|
||||||
@@ -73,7 +185,6 @@ public static class GltfLoader
|
|||||||
return indices;
|
return indices;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-indexed primitive
|
|
||||||
var auto = new uint[positionCount];
|
var auto = new uint[positionCount];
|
||||||
for (var i = 0; i < positionCount; i++)
|
for (var i = 0; i < positionCount; i++)
|
||||||
auto[i] = (uint)i;
|
auto[i] = (uint)i;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scene serialization — saves and loads the ECS world to/from JSON.
|
||||||
|
/// Uses manual serialization for named entities with Transform, Material, Light, Camera components.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneSerializer
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new Vector3JsonConverter(),
|
||||||
|
new QuaternionJsonConverter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize all named entities with their components to a JSON string.
|
||||||
|
/// </summary>
|
||||||
|
public static string SaveToString(World world)
|
||||||
|
{
|
||||||
|
var entities = new List<SceneEntity>();
|
||||||
|
var processedNames = new HashSet<string>();
|
||||||
|
|
||||||
|
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<Transform>())
|
||||||
|
{
|
||||||
|
var t = e.Get<Transform>();
|
||||||
|
entry.Transform = new SceneTransform
|
||||||
|
{
|
||||||
|
Position = t.Position,
|
||||||
|
Rotation = t.Rotation,
|
||||||
|
Scale = t.Scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Has<Material>())
|
||||||
|
{
|
||||||
|
var m = e.Get<Material>();
|
||||||
|
entry.Material = new SceneMaterial
|
||||||
|
{
|
||||||
|
Albedo = m.Albedo,
|
||||||
|
Roughness = m.Roughness,
|
||||||
|
Metallic = m.Metallic,
|
||||||
|
TexturePath = m.TexturePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Has<Light>())
|
||||||
|
{
|
||||||
|
var l = e.Get<Light>();
|
||||||
|
entry.Light = new SceneLight
|
||||||
|
{
|
||||||
|
Direction = l.Direction,
|
||||||
|
Color = l.Color,
|
||||||
|
Intensity = l.Intensity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Has<Camera>())
|
||||||
|
{
|
||||||
|
var c = e.Get<Camera>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save the world to a JSON file.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load entities from a JSON string into the world.
|
||||||
|
/// Returns the number of entities loaded.
|
||||||
|
/// </summary>
|
||||||
|
public static int LoadFromString(World world, string json)
|
||||||
|
{
|
||||||
|
var entities = JsonSerializer.Deserialize<List<SceneEntity>>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load entities from a JSON file into the world.
|
||||||
|
/// Returns the number of entities loaded.
|
||||||
|
/// </summary>
|
||||||
|
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<Vector3>
|
||||||
|
{
|
||||||
|
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<Quaternion>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileNotFoundException>(() =>
|
||||||
|
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<Transform>();
|
||||||
|
Assert.Equal(5f, transform.Position.X, 0.001f);
|
||||||
|
Assert.Equal(10f, transform.Position.Y, 0.001f);
|
||||||
|
Assert.Equal(15f, transform.Position.Z, 0.001f);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user