chore: remove all graphics backends (Raylib, OpenTK, Vulkan/Silk.NET) — prepare for pure Vulkan P/Invoke rewrite
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>DEV_MODE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
|
||||
<DefineConstants>RELEASE_AOT</DefineConstants>
|
||||
<PublishAot>true</PublishAot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release' OR '$(Configuration)' == 'ReleaseAOT'" />
|
||||
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,27 +0,0 @@
|
||||
using Engine.Core;
|
||||
|
||||
namespace Engine.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over a graphics backend (Vulkan, Raylib, etc.).
|
||||
/// Each backend owns its window and surface. The application retrieves
|
||||
/// the window via <see cref="Window"/> for input and event polling.
|
||||
/// </summary>
|
||||
public interface IRenderContext : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The window owned by this backend. The application uses this for
|
||||
/// input polling, resize detection, and close requests.
|
||||
/// </summary>
|
||||
IWindow Window { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a renderer that can draw the ECS world using this backend.
|
||||
/// </summary>
|
||||
IRenderer CreateRenderer();
|
||||
|
||||
/// <summary>
|
||||
/// Notify the backend that the output surface has been resized.
|
||||
/// </summary>
|
||||
void Resize(int width, int height);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Engine.Core;
|
||||
using Flecs.NET.Core;
|
||||
|
||||
namespace Engine.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the ECS world and exposes screenshot capture.
|
||||
/// Implemented by concrete graphics backends.
|
||||
/// </summary>
|
||||
public interface IRenderer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Render one frame of the ECS world and present it.
|
||||
/// </summary>
|
||||
void RenderWorld(World world);
|
||||
|
||||
/// <summary>
|
||||
/// Request a screenshot of the next rendered frame to be saved to disk.
|
||||
/// </summary>
|
||||
void RequestScreenshot(string outputPath);
|
||||
|
||||
/// <summary>
|
||||
/// True if a screenshot has been requested but not yet captured.
|
||||
/// </summary>
|
||||
bool IsScreenshotRequested { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider that can asynchronously capture the current frame to PNG bytes.
|
||||
/// </summary>
|
||||
IScreenshotProvider ScreenshotProvider { get; }
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using EngineCoreMaterial = Engine.Core.Components.Material;
|
||||
using EngineMesh = Engine.Core.Components.Mesh;
|
||||
using SharpGLTF.Schema2;
|
||||
|
||||
namespace Engine.Graphics.Loaders;
|
||||
|
||||
/// <summary>
|
||||
/// glTF/glTF binary loader using SharpGLTF.Core.
|
||||
/// Loads all primitives across all meshes, extracting:
|
||||
/// - Positions, normals (from file or computed), texcoords
|
||||
/// - PBR material: albedo, roughness, metallic, base color texture
|
||||
/// </summary>
|
||||
public static class GltfLoader
|
||||
{
|
||||
/// <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 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 vertices = new List<Vertex>();
|
||||
var indices = new List<uint>();
|
||||
var materials = new List<EngineCoreMaterial>();
|
||||
|
||||
foreach (var mesh in model.LogicalMeshes)
|
||||
{
|
||||
foreach (var primitive in mesh.Primitives)
|
||||
{
|
||||
if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
|
||||
continue;
|
||||
|
||||
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 primIndices = GetIndices(primitive, positions.Count);
|
||||
var material = ExtractMaterial(primitive, color, textureDir);
|
||||
materials.Add(material);
|
||||
|
||||
var vertexBase = (uint)vertices.Count;
|
||||
|
||||
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 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)
|
||||
{
|
||||
if (primitive.IndexAccessor != null)
|
||||
{
|
||||
var idx = primitive.IndexAccessor.AsIndexArray();
|
||||
var indices = new uint[idx.Count];
|
||||
for (var i = 0; i < idx.Count; i++)
|
||||
indices[i] = idx[i];
|
||||
return indices;
|
||||
}
|
||||
|
||||
var auto = new uint[positionCount];
|
||||
for (var i = 0; i < positionCount; i++)
|
||||
auto[i] = (uint)i;
|
||||
return auto;
|
||||
}
|
||||
|
||||
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
|
||||
=> MeshMath.ComputeFaceNormal(a, b, c);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
|
||||
namespace Engine.Graphics.Loaders;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal .obj loader.
|
||||
/// Supports vertices (v) and faces (f). Creates per-face normals for flat shading.
|
||||
/// Produces a colored Mesh component.
|
||||
/// </summary>
|
||||
public static class ObjLoader
|
||||
{
|
||||
public static Mesh Load(string path, Vector3? defaultColor = null)
|
||||
{
|
||||
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
|
||||
|
||||
var positions = new List<Vector3>();
|
||||
var vertices = new List<Vertex>();
|
||||
var indices = new List<uint>();
|
||||
|
||||
foreach (var rawLine in File.ReadLines(path))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrEmpty(line) || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
continue;
|
||||
|
||||
switch (parts[0])
|
||||
{
|
||||
case "v" when parts.Length >= 4:
|
||||
positions.Add(new Vector3(
|
||||
float.Parse(parts[1]),
|
||||
float.Parse(parts[2]),
|
||||
float.Parse(parts[3])));
|
||||
break;
|
||||
|
||||
case "f" when parts.Length >= 4:
|
||||
// Triangulate the face as a fan.
|
||||
var baseIndex = ParseFaceIndex(parts[1]);
|
||||
for (var i = 2; i < parts.Length - 1; i++)
|
||||
{
|
||||
var i0 = baseIndex;
|
||||
var i1 = ParseFaceIndex(parts[i]);
|
||||
var i2 = ParseFaceIndex(parts[i + 1]);
|
||||
|
||||
var v0 = positions[(int)i0];
|
||||
var v1 = positions[(int)i1];
|
||||
var v2 = positions[(int)i2];
|
||||
var normal = ComputeFaceNormal(v0, v1, v2);
|
||||
|
||||
var vertexBase = (uint)vertices.Count;
|
||||
indices.Add(vertexBase);
|
||||
indices.Add(vertexBase + 1);
|
||||
indices.Add(vertexBase + 2);
|
||||
|
||||
vertices.Add(new Vertex(v0, color, normal));
|
||||
vertices.Add(new Vertex(v1, color, normal));
|
||||
vertices.Add(new Vertex(v2, color, normal));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.Count == 0)
|
||||
throw new InvalidOperationException($"OBJ file has no vertices: {path}");
|
||||
|
||||
return new Mesh(vertices.ToArray(), indices.ToArray());
|
||||
}
|
||||
|
||||
private static uint ParseFaceIndex(string part)
|
||||
{
|
||||
// Formats: v, v/vt, v/vt/vn, v//vn
|
||||
var slashIndex = part.IndexOf('/');
|
||||
var indexStr = slashIndex == -1 ? part : part.Substring(0, slashIndex);
|
||||
var index = int.Parse(indexStr);
|
||||
return (uint)(index - 1); // OBJ indices are 1-based
|
||||
}
|
||||
|
||||
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
|
||||
=> MeshMath.ComputeFaceNormal(a, b, c);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace Engine.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared mesh math utilities used by loaders and procedural generators.
|
||||
/// </summary>
|
||||
public static class MeshMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute a flat face normal from three vertex positions.
|
||||
/// Falls back to Vector3.UnitY for degenerate (zero-area) triangles.
|
||||
/// </summary>
|
||||
public static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
|
||||
{
|
||||
var ab = b - a;
|
||||
var ac = c - a;
|
||||
// Cross(ac, ab) instead of Cross(ab, ac) to match CW winding in typical OBJ files
|
||||
var normal = Vector3.Cross(ac, ab);
|
||||
if (normal.LengthSquared() > 0.00001f)
|
||||
normal = Vector3.Normalize(normal);
|
||||
else
|
||||
normal = Vector3.UnitY;
|
||||
return normal;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using Engine.Core;
|
||||
using Engine.Core.Components;
|
||||
|
||||
namespace Engine.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Procedural mesh generators for common primitive shapes.
|
||||
/// All methods are pure CPU — no GPU/display dependencies.
|
||||
/// </summary>
|
||||
public static class ProceduralMesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a UV sphere mesh.
|
||||
/// </summary>
|
||||
/// <param name="radius">Sphere radius.</param>
|
||||
/// <param name="segments">Longitude segments (around the equator).</param>
|
||||
/// <param name="rings">Latitude rings (from pole to pole).</param>
|
||||
/// <param name="color">Vertex color applied to all vertices.</param>
|
||||
public static Mesh CreateSphere(float radius, int segments, int rings, Vector3 color)
|
||||
{
|
||||
var vertices = new List<Vertex>();
|
||||
var indices = new List<uint>();
|
||||
|
||||
for (var ring = 0; ring <= rings; ring++)
|
||||
{
|
||||
var phi = MathF.PI * ring / rings;
|
||||
var sinPhi = MathF.Sin(phi);
|
||||
var cosPhi = MathF.Cos(phi);
|
||||
|
||||
for (var seg = 0; seg <= segments; seg++)
|
||||
{
|
||||
var theta = 2.0f * MathF.PI * seg / segments;
|
||||
var sinTheta = MathF.Sin(theta);
|
||||
var cosTheta = MathF.Cos(theta);
|
||||
|
||||
var x = radius * sinPhi * cosTheta;
|
||||
var y = radius * cosPhi;
|
||||
var z = radius * sinPhi * sinTheta;
|
||||
var normal = Vector3.Normalize(new Vector3(x, y, z));
|
||||
|
||||
vertices.Add(new Vertex(new Vector3(x, y, z), color, normal));
|
||||
}
|
||||
}
|
||||
|
||||
for (var ring = 0; ring < rings; ring++)
|
||||
{
|
||||
for (var seg = 0; seg < segments; seg++)
|
||||
{
|
||||
var i0 = (uint)(ring * (segments + 1) + seg);
|
||||
var i1 = i0 + 1;
|
||||
var i2 = i0 + (uint)(segments + 1);
|
||||
var i3 = i2 + 1;
|
||||
|
||||
indices.Add(i0); indices.Add(i1); indices.Add(i2);
|
||||
indices.Add(i1); indices.Add(i3); indices.Add(i2);
|
||||
}
|
||||
}
|
||||
|
||||
return new Mesh(vertices.ToArray(), indices.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a ground grid mesh at Y=0, consisting of thin quads.
|
||||
/// </summary>
|
||||
/// <param name="lines">Number of grid lines on each side of the origin.</param>
|
||||
/// <param name="spacing">Distance between grid lines.</param>
|
||||
/// <param name="color">Vertex color applied to all vertices.</param>
|
||||
public static Mesh CreateGrid(int lines, float spacing, Vector3 color)
|
||||
{
|
||||
var vertices = new List<Vertex>();
|
||||
var indices = new List<uint>();
|
||||
var extent = lines * spacing;
|
||||
var normal = Vector3.UnitY;
|
||||
var halfWidth = 0.02f;
|
||||
|
||||
for (var i = -lines; i <= lines; i++)
|
||||
{
|
||||
var offset = i * spacing;
|
||||
|
||||
var baseIndex = (uint)vertices.Count;
|
||||
vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal));
|
||||
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
|
||||
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
|
||||
|
||||
baseIndex = (uint)vertices.Count;
|
||||
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal));
|
||||
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal));
|
||||
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
|
||||
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
|
||||
}
|
||||
|
||||
return new Mesh(vertices.ToArray(), indices.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using Engine.Core;
|
||||
|
||||
namespace Engine.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating concrete graphics backends by name.
|
||||
/// Backends register themselves so the app only depends on the HAL interfaces.
|
||||
/// Each backend creates and owns its own window.
|
||||
/// </summary>
|
||||
public static class RenderBackendFactory
|
||||
{
|
||||
private static readonly Dictionary<string, Func<int, int, bool, IRenderContext>> _registry
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Register a backend implementation under the given name.
|
||||
/// The factory receives (width, height, enableValidation) and must create
|
||||
/// its own window and render context.
|
||||
/// </summary>
|
||||
public static void Register(string name, Func<int, int, bool, IRenderContext> factory)
|
||||
{
|
||||
_registry[name] = factory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a backend instance for the given name.
|
||||
/// The backend assembly must have registered itself before this is called.
|
||||
/// </summary>
|
||||
public static IRenderContext Create(string name, int width, int height, bool enableValidation)
|
||||
{
|
||||
if (!_registry.TryGetValue(name, out var factory))
|
||||
throw new NotSupportedException($"No graphics backend named '{name}' is registered.");
|
||||
|
||||
return factory(width, height, enableValidation);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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
|
||||
{
|
||||
Type = l.Type,
|
||||
Direction = l.Direction,
|
||||
Position = l.Position,
|
||||
Color = l.Color,
|
||||
Intensity = l.Intensity,
|
||||
Range = l.Range
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (entry.Light.Type == LightType.Point)
|
||||
{
|
||||
entity.Set(Light.Point(
|
||||
entry.Light.Position,
|
||||
entry.Light.Color,
|
||||
entry.Light.Intensity,
|
||||
entry.Light.Range));
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Set(Light.Directional(
|
||||
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 LightType Type { get; set; }
|
||||
public Vector3 Direction { get; set; }
|
||||
public Vector3 Position { get; set; }
|
||||
public Vector3 Color { get; set; }
|
||||
public float Intensity { get; set; }
|
||||
public float Range { 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user