diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a49f64a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- In MoonTools.ECS, do not store plain references to `Entity` objects in long-lived collections outside the ECS world, because their underlying IDs can be reused or destroyed. Instead, query the ECS world to track or process entities based on their assigned components. \ No newline at end of file diff --git a/Nerfed.Editor/Program.cs b/Nerfed.Editor/Program.cs index dcf929b..1751a82 100644 --- a/Nerfed.Editor/Program.cs +++ b/Nerfed.Editor/Program.cs @@ -29,40 +29,40 @@ internal class Program //systems.Add(new ParentSystem(world)); systems.Add(new LocalToWorldSystem(world)); editorSystems.Add(new EditorProfilerWindow(world)); - editorSystems.Add(new EditorHierarchyWindow(world)); + // editorSystems.Add(new EditorHierarchyWindow(world)); #if DEBUG editorSystems.Add(new EditorInspectorWindow(world)); #endif - Entity ent1 = world.CreateEntity("parent"); - world.Set(ent1, new Root()); - world.Set(ent1, new LocalTransform(new Vector3(1, 0, 0), Quaternion.Identity, Vector3.One)); + // Entity ent1 = world.CreateEntity("parent"); + // world.Set(ent1, new Root()); + // world.Set(ent1, new LocalTransform(new Vector3(1, 0, 0), Quaternion.Identity, Vector3.One)); + // + // Entity ent2 = world.CreateEntity("child"); + // world.Set(ent2, new LocalTransform(new Vector3(0, 1, 0), Quaternion.Identity, Vector3.One)); + // Transform.SetParent(world, ent2, ent1); + // + // Entity ent3 = world.CreateEntity("entity3"); + // world.Set(ent3, new Root()); + // Transform.SetParent(world, ent3, ent2); + // + // Entity ent4 = world.CreateEntity("entity4"); + // world.Set(ent4, new Root()); + // + // Entity ent5 = world.CreateBaseEntity("entity5"); - Entity ent2 = world.CreateEntity("child"); - world.Set(ent2, new LocalTransform(new Vector3(0, 1, 0), Quaternion.Identity, Vector3.One)); - Transform.SetParent(world, ent2, ent1); - - Entity ent3 = world.CreateEntity("entity3"); - world.Set(ent3, new Root()); - Transform.SetParent(world, ent3, ent2); - - Entity ent4 = world.CreateEntity("entity4"); - world.Set(ent4, new Root()); - - Entity ent5 = world.CreateBaseEntity("entity5"); - - for (int i = 0; i < 256; i++) + for (int i = 0; i < 1000000; i++) { Entity newEnt = world.CreateBaseEntity(); world.Set(newEnt, new LocalTransform(new Vector3(i, i, i), Quaternion.Identity, Vector3.One)); - Entity parent = newEnt; - for (int j = 0; j < 2; j++) { - Entity newChildEnt = world.CreateEntity(); - world.Set(newChildEnt, new LocalTransform(new Vector3(i + j * i, i - j * i, j - i * i), Quaternion.Identity, Vector3.One)); - Transform.SetParent(world, newChildEnt, parent); - parent = newChildEnt; - } + // Entity parent = newEnt; + // for (int j = 0; j < 2; j++) { + // Entity newChildEnt = world.CreateEntity(); + // world.Set(newChildEnt, new LocalTransform(new Vector3(i + j * i, i - j * i, j - i * i), Quaternion.Identity, Vector3.One)); + // Transform.SetParent(world, newChildEnt, parent); + // parent = newChildEnt; + // } } // Open project. diff --git a/Nerfed.Runtime/Components/LocalTransform.cs b/Nerfed.Runtime/Components/LocalTransform.cs index 79b2cd4..04b6032 100644 --- a/Nerfed.Runtime/Components/LocalTransform.cs +++ b/Nerfed.Runtime/Components/LocalTransform.cs @@ -1,7 +1,9 @@ using System.Numerics; +using Nerfed.Runtime.Scene; namespace Nerfed.Runtime.Components { + [SceneComponent] public readonly record struct LocalTransform(Vector3 position, Quaternion rotation, Vector3 scale) { public static readonly LocalTransform Identity = new(Vector3.Zero, Quaternion.Identity, Vector3.One); diff --git a/Nerfed.Runtime/Components/Test.cs b/Nerfed.Runtime/Components/Test.cs index 9cd7e8d..9c93d7d 100644 --- a/Nerfed.Runtime/Components/Test.cs +++ b/Nerfed.Runtime/Components/Test.cs @@ -1,4 +1,7 @@ -namespace Nerfed.Runtime.Components +using Nerfed.Runtime.Scene; + +namespace Nerfed.Runtime.Components { + [SceneComponent] public readonly record struct Test(); } diff --git a/Nerfed.Runtime/Engine.cs b/Nerfed.Runtime/Engine.cs index 3a7e894..1145eb5 100644 --- a/Nerfed.Runtime/Engine.cs +++ b/Nerfed.Runtime/Engine.cs @@ -16,7 +16,7 @@ public static class Engine public static bool VSync { get; set; } public static GraphicsDevice GraphicsDevice { get; private set; } - public static AudioDevice AudioDevice { get; private set; } + //public static AudioDevice AudioDevice { get; private set; } public static Window MainWindow { get; private set; } public static TimeSpan Timestep { get; private set; } @@ -44,37 +44,32 @@ public static class Engine private const string WindowTitle = "Nerfed"; //.. - public static void Run(string[] args) - { + public static void Run(string[] args) { Timestep = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / TargetTimestep); gameTimer = Stopwatch.StartNew(); SetFrameLimiter(new FrameLimiterSettings(FrameLimiterMode.Capped, MaxFps)); - for (int i = 0; i < previousSleepTimes.Length; i += 1) - { + for(int i = 0; i < previousSleepTimes.Length; i += 1) { previousSleepTimes[i] = TimeSpan.FromMilliseconds(1); } - if (SDL.SDL_Init(SDL.SDL_INIT_VIDEO | SDL.SDL_INIT_TIMER | SDL.SDL_INIT_GAMECONTROLLER) < 0) - { + if(SDL.SDL_Init(SDL.SDL_INIT_VIDEO | SDL.SDL_INIT_TIMER | SDL.SDL_INIT_GAMECONTROLLER) < 0) { throw new Exception("Failed to init SDL"); } - + GraphicsDevice = new GraphicsDevice(BackendFlags.All); GraphicsDevice.LoadDefaultPipelines(); MainWindow = new Window(GraphicsDevice, new WindowCreateInfo(WindowTitle, WindowWidth, WindowHeight, ScreenMode.Windowed)); - if (!GraphicsDevice.ClaimWindow(MainWindow, SwapchainComposition.SDR, VSync ? PresentMode.VSync : PresentMode.Mailbox)) - { + if(!GraphicsDevice.ClaimWindow(MainWindow, SwapchainComposition.SDR, VSync ? PresentMode.VSync : PresentMode.Mailbox)) { throw new Exception("Failed to claim window"); } - AudioDevice = new AudioDevice(); + //AudioDevice = new AudioDevice(); OnInitialize?.Invoke(); - while (!quit) - { + while(!quit) { Tick(); } @@ -83,40 +78,33 @@ public static class Engine GraphicsDevice.UnclaimWindow(MainWindow); MainWindow.Dispose(); GraphicsDevice.Dispose(); - AudioDevice.Dispose(); + //AudioDevice.Dispose(); SDL.SDL_Quit(); } /// /// Updates the frame limiter settings. /// - public static void SetFrameLimiter(FrameLimiterSettings settings) - { + public static void SetFrameLimiter(FrameLimiterSettings settings) { framerateCapped = settings.Mode == FrameLimiterMode.Capped; - if (framerateCapped) - { + if(framerateCapped) { framerateCapTimeSpan = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / settings.Cap); - } - else - { + } else { framerateCapTimeSpan = TimeSpan.Zero; } } - public static void Quit() - { + public static void Quit() { quit = true; } - private static void Tick() - { + private static void Tick() { Profiler.BeginFrame(); AdvanceElapsedTime(); - if (framerateCapped) - { + if(framerateCapped) { Profiler.BeginSample("framerateCapped"); /* We want to wait until the framerate cap, @@ -124,8 +112,7 @@ public static class Engine * seeing how long we actually slept for lets us estimate the worst case * sleep precision so we don't oversleep the next frame. */ - while (accumulatedDrawTime + worstCaseSleepPrecision < framerateCapTimeSpan) - { + while(accumulatedDrawTime + worstCaseSleepPrecision < framerateCapTimeSpan) { Thread.Sleep(1); TimeSpan timeAdvancedSinceSleeping = AdvanceElapsedTime(); UpdateEstimatedSleepPrecision(timeAdvancedSinceSleeping); @@ -136,8 +123,7 @@ public static class Engine * SpinWait(1) works by pausing the thread for very short intervals, so it is * an efficient and time-accurate way to wait out the rest of the time. */ - while (accumulatedDrawTime < framerateCapTimeSpan) - { + while(accumulatedDrawTime < framerateCapTimeSpan) { Thread.SpinWait(1); AdvanceElapsedTime(); } @@ -146,15 +132,12 @@ public static class Engine } // Do not let any step take longer than our maximum. - if (accumulatedUpdateTime > MaxDeltaTime) - { + if(accumulatedUpdateTime > MaxDeltaTime) { accumulatedUpdateTime = MaxDeltaTime; } - if (!quit) - { - while (accumulatedUpdateTime >= Timestep) - { + if(!quit) { + while(accumulatedUpdateTime >= Timestep) { Profiler.BeginSample("Update"); Keyboard.Update(); Mouse.Update(); @@ -167,7 +150,7 @@ public static class Engine OnUpdate?.Invoke(); Profiler.EndSample(); - AudioDevice.WakeThread(); + //AudioDevice.WakeThread(); accumulatedUpdateTime -= Timestep; Profiler.EndSample(); } @@ -185,8 +168,7 @@ public static class Engine Profiler.EndFrame(); } - private static TimeSpan AdvanceElapsedTime() - { + private static TimeSpan AdvanceElapsedTime() { long currentTicks = gameTimer.Elapsed.Ticks; TimeSpan timeAdvanced = TimeSpan.FromTicks(currentTicks - previousTicks); accumulatedUpdateTime += timeAdvanced; @@ -195,12 +177,9 @@ public static class Engine return timeAdvanced; } - private static void ProcessSDLEvents() - { - while (SDL.SDL_PollEvent(out SDL.SDL_Event ev) == 1) - { - switch (ev.type) - { + private static void ProcessSDLEvents() { + while(SDL.SDL_PollEvent(out SDL.SDL_Event ev) == 1) { + switch(ev.type) { case SDL.SDL_EventType.SDL_QUIT: Quit(); break; @@ -236,16 +215,14 @@ public static class Engine /* To calculate the sleep precision of the OS, we take the worst case * time spent sleeping over the results of previous requests to sleep 1ms. */ - private static void UpdateEstimatedSleepPrecision(TimeSpan timeSpentSleeping) - { + private static void UpdateEstimatedSleepPrecision(TimeSpan timeSpentSleeping) { /* It is unlikely that the scheduler will actually be more imprecise than * 4ms and we don't want to get wrecked by a single long sleep so we cap this * value at 4ms for sanity. */ TimeSpan upperTimeBound = TimeSpan.FromMilliseconds(4); - if (timeSpentSleeping > upperTimeBound) - { + if(timeSpentSleeping > upperTimeBound) { timeSpentSleeping = upperTimeBound; } @@ -254,17 +231,12 @@ public static class Engine * is if we either 1) just got a new worst case, or 2) the worst case was * the oldest entry on the list. */ - if (timeSpentSleeping >= worstCaseSleepPrecision) - { + if(timeSpentSleeping >= worstCaseSleepPrecision) { worstCaseSleepPrecision = timeSpentSleeping; - } - else if (previousSleepTimes[sleepTimeIndex] == worstCaseSleepPrecision) - { + } else if(previousSleepTimes[sleepTimeIndex] == worstCaseSleepPrecision) { TimeSpan maxSleepTime = TimeSpan.MinValue; - for (int i = 0; i < previousSleepTimes.Length; i++) - { - if (previousSleepTimes[i] > maxSleepTime) - { + for(int i = 0; i < previousSleepTimes.Length; i++) { + if(previousSleepTimes[i] > maxSleepTime) { maxSleepTime = previousSleepTimes[i]; } } diff --git a/Nerfed.Runtime/Scene/ISceneSerializer.cs b/Nerfed.Runtime/Scene/ISceneSerializer.cs new file mode 100644 index 0000000..55b7556 --- /dev/null +++ b/Nerfed.Runtime/Scene/ISceneSerializer.cs @@ -0,0 +1,13 @@ +namespace Nerfed.Runtime.Scene; + +/// +/// Abstraction over a concrete scene format (JSON, binary, …). +/// Implementations read and write to a , +/// making it straightforward to add a compact binary format later without +/// changing any of the surrounding scene infrastructure. +/// +public interface ISceneSerializer +{ + void Serialize(SceneData scene, Stream stream); + SceneData Deserialize(Stream stream); +} diff --git a/Nerfed.Runtime/Scene/JsonSceneSerializer.cs b/Nerfed.Runtime/Scene/JsonSceneSerializer.cs new file mode 100644 index 0000000..dc70c0b --- /dev/null +++ b/Nerfed.Runtime/Scene/JsonSceneSerializer.cs @@ -0,0 +1,189 @@ +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Nerfed.Runtime.Scene; + +/// +/// Human-readable JSON scene serializer. +/// +/// Example output: +/// +/// { +/// "version": 1, +/// "name": "MyScene", +/// "entities": [ +/// { +/// "id": "a1b2c3d4-...", +/// "tag": "Player", +/// "parentId": null, +/// "components": [ +/// { +/// "type": "Nerfed.Runtime.Components.LocalTransform", +/// "data": { +/// "position": { "x": 0.0, "y": 0.0, "z": 0.0 }, +/// "rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 }, +/// "scale": { "x": 1.0, "y": 1.0, "z": 1.0 } +/// } +/// } +/// ] +/// } +/// ], +/// "relations": [ +/// { +/// "type": "Nerfed.Runtime.Components.OwnerRelation", +/// "entityA": "a1b2c3d4-...", +/// "entityB": "e5f6a7b8-...", +/// "data": {} +/// } +/// ] +/// } +/// +/// +public sealed class JsonSceneSerializer : ISceneSerializer +{ + private static readonly JsonSerializerOptions Options = new() { + WriteIndented = true, + Converters = + { + new Vector3JsonConverter(), + new QuaternionJsonConverter(), + new SceneComponentDataJsonConverter(), + new SceneRelationDataJsonConverter(), + }, + }; + + public void Serialize(SceneData scene, Stream stream) { + JsonSerializer.Serialize(stream, scene, Options); + } + + public SceneData Deserialize(Stream stream) { + return JsonSerializer.Deserialize(stream, Options) + ?? throw new InvalidOperationException("Failed to deserialize scene: root element was null."); + } + + // ------------------------------------------------------------------------- + // Converters + // ------------------------------------------------------------------------- + + private sealed class Vector3JsonConverter : JsonConverter + { + public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + float x = 0f, y = 0f, z = 0f; + reader.Read(); // StartObject + while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) { + string name = reader.GetString()!; + reader.Read(); + switch(name) { + case "x": x = reader.GetSingle(); break; + case "y": y = reader.GetSingle(); break; + case "z": z = reader.GetSingle(); break; + } + } + return new Vector3(x, y, z); + } + + public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options) { + writer.WriteStartObject(); + writer.WriteNumber("x", value.X); + writer.WriteNumber("y", value.Y); + writer.WriteNumber("z", value.Z); + writer.WriteEndObject(); + } + } + + private sealed class QuaternionJsonConverter : JsonConverter + { + public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + float x = 0f, y = 0f, z = 0f, w = 1f; + reader.Read(); // StartObject + while(reader.Read() && reader.TokenType != JsonTokenType.EndObject) { + string name = reader.GetString()!; + reader.Read(); + switch(name) { + case "x": x = reader.GetSingle(); break; + case "y": y = reader.GetSingle(); break; + case "z": z = reader.GetSingle(); break; + case "w": w = reader.GetSingle(); break; + } + } + return new Quaternion(x, y, z, w); + } + + public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options) { + writer.WriteStartObject(); + writer.WriteNumber("x", value.X); + writer.WriteNumber("y", value.Y); + writer.WriteNumber("z", value.Z); + writer.WriteNumber("w", value.W); + writer.WriteEndObject(); + } + } + + /// + /// Buffers the full JSON object, resolves the CLR component type from the "type" field, + /// then deserializes "data" using that concrete type. + /// + private sealed class SceneComponentDataJsonConverter : JsonConverter + { + public override SceneComponentData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + + string typeName = root.GetProperty("type").GetString() + ?? throw new JsonException("Missing or null 'type' field in component data."); + + Type componentType = SceneManager.GetComponentType(typeName) + ?? throw new JsonException($"Unknown component type '{typeName}'. Ensure the struct is marked with [SceneComponent]."); + + string rawData = root.GetProperty("data").GetRawText(); + ValueType value = (ValueType)JsonSerializer.Deserialize(rawData, componentType, options)!; + + return new SceneComponentData { Type = typeName, Value = value }; + } + + public override void Write(Utf8JsonWriter writer, SceneComponentData value, JsonSerializerOptions options) { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WritePropertyName("data"); + JsonSerializer.Serialize(writer, value.Value, value.Value.GetType(), options); + writer.WriteEndObject(); + } + } + + /// + /// Same pattern as but for relation data. + /// Resolves the type via . + /// + private sealed class SceneRelationDataJsonConverter : JsonConverter + { + public override SceneRelationData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + + string typeName = root.GetProperty("type").GetString() + ?? throw new JsonException("Missing or null 'type' field in relation data."); + + Type relationType = SceneManager.GetRelationType(typeName) + ?? throw new JsonException($"Unknown relation type '{typeName}'. Ensure the struct is marked with [SceneRelation]."); + + Guid entityA = root.GetProperty("entityA").GetGuid(); + Guid entityB = root.GetProperty("entityB").GetGuid(); + + string rawData = root.GetProperty("data").GetRawText(); + ValueType value = (ValueType)JsonSerializer.Deserialize(rawData, relationType, options)!; + + return new SceneRelationData { Type = typeName, EntityA = entityA, EntityB = entityB, Value = value }; + } + + public override void Write(Utf8JsonWriter writer, SceneRelationData value, JsonSerializerOptions options) { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WriteString("entityA", value.EntityA); + writer.WriteString("entityB", value.EntityB); + writer.WritePropertyName("data"); + JsonSerializer.Serialize(writer, value.Value, value.Value.GetType(), options); + writer.WriteEndObject(); + } + } +} \ No newline at end of file diff --git a/Nerfed.Runtime/Scene/SceneComponentAttribute.cs b/Nerfed.Runtime/Scene/SceneComponentAttribute.cs new file mode 100644 index 0000000..8202144 --- /dev/null +++ b/Nerfed.Runtime/Scene/SceneComponentAttribute.cs @@ -0,0 +1,8 @@ +namespace Nerfed.Runtime.Scene; + +/// +/// Marks an unmanaged struct as a serializable scene component. +/// Only types with this attribute will be saved/loaded by the scene system. +/// +[AttributeUsage(AttributeTargets.Struct, Inherited = false)] +public sealed class SceneComponentAttribute : Attribute { } diff --git a/Nerfed.Runtime/Scene/SceneData.cs b/Nerfed.Runtime/Scene/SceneData.cs new file mode 100644 index 0000000..ee4f624 --- /dev/null +++ b/Nerfed.Runtime/Scene/SceneData.cs @@ -0,0 +1,65 @@ +namespace Nerfed.Runtime.Scene; + +/// +/// Root data model for a scene. A scene and a prefab are the same thing — +/// there is no distinction between the two, mirroring Godot's design. +/// +public sealed class SceneData +{ + /// Incremented when the file format changes in a breaking way. + public int Version { get; set; } = SceneData.CurrentVersion; + public string Name { get; set; } = string.Empty; + public List Entities { get; set; } = new(); + /// All user-defined relations between entities in this scene. + public List Relations { get; set; } = new(); + + public const int CurrentVersion = 1; +} + +/// +/// Serialized representation of a single entity. +/// The is a scene-local identifier that only exists in the +/// serialized data and is used to reconstruct parent–child and relation references. +/// It is never stored as a component on a live entity. +/// An entity is included if it owns at least one component +/// OR participates in at least one relation. +/// +public sealed class SceneEntityData +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Tag { get; set; } = string.Empty; + + /// + /// Scene-local of this entity's + /// parent, or null if this is a root entity. + /// + public Guid? ParentId { get; set; } + + public List Components { get; set; } = new(); +} + +/// +/// Serialized representation of a single component value on an entity. +/// is the fully-qualified CLR type name used to resolve the component on load. +/// is the boxed runtime value; each is +/// responsible for converting it to/from its wire format. +/// +public sealed class SceneComponentData +{ + public string Type { get; set; } = string.Empty; + public ValueType Value { get; set; } = default!; +} + +/// +/// Serialized representation of a relation between two entities. +/// and reference scene-local values. +/// identifies the relation kind (must be marked with ). +/// holds the relation data payload (may be an empty struct). +/// +public sealed class SceneRelationData +{ + public string Type { get; set; } = string.Empty; + public Guid EntityA { get; set; } + public Guid EntityB { get; set; } + public ValueType Value { get; set; } = default!; +} diff --git a/Nerfed.Runtime/Scene/SceneManager.cs b/Nerfed.Runtime/Scene/SceneManager.cs new file mode 100644 index 0000000..a703c51 --- /dev/null +++ b/Nerfed.Runtime/Scene/SceneManager.cs @@ -0,0 +1,345 @@ +using System.Reflection; +using MoonTools.ECS; +using Nerfed.Runtime.Components; + +namespace Nerfed.Runtime.Scene; + +/// +/// Central hub for scene serialization and deserialization. +/// +/// On first use the static constructor scans all loaded assemblies for: +/// • Structs annotated with — serialized as per-entity component data. +/// • Structs annotated with — serialized as cross-entity relation data. +/// +/// The hierarchy is handled separately via +/// and does NOT need a . +/// +/// Usage: +/// +/// var serializer = new JsonSceneSerializer(); +/// SceneManager.Save(world, "Assets/level1.scene", serializer, "Level 1"); +/// SceneManager.Load(world, "Assets/level1.scene", serializer); +/// +/// +public static class SceneManager +{ + // Full CLR type name → Type + private static readonly Dictionary ComponentRegistry = new(); + private static readonly Dictionary RelationRegistry = new(); + + // Reflection cache so we only build the delegates once per type. + private static readonly Dictionary> HasComponentCache = new(); + private static readonly Dictionary> GetComponentCache = new(); + private static readonly Dictionary> SetComponentCache = new(); + private static readonly Dictionary> HasOutRelationCache = new(); + private static readonly Dictionary> OutRelationsCache = new(); + private static readonly Dictionary> GetRelationDataCache = new(); + private static readonly Dictionary> RelateCache = new(); + + static SceneManager() { + foreach(Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { + Type[] types; + try { types = assembly.GetTypes(); } catch(ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; } + + foreach(Type type in types) { + if(type.FullName is null) continue; + + if(type.GetCustomAttribute() is not null) + ComponentRegistry[type.FullName] = type; + + if(type.GetCustomAttribute() is not null) + RelationRegistry[type.FullName] = type; + } + } + } + + // ------------------------------------------------------------------------- + // Public registry accessors + // ------------------------------------------------------------------------- + + public static Type? GetComponentType(string fullName) { + ComponentRegistry.TryGetValue(fullName, out Type? type); + return type; + } + + public static Type? GetRelationType(string fullName) { + RelationRegistry.TryGetValue(fullName, out Type? type); + return type; + } + + public static IReadOnlyDictionary RegisteredComponentTypes => ComponentRegistry; + public static IReadOnlyDictionary RegisteredRelationTypes => RelationRegistry; + + // ------------------------------------------------------------------------- + // High-level Save / Load + // ------------------------------------------------------------------------- + + public static void Save(World world, string path, ISceneSerializer serializer, string sceneName = "") { + SceneData scene = Extract(world, sceneName); + string? directory = Path.GetDirectoryName(path); + if(!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + using FileStream stream = File.Open(path, FileMode.Create, FileAccess.Write); + serializer.Serialize(scene, stream); + } + + public static Dictionary Load(World world, string path, ISceneSerializer serializer) { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read); + SceneData scene = serializer.Deserialize(stream); + return Instantiate(world, scene); + } + + // ------------------------------------------------------------------------- + // Extract (world → SceneData) + // ------------------------------------------------------------------------- + + public static SceneData Extract(World world, string name = "") { + SceneData scene = new() { Name = name }; + + // ── 1. Collect entities ────────────────────────────────────────────── + // Include an entity if it has at least one scene component OR if it + // appears as an endpoint of at least one scene relation. This ensures + // pure grouping nodes and relation-only entities are not dropped. + + Dictionary entityToGuid = new(); + + void EnsureEntity(Entity e) { + if(!entityToGuid.ContainsKey(e.ID)) + entityToGuid[e.ID] = Guid.NewGuid(); + } + + foreach(Entity entity in world.GetAllEntities()) { + if(HasAnySceneComponent(world, entity)) + EnsureEntity(entity); + } + + // Walk all registered relation types and pull in both endpoints. + foreach(Type relationType in RelationRegistry.Values) { + foreach((Entity a, Entity b) in WorldAllRelations(world, relationType)) { + EnsureEntity(a); + EnsureEntity(b); + } + } + + // Also include entities that are part of the ChildParentRelation hierarchy + // even if they carry no scene components and no user-defined relations. + foreach((Entity child, Entity parent) in world.Relations()) { + EnsureEntity(child); + EnsureEntity(parent); + } + + // ── 2. Build entity records (parents must be known before children so + // we sort parents-before-children for readable output) ─────────── + List ordered = BuildSortedEntityList(world, entityToGuid); + scene.Entities.AddRange(ordered); + + // ── 3. Build relation records ──────────────────────────────────────── + foreach((string typeName, Type relationType) in RelationRegistry) { + foreach((Entity a, Entity b) in WorldAllRelations(world, relationType)) { + if(!entityToGuid.TryGetValue(a.ID, out Guid guidA) || + !entityToGuid.TryGetValue(b.ID, out Guid guidB)) + continue; + + ValueType payload = WorldGetRelationData(world, a, b, relationType); + scene.Relations.Add(new SceneRelationData { + Type = typeName, + EntityA = guidA, + EntityB = guidB, + Value = payload, + }); + } + } + + return scene; + } + + // ------------------------------------------------------------------------- + // Instantiate (SceneData → world) + // ------------------------------------------------------------------------- + + public static Dictionary Instantiate(World world, SceneData scene) { + Dictionary guidToEntity = new(scene.Entities.Count); + + // Pass 1 – create all entities. + foreach(SceneEntityData entityData in scene.Entities) { + Entity entity = world.CreateEntity(entityData.Tag); + guidToEntity[entityData.Id] = entity; + } + + // Pass 2 – set components and wire up the ChildParentRelation hierarchy. + foreach(SceneEntityData entityData in scene.Entities) { + Entity entity = guidToEntity[entityData.Id]; + + foreach(SceneComponentData componentData in entityData.Components) + WorldSetComponent(world, entity, componentData.Type, componentData.Value); + + if(entityData.ParentId is Guid parentGuid && guidToEntity.TryGetValue(parentGuid, out Entity parent)) { + world.Set(entity, new Child()); + world.Relate(entity, parent, new ChildParentRelation()); + } else { + world.Set(entity, new Root()); + } + } + + // Pass 3 – restore all user-defined relations. + foreach(SceneRelationData relationData in scene.Relations) { + if(!guidToEntity.TryGetValue(relationData.EntityA, out Entity entityA) || + !guidToEntity.TryGetValue(relationData.EntityB, out Entity entityB)) + continue; + + WorldRelate(world, entityA, entityB, relationData.Type, relationData.Value); + } + + return guidToEntity; + } + + // ------------------------------------------------------------------------- + // Helpers – entity ordering + // ------------------------------------------------------------------------- + + // Returns entities sorted so that a parent always appears before its children, + // making the JSON file human-readable and easier to diff. + private static List BuildSortedEntityList( + World world, + Dictionary entityToGuid) { + // Build per-entity data (unsorted first). + Dictionary byGuid = new(entityToGuid.Count); + + foreach((uint entityId, Guid guid) in entityToGuid) { + Entity entity = new(entityId); + + Guid? parentId = null; + if(world.HasOutRelation(entity)) { + // Iterate all out-relations — an entity may have multiple parents + // in theory, but ChildParentRelation is designed as singleton. + // We capture the first valid one here. + foreach(Entity parent in world.OutRelations(entity)) { + if(entityToGuid.TryGetValue(parent.ID, out Guid parentGuid)) { + parentId = parentGuid; + break; + } + } + } + + List components = new(); + foreach((string typeName, Type componentType) in ComponentRegistry) { + if(!WorldHasComponent(world, entity, componentType)) continue; + ValueType value = WorldGetComponent(world, entity, componentType); + components.Add(new SceneComponentData { Type = typeName, Value = value }); + } + + byGuid[guid] = new SceneEntityData { + Id = guid, + Tag = world.GetTag(entity), + ParentId = parentId, + Components = components, + }; + } + + // Topological sort: parents before children. + List sorted = new(byGuid.Count); + HashSet visited = new(byGuid.Count); + + void Visit(Guid id) { + if(!visited.Add(id)) return; + SceneEntityData data = byGuid[id]; + if(data.ParentId is Guid pid && byGuid.ContainsKey(pid)) + Visit(pid); + sorted.Add(data); + } + + foreach(Guid id in byGuid.Keys) + Visit(id); + + return sorted; + } + + // ------------------------------------------------------------------------- + // Reflection helpers – components + // ------------------------------------------------------------------------- + + private static bool HasAnySceneComponent(World world, Entity entity) { + foreach(Type componentType in ComponentRegistry.Values) { + if(WorldHasComponent(world, entity, componentType)) return true; + } + return false; + } + + private static bool WorldHasComponent(World world, Entity entity, Type componentType) { + if(!HasComponentCache.TryGetValue(componentType, out Func? fn)) { + MethodInfo method = FindGenericMethod(nameof(World.Has)).MakeGenericMethod(componentType); + fn = (w, e) => (bool)method.Invoke(w, new object[] { e })!; + HasComponentCache[componentType] = fn; + } + return fn(world, entity); + } + + private static ValueType WorldGetComponent(World world, Entity entity, Type componentType) { + if(!GetComponentCache.TryGetValue(componentType, out Func? fn)) { + MethodInfo method = FindGenericMethod(nameof(World.Get)).MakeGenericMethod(componentType); + fn = (w, e) => (ValueType)method.Invoke(w, new object[] { e })!; + GetComponentCache[componentType] = fn; + } + return fn(world, entity); + } + + private static void WorldSetComponent(World world, Entity entity, string typeName, ValueType value) { + if(!ComponentRegistry.TryGetValue(typeName, out Type? componentType)) return; + + if(!SetComponentCache.TryGetValue(componentType, out Action? fn)) { + MethodInfo method = FindGenericMethod(nameof(World.Set)).MakeGenericMethod(componentType); + fn = (w, e, v) => method.Invoke(w, new object[] { e, v }); + SetComponentCache[componentType] = fn; + } + fn(world, entity, value); + } + + // ------------------------------------------------------------------------- + // Reflection helpers – relations + // ------------------------------------------------------------------------- + + private static IEnumerable<(Entity, Entity)> WorldAllRelations(World world, Type relationType) { + // World.Relations() returns ReverseSpanEnumerator<(Entity,Entity)>. + // We materialise it into a list so the caller can iterate freely. + MethodInfo method = FindGenericMethod(nameof(World.Relations)).MakeGenericMethod(relationType); + // Returns a boxed ReverseSpanEnumerator; invoke MoveNext/Current via dynamic. + // Easiest: call via dynamic to avoid unsafe span-from-box issues. + dynamic enumerator = method.Invoke(world, null)!; + List<(Entity, Entity)> results = new(); + while(enumerator.MoveNext()) + results.Add(enumerator.Current); + return results; + } + + private static ValueType WorldGetRelationData(World world, Entity a, Entity b, Type relationType) { + if(!GetRelationDataCache.TryGetValue(relationType, out Func? fn)) { + MethodInfo method = FindGenericMethod(nameof(World.GetRelationData)).MakeGenericMethod(relationType); + fn = (w, ea, eb) => (ValueType)method.Invoke(w, new object[] { ea, eb })!; + GetRelationDataCache[relationType] = fn; + } + return fn(world, a, b); + } + + private static void WorldRelate(World world, Entity a, Entity b, string typeName, ValueType value) { + if(!RelationRegistry.TryGetValue(typeName, out Type? relationType)) return; + + if(!RelateCache.TryGetValue(relationType, out Action? fn)) { + MethodInfo method = FindGenericMethod(nameof(World.Relate)).MakeGenericMethod(relationType); + fn = (w, ea, eb, v) => method.Invoke(w, new object[] { ea, eb, v }); + RelateCache[relationType] = fn; + } + fn(world, a, b, value); + } + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + private static MethodInfo FindGenericMethod(string name) { + foreach(MethodInfo m in typeof(World).GetMethods(BindingFlags.Public | BindingFlags.Instance)) { + if(m.Name == name && m.IsGenericMethodDefinition) + return m; + } + throw new InvalidOperationException($"Could not find generic method '{name}' on {nameof(World)}."); + } +} diff --git a/Nerfed.Runtime/Scene/SceneRelationAttribute.cs b/Nerfed.Runtime/Scene/SceneRelationAttribute.cs new file mode 100644 index 0000000..162bec9 --- /dev/null +++ b/Nerfed.Runtime/Scene/SceneRelationAttribute.cs @@ -0,0 +1,10 @@ +namespace Nerfed.Runtime.Scene; + +/// +/// Marks an unmanaged struct as a serializable scene relation kind. +/// Both endpoints and the data payload will be saved/loaded by the scene system. +/// The hierarchy is handled separately via +/// and should NOT be marked with this attribute. +/// +[AttributeUsage(AttributeTargets.Struct, Inherited = false)] +public sealed class SceneRelationAttribute : Attribute { } diff --git a/Nerfed.Runtime/Scene/Streaming/ChunkStreamingSystem.cs b/Nerfed.Runtime/Scene/Streaming/ChunkStreamingSystem.cs new file mode 100644 index 0000000..0ccc397 --- /dev/null +++ b/Nerfed.Runtime/Scene/Streaming/ChunkStreamingSystem.cs @@ -0,0 +1,202 @@ +using MoonTools.ECS; +using System.Collections.Generic; +using System; +using System.Numerics; +using Nerfed.Runtime.Components; + +namespace Nerfed.Runtime.Scene.Streaming; + +/// +/// Status of a chunk in the streaming system. +/// +public enum ChunkState +{ + Unloaded, + Loading, + Loaded, + Unloading +} + +/// +/// A system that manages spatial partitioning. It determines which chunks should be loaded based on observers. +/// +public class ChunkStreamingSystem : MoonTools.ECS.System +{ + private readonly struct ChunkCoord : IEquatable + { + public readonly int X; + public readonly int Y; + public readonly int Z; + + // Pre-calculated on creation + public readonly long Id; + + public ChunkCoord(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + + // We allocate 21 bits per axis (allowing ~2 million chunks positive and negative). + var hashX = (long)x & 0x1FFFFF; + var hashY = (long)y & 0x1FFFFF; + var hashZ = (long)z & 0x1FFFFF; + + Id = hashX | (hashY << 21) | (hashZ << 42); + } + + public bool Equals(ChunkCoord other) => Id == other.Id; + public override bool Equals(object? obj) => obj is ChunkCoord other && Equals(other); + public override int GetHashCode() => Id.GetHashCode(); + } + + // Configurable size of a chunk in world coordinates. + public float ChunkSize { get; set; } = 64f; + + private readonly Filter _observerFilter; + private readonly Filter _chunkMemberFilter; + private readonly Filter _unloadedFilter; + + // Active loaded/loading chunks + private readonly Dictionary _activeChunks = new(); + + // Queue of chunks waiting to be loaded + private readonly Queue _pendingLoads = new(); + + // Queue of chunks waiting to be completely tagged for unloading + private readonly Queue _pendingUnloads = new(); + + public ChunkStreamingSystem(World world) : base(world) + { + _observerFilter = FilterBuilder + .Include() + .Include() // Needs a world position + .Exclude() // Ignore dying observers + .Build(); + + _chunkMemberFilter = FilterBuilder + .Include() + .Exclude() // Ignore entities already marked for death + .Build(); + + _unloadedFilter = FilterBuilder + .Include() + .Build(); + } + + public override void Update(TimeSpan delta) + { + var requiredChunks = new HashSet(); + + // 1. Find all chunks that should be loaded based on observers + foreach (var observerEntity in _observerFilter.Entities) + { + var observer = Get(observerEntity); + var transform = Get(observerEntity); + + // Convert world pos to grid coordinates + var worldPos = transform.localToWorldMatrix.Translation; + var centerChunk = GetChunkCoord(worldPos); + + // Determine chunk radius based on observer radius and chunk size + int chunkRadius = (int)MathF.Ceiling(observer.ViewRadius / ChunkSize); + + for (int x = -chunkRadius; x <= chunkRadius; x++) + { + for (int y = -chunkRadius; y <= chunkRadius; y++) + { + for (int z = -chunkRadius; z <= chunkRadius; z++) + { + var coord = new ChunkCoord(centerChunk.X + x, centerChunk.Y + y, centerChunk.Z + z); + requiredChunks.Add(coord); + } + } + } + } + + // 2. Unload chunks that are active but no longer required + var chunksToUnload = new List(); + foreach (var activeChunk in _activeChunks.Keys) + { + if (!requiredChunks.Contains(activeChunk) && _activeChunks[activeChunk] != ChunkState.Unloading) + { + chunksToUnload.Add(activeChunk); + } + } + + foreach (var coord in chunksToUnload) + { + _activeChunks[coord] = ChunkState.Unloading; + _pendingUnloads.Enqueue(coord); + } + + // 3. Queue newly required chunks + foreach (var coord in requiredChunks) + { + if (!_activeChunks.ContainsKey(coord)) + { + // Mark as unloaded so we don't queue it multiple times + _activeChunks[coord] = ChunkState.Unloaded; + _pendingLoads.Enqueue(coord); + } + } + + // 4. Process only ONE chunk load per frame to prevent stuttering + if (_pendingLoads.Count > 0) + { + var chunkToLoad = _pendingLoads.Dequeue(); + // Double check it wasn't unloaded before we got around to loading it + if (_activeChunks.TryGetValue(chunkToLoad, out var state) && state == ChunkState.Unloaded) + { + LoadChunk(chunkToLoad); + } + } + + // 5. Process only ONE chunk unload tagging per frame + if (_pendingUnloads.Count > 0) + { + var chunkToUnload = _pendingUnloads.Dequeue(); + UnloadChunk(chunkToUnload); + } + } + + private ChunkCoord GetChunkCoord(Vector3 worldPos) + { + return new ChunkCoord( + (int)MathF.Floor(worldPos.X / ChunkSize), + (int)MathF.Floor(worldPos.Y / ChunkSize), + (int)MathF.Floor(worldPos.Z / ChunkSize) + ); + } + + private void LoadChunk(ChunkCoord coord) + { + _activeChunks[coord] = ChunkState.Loading; + + // TODO: In a real system, you'd queue async I/O here to read SceneData for this chunk + // and spawn the entities. Once they are all spawned, set state to Loaded. + + // For demonstration, immediately set to loaded. + _activeChunks[coord] = ChunkState.Loaded; + } + + private void UnloadChunk(ChunkCoord coord) + { + // Instead of destroying everything instantly, we tag the entities as 'Unloaded' + // so that they stop participating in rendering/gameplay, and get destroyed slowly + // by the ChunkTeardownSystem. + long coordId = coord.Id; + foreach (var entity in _chunkMemberFilter.Entities) + { + var chunkMember = Get(entity); + if (chunkMember.ChunkId == coordId) + { + Set(entity, new ChunkUnloadPendingTag()); + } + } + + // Immediately remove it from the required grid so it can be re-loaded + // if the player turns around quickly, while older entities are just garbage collected. + _activeChunks.Remove(coord); + } +} diff --git a/Nerfed.Runtime/Scene/Streaming/ChunkTeardownSystem.cs b/Nerfed.Runtime/Scene/Streaming/ChunkTeardownSystem.cs new file mode 100644 index 0000000..f94d1fb --- /dev/null +++ b/Nerfed.Runtime/Scene/Streaming/ChunkTeardownSystem.cs @@ -0,0 +1,35 @@ +using MoonTools.ECS; +using System; + +namespace Nerfed.Runtime.Scene.Streaming; + +/// +/// A centralized cleanup system for slowly destroying chunk entities to avoid frame stutters. +/// +public class ChunkTeardownSystem : MoonTools.ECS.System +{ + private readonly Filter _unloadedFilter; + + // Adjustable limit to prevent massive stutters when unloading chunks. + public int MaxEntitiesToDestroyPerFrame { get; set; } = 250; + + public ChunkTeardownSystem(World world) : base(world) + { + _unloadedFilter = FilterBuilder + .Include() + .Build(); + } + + public override void Update(TimeSpan delta) + { + int destroyed = 0; + + foreach (var entity in _unloadedFilter.Entities) + { + if (destroyed >= MaxEntitiesToDestroyPerFrame) break; + + Destroy(entity); + destroyed++; + } + } +} diff --git a/Nerfed.Runtime/Scene/Streaming/StreamingComponents.cs b/Nerfed.Runtime/Scene/Streaming/StreamingComponents.cs new file mode 100644 index 0000000..0da245e --- /dev/null +++ b/Nerfed.Runtime/Scene/Streaming/StreamingComponents.cs @@ -0,0 +1,28 @@ +using MoonTools.ECS; +using System.Numerics; + +namespace Nerfed.Runtime.Scene.Streaming; + +/// +/// Marks an entity as a streaming observer (e.g. the player camera) that causes chunks +/// to be loaded around it. +/// +public struct ChunkObserverComponent +{ + public float ViewRadius; +} + +/// +/// Tags an entity as belonging to a specific chunk, allowing it to be unloaded when the chunk is out of range. +/// +public struct ChunkMemberComponent +{ + // A 64-bit spatial hash combining the X, Y, and Z coordinates. + public long ChunkId; +} + +/// +/// Added to entities that belong to a chunk that has been unloaded. +/// A dedicated system will process and destroy these slowly over multiple frames. +/// +public struct ChunkUnloadPendingTag { } diff --git a/Nerfed.Runtime/Systems/LocalToWorldSystem.cs b/Nerfed.Runtime/Systems/LocalToWorldSystem.cs index 1949807..8e83ebe 100644 --- a/Nerfed.Runtime/Systems/LocalToWorldSystem.cs +++ b/Nerfed.Runtime/Systems/LocalToWorldSystem.cs @@ -1,6 +1,8 @@ using MoonTools.ECS; using Nerfed.Runtime.Components; using Nerfed.Runtime.Util; +using System; +using System.Collections.Generic; using System.Numerics; // TODO: @@ -15,10 +17,19 @@ namespace Nerfed.Runtime.Systems { public class LocalToWorldSystem : MoonTools.ECS.System { - private readonly bool useParallelFor = true; // When having a low amount of transforms or when in debug mode this might be slower. + public override IReadOnlySet ReadsComponents { get; } = new HashSet { typeof(LocalTransform) }; + public override IReadOnlySet WritesComponents { get; } = new HashSet { typeof(LocalToWorld) }; + + private readonly bool useParallelFor = true; + private const int ParallelForMinCount = 32; // Below this, parallel overhead costs more than it saves. + private static readonly System.Threading.Tasks.ParallelOptions ParallelOptions = new() + { + MaxDegreeOfParallelism = Environment.ProcessorCount + }; private readonly Filter rootEntitiesFilter; private readonly Filter entitiesWithoutLocalToWorldFilter; private readonly Action updateWorldTransform; + private ParallelWriter _parallelWriter; public LocalToWorldSystem(World world) : base(world) { @@ -39,50 +50,64 @@ namespace Nerfed.Runtime.Systems if (useParallelFor) { - Profiler.BeginSample("ParallelFor.LocalToWorldCheck"); // This check is needed because some entities might not have a LocalToWorld component yet. // Adding this during the loop will break. + Profiler.BeginSample("ParallelFor.LocalToWorldCheck"); foreach (Entity entity in entitiesWithoutLocalToWorldFilter.Entities) { Set(entity, new LocalToWorld(Matrix4x4.Identity)); } Profiler.EndSample(); + // Acquire a ParallelWriter AFTER pre-allocation — all entities now have LocalToWorld. + // This writer only permits updating existing values; no structural mutations allowed. + _parallelWriter = World.GetParallelWriter(); + Profiler.BeginSample("ParallelFor.LocalToWorldUpdate"); - // This should only be used when the filter doesn't change by executing these functions! - // So no entity deletion or setting/removing of components used by the filters in this loop. - Parallel.For(0, rootEntitiesFilter.Count, updateWorldTransform); + if (rootEntitiesFilter.Count >= ParallelForMinCount) + { + Parallel.For(0, rootEntitiesFilter.Count, ParallelOptions, updateWorldTransform); + } + else + { + // Not enough work to justify thread overhead — run serially. + for (int i = 0; i < rootEntitiesFilter.Count; i++) + { + updateWorldTransform(i); + } + } Profiler.EndSample(); } else { foreach (Entity entity in rootEntitiesFilter.Entities) { - Profiler.BeginSample("UpdateWorldTransform"); + // Profiler.BeginSample("UpdateWorldTransform"); UpdateWorldTransform(entity, Matrix4x4.Identity); - Profiler.EndSample(); + // Profiler.EndSample(); } } } private void UpdateWorldTransformByIndex(int entityFilterIndex) { - Profiler.BeginSample("UpdateWorldTransformByIndex"); + // Profiler.BeginSample("UpdateWorldTransformByIndex"); Entity entity = rootEntitiesFilter.NthEntity(entityFilterIndex); UpdateWorldTransform(entity, Matrix4x4.Identity); - Profiler.EndSample(); + // Profiler.EndSample(); } private void UpdateWorldTransform(in Entity entity, Matrix4x4 localToWorldMatrix) { - // TODO: Only update dirty transforms. - // If a parent is dirty all the children need to update their localToWorld matrix. - // How do we check if something is dirty? How do we know if a LocalTransform has been changed? if (Has(entity)) { LocalTransform localTransform = Get(entity); localToWorldMatrix = Matrix4x4.Multiply(localToWorldMatrix, localTransform.TRS()); LocalToWorld localToWorld = new(localToWorldMatrix); - Set(entity, localToWorld); + + if (useParallelFor) + _parallelWriter.Set(entity, localToWorld); // thread-safe: direct write, no structural mutation + else + Set(entity, localToWorld); } ReverseSpanEnumerator childEntities = World.InRelations(entity);