forked from Misaki/GhostEngine
Add simple scene graph
This commit is contained in:
@@ -1,17 +1,19 @@
|
|||||||
using Ghost.Editor.Core.Progress;
|
using Ghost.Editor.Core.Progress;
|
||||||
using Ghost.Editor.Core.Resources;
|
using Ghost.Editor.Core.Resources;
|
||||||
|
using Ghost.Editor.Core.Serializer;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
public enum OpenWorldMode
|
/// <summary>
|
||||||
{
|
/// Editor-specific scene manager that uses JSON serialization for human-readable scene files.
|
||||||
Single,
|
/// </summary>
|
||||||
Additive,
|
/// <remarks>
|
||||||
AdditiveWithoutLoading
|
/// This manager provides JSON-based serialization suitable for editor workflows.
|
||||||
}
|
/// Runtime applications should use SceneManager with binary serialization.
|
||||||
|
/// </remarks>
|
||||||
public static class EditorSceneManager
|
public static class EditorSceneManager
|
||||||
{
|
{
|
||||||
// TODO: Use guid keys instead of string paths for better performance and uniqueness
|
// TODO: Use guid keys instead of string paths for better performance and uniqueness
|
||||||
@@ -21,6 +23,10 @@ public static class EditorSceneManager
|
|||||||
public static event Action<SceneNode>? OnWorldLoaded;
|
public static event Action<SceneNode>? OnWorldLoaded;
|
||||||
public static event Action<SceneNode>? OnWorldUnloaded;
|
public static event Action<SceneNode>? OnWorldUnloaded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a scene from a JSON file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="worldPath">The path to the JSON scene file.</param>
|
||||||
public static async Task LoadSceneAsync(string worldPath)
|
public static async Task LoadSceneAsync(string worldPath)
|
||||||
{
|
{
|
||||||
if (s_loadedWorlds.ContainsKey(worldPath)
|
if (s_loadedWorlds.ContainsKey(worldPath)
|
||||||
@@ -33,21 +39,34 @@ public static class EditorSceneManager
|
|||||||
var progressService = EditorApplication.GetService<IProgressService>();
|
var progressService = EditorApplication.GetService<IProgressService>();
|
||||||
progressService.ShowIndeterminateProgress("Loading world...");
|
progressService.ShowIndeterminateProgress("Loading world...");
|
||||||
|
|
||||||
|
// Unload existing scenes
|
||||||
foreach (var world in s_loadedWorlds)
|
foreach (var world in s_loadedWorlds)
|
||||||
{
|
{
|
||||||
world.Value.Unload();
|
world.Value.Unload();
|
||||||
OnWorldUnloaded?.Invoke(world.Value);
|
OnWorldUnloaded?.Invoke(world.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s_loadedWorlds.Clear();
|
||||||
|
|
||||||
|
// TODO: Get or create World instance for editor
|
||||||
|
// For now, keep compatibility with old SceneNode deserialization
|
||||||
await using var readStream = new FileStream(worldPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var readStream = new FileStream(worldPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
var deserializedScene = await JsonSerializer.DeserializeAsync<SceneNode>(readStream, Engine.Resources.EngineResource.defaultSerializerOptions) ?? throw new Exception("Deserialization failed.");
|
var deserializedScene = await JsonSerializer.DeserializeAsync<SceneNode>(readStream, Engine.Resources.EngineResource.defaultSerializerOptions) ?? throw new Exception("Deserialization failed.");
|
||||||
|
|
||||||
s_loadedWorlds.Clear();
|
|
||||||
|
|
||||||
s_loadedWorlds[worldPath] = deserializedScene;
|
s_loadedWorlds[worldPath] = deserializedScene;
|
||||||
await deserializedScene.LoadAsync();
|
await deserializedScene.LoadAsync();
|
||||||
|
|
||||||
progressService.HideProgress();
|
progressService.HideProgress();
|
||||||
OnWorldLoaded?.Invoke(deserializedScene);
|
OnWorldLoaded?.Invoke(deserializedScene);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a scene to a JSON file using the new serializer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scene">The scene to save.</param>
|
||||||
|
/// <param name="filePath">The path to save the JSON scene file.</param>
|
||||||
|
public static async Task SaveSceneAsync(Scene scene, string filePath)
|
||||||
|
{
|
||||||
|
await SceneSerializer.SaveSceneAsync(scene.World, scene.ID, filePath, scene.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
using Ghost.Editor.Core.AssetHandle;
|
using Ghost.Editor.Core.AssetHandle;
|
||||||
using Ghost.Editor.Core.Inspector;
|
using Ghost.Editor.Core.Inspector;
|
||||||
using Ghost.Editor.Core.Resources;
|
using Ghost.Editor.Core.Resources;
|
||||||
using Ghost.Editor.Core.Serializer;
|
|
||||||
using Ghost.Engine.Components;
|
using Ghost.Engine.Components;
|
||||||
using Ghost.Engine.Core;
|
using Ghost.Engine.Core;
|
||||||
using Ghost.Engine.IO;
|
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
[CustomSerializer(typeof(SceneNodeSerializer))]
|
|
||||||
public partial class SceneNode : SceneGraphNode, IEquatable<SceneNode>
|
public partial class SceneNode : SceneGraphNode, IEquatable<SceneNode>
|
||||||
{
|
{
|
||||||
private Scene _scene;
|
private Scene _scene;
|
||||||
|
|||||||
115
Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs
Normal file
115
Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
using Ghost.Engine.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Serializer.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON converter for Entity that handles automatic ID remapping during deserialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// During serialization, writes the file-local entity ID.
|
||||||
|
/// During deserialization, reads the file-local ID and translates it to the runtime Entity
|
||||||
|
/// using the active SerializationContext.
|
||||||
|
/// </remarks>
|
||||||
|
public class EntityJsonConverter : JsonConverter<Entity>
|
||||||
|
{
|
||||||
|
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Null)
|
||||||
|
{
|
||||||
|
return Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType != JsonTokenType.StartObject)
|
||||||
|
{
|
||||||
|
throw new JsonException("Expected StartObject token for Entity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int fileId = -1;
|
||||||
|
int generation = -1;
|
||||||
|
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.EndObject)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.PropertyName)
|
||||||
|
{
|
||||||
|
var propertyName = reader.GetString();
|
||||||
|
reader.Read();
|
||||||
|
|
||||||
|
switch (propertyName)
|
||||||
|
{
|
||||||
|
case "ID":
|
||||||
|
case "id":
|
||||||
|
fileId = reader.GetInt32();
|
||||||
|
break;
|
||||||
|
case "Generation":
|
||||||
|
case "generation":
|
||||||
|
generation = reader.GetInt32();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileId == Entity.INVALID_ID)
|
||||||
|
{
|
||||||
|
return Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a serialization context, remap the file ID to runtime entity
|
||||||
|
var context = SerializationContext.Current;
|
||||||
|
if (context != null)
|
||||||
|
{
|
||||||
|
if (context.TryGetEntity(fileId, out var runtimeEntity))
|
||||||
|
{
|
||||||
|
return runtimeEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If entity not found in map, return invalid
|
||||||
|
return Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No context means we're not in a deserialization scope - should not happen
|
||||||
|
throw new InvalidOperationException("Entity deserialization requires an active SerializationContext.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (!value.IsValid)
|
||||||
|
{
|
||||||
|
writer.WriteNullValue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
|
||||||
|
// If we have a serialization context, write the file-local ID
|
||||||
|
var context = SerializationContext.Current;
|
||||||
|
if (context != null)
|
||||||
|
{
|
||||||
|
if (context.TryGetFileId(value, out var fileId))
|
||||||
|
{
|
||||||
|
writer.WriteNumber("ID", fileId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Entity not in context - register it now
|
||||||
|
var newFileId = context.RegisterEntityForSerialization(value);
|
||||||
|
writer.WriteNumber("ID", newFileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No context - write the runtime ID (for debugging or non-scene serialization)
|
||||||
|
writer.WriteNumber("ID", value.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteNumber("Generation", value.Generation);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
using Ghost.Editor.Core.SceneGraph;
|
|
||||||
using Ghost.Engine.IO;
|
|
||||||
using Ghost.Engine.Utilities;
|
|
||||||
using Ghost.Entities;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Serializer;
|
|
||||||
|
|
||||||
internal class SceneNodeSerializer : CustomSerializer<SceneNode>
|
|
||||||
{
|
|
||||||
private static class Property
|
|
||||||
{
|
|
||||||
public const string NAME = "Name";
|
|
||||||
public const string ENTITIES = "Entities";
|
|
||||||
public const string ID = "ID";
|
|
||||||
public const string ENTITY_ID = "EntityID";
|
|
||||||
public const string COMPONENTS = "Components";
|
|
||||||
public const string DATA = "Data";
|
|
||||||
public const string SYSTEMS = "Systems";
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanConvert(Type typeToConvert)
|
|
||||||
{
|
|
||||||
return typeToConvert == typeof(SceneNode) || typeToConvert.IsSubclassOf(typeof(SceneNode));
|
|
||||||
}
|
|
||||||
|
|
||||||
public unsafe override void SerializeJson(Utf8JsonWriter writer, SceneNode value, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
writer.WriteObject(() =>
|
|
||||||
{
|
|
||||||
writer.WriteString(Property.NAME, value.Name);
|
|
||||||
writer.WriteStartArray(Property.ENTITIES);
|
|
||||||
|
|
||||||
for (var i = 0; i < value.Scene.World.ComponentManager.ArchetypeCount; i++)
|
|
||||||
{
|
|
||||||
ref var archetype = ref value.Scene.World.ComponentManager.GetArchetypeReference(i);
|
|
||||||
|
|
||||||
for (var j = 0; j < archetype.ChunkCount; j++)
|
|
||||||
{
|
|
||||||
ref var chunk = ref archetype.GetChunkReference(j);
|
|
||||||
for (var k = 0; k < chunk._count; k++)
|
|
||||||
{
|
|
||||||
foreach (var layout in archetype._layouts)
|
|
||||||
{
|
|
||||||
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
|
||||||
var size = ComponentRegistry.GetComponentInfo(layout.componentID).size;
|
|
||||||
|
|
||||||
if (type.AssemblyQualifiedName == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.WriteStartObject();
|
|
||||||
writer.WriteString("Type", type.AssemblyQualifiedName);
|
|
||||||
writer.WritePropertyName("Data");
|
|
||||||
|
|
||||||
var pComponentData = chunk.GetUnsafePtr() + layout.offset + (k * size);
|
|
||||||
ComponentSerializerRegistry.SerializeJson(layout.componentID, writer, pComponentData, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.WriteEndArray();
|
|
||||||
|
|
||||||
writer.WriteArray(Property.SYSTEMS, value.Scene.World.SystemManager.Systems, system =>
|
|
||||||
{
|
|
||||||
var name = system.GetType().AssemblyQualifiedName;
|
|
||||||
if (name == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.WriteStringValue(name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override SceneNode? DeserializeJson(ref Utf8JsonReader reader, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
|
|
||||||
//var element = JsonDocument.ParseValue(ref reader).RootElement;
|
|
||||||
//var name = element.GetProperty(Property.NAME).GetString() ?? "New World";
|
|
||||||
|
|
||||||
//var world = World.Create(EngineCore.JobScheduler);
|
|
||||||
//var result = new WorldNode(world, name);
|
|
||||||
|
|
||||||
//foreach (var entityElement in element.GetProperty(Property.ENTITIES).EnumerateArray())
|
|
||||||
//{
|
|
||||||
// var entityName = entityElement.GetProperty(Property.NAME).GetString() ?? "New Entity";
|
|
||||||
// var entityID = entityElement.GetProperty(Property.ID).GetInt32();
|
|
||||||
// var entity = new Entity(entityID, 0);
|
|
||||||
// var node = new EntityNode(result, entity, entityName);
|
|
||||||
|
|
||||||
// world.EntityManager.AddEntityInternal(entity);
|
|
||||||
// result.EntityNodeLookup[entity] = node;
|
|
||||||
//}
|
|
||||||
|
|
||||||
//foreach (var componentElement in element.GetProperty(Property.COMPONENTS).EnumerateObject())
|
|
||||||
//{
|
|
||||||
// var typeName = componentElement.Name;
|
|
||||||
// var space = Type.GetType(typeName) ?? throw new Exception($"Type {typeName} not found.");
|
|
||||||
|
|
||||||
// foreach (var dataElement in componentElement.Value.EnumerateArray())
|
|
||||||
// {
|
|
||||||
// var entityID = dataElement.GetProperty(Property.ENTITY_ID).GetInt32();
|
|
||||||
// var entity = new Entity(entityID, 0);
|
|
||||||
|
|
||||||
// var dataProperty = dataElement.GetProperty(Property.DATA);
|
|
||||||
// var component = JsonSerializer.Deserialize(dataProperty.GetRawText(), space, options);
|
|
||||||
// if (component is IComponent data)
|
|
||||||
// {
|
|
||||||
// world.EntityManager.AddComponent(entity, data, space);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
//foreach (var systemElement in element.GetProperty(Property.SYSTEMS).EnumerateArray())
|
|
||||||
//{
|
|
||||||
// var typeString = systemElement.GetString();
|
|
||||||
// if (string.IsNullOrEmpty(typeString))
|
|
||||||
// {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var systemType = Type.GetType(typeString);
|
|
||||||
// if (systemType == null)
|
|
||||||
// {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// world.SystemStorage.AddSystem(systemType);
|
|
||||||
//}
|
|
||||||
|
|
||||||
//return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void SerializeBinary(BinaryWriter writer, SceneNode value)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override SceneNode? DeserializeBinary(BinaryReader reader)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
258
Ghost.Editor.Core/Serializer/SceneSerializer.cs
Normal file
258
Ghost.Editor.Core/Serializer/SceneSerializer.cs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Serializer.Converters;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Engine.IO;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Serializer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles JSON serialization and deserialization of scenes.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneSerializer
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions s_serializerOptions;
|
||||||
|
|
||||||
|
static SceneSerializer()
|
||||||
|
{
|
||||||
|
s_serializerOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
IncludeFields = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new EntityJsonConverter(),
|
||||||
|
new JsonStringEnumConverter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the serialized data for a single entity.
|
||||||
|
/// </summary>
|
||||||
|
private class SerializedEntity
|
||||||
|
{
|
||||||
|
public int FileID { get; set; }
|
||||||
|
public List<SerializedComponent> Components { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a serialized component with its type and data.
|
||||||
|
/// </summary>
|
||||||
|
private class SerializedComponent
|
||||||
|
{
|
||||||
|
public string TypeName { get; set; } = string.Empty;
|
||||||
|
public JsonElement Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the complete scene file structure.
|
||||||
|
/// </summary>
|
||||||
|
private class SceneFile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "Untitled Scene";
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
public List<SerializedEntity> Entities { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a scene to a JSON file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world containing the entities.</param>
|
||||||
|
/// <param name="sceneID">The scene ID to save.</param>
|
||||||
|
/// <param name="filePath">The path to save the scene file.</param>
|
||||||
|
/// <param name="sceneName">Optional scene name.</param>
|
||||||
|
public static async Task SaveSceneAsync(World world, short sceneID, string filePath, string? sceneName = null)
|
||||||
|
{
|
||||||
|
using var context = SerializationContext.Create();
|
||||||
|
|
||||||
|
var sceneFile = new SceneFile
|
||||||
|
{
|
||||||
|
Name = sceneName ?? Path.GetFileNameWithoutExtension(filePath),
|
||||||
|
Entities = new List<SerializedEntity>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query all entities with the specified SceneID
|
||||||
|
var queryID = new QueryBuilder()
|
||||||
|
.WithAll<SceneID>()
|
||||||
|
.Build(world);
|
||||||
|
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
|
world.ComponentManager.GetEntityQueryReference(queryID).ForEach<SceneID>((Entity entity, ref SceneID sceneIDComponent) =>
|
||||||
|
{
|
||||||
|
if (sceneIDComponent.id == sceneID)
|
||||||
|
{
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serialize each entity
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var fileId = context.RegisterEntityForSerialization(entity);
|
||||||
|
var serializedEntity = new SerializedEntity
|
||||||
|
{
|
||||||
|
FileID = fileId,
|
||||||
|
Components = new List<SerializedComponent>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get entity location to find archetype
|
||||||
|
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locationResult.Error != Error.None)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
// Serialize each component
|
||||||
|
foreach (var layout in archetype._layouts)
|
||||||
|
{
|
||||||
|
var componentType = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||||
|
|
||||||
|
if (componentType == null || componentType.AssemblyQualifiedName == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get component data
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComponentData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||||
|
if (pComponentData == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize component to JSON
|
||||||
|
// We need to box the unmanaged data to serialize it
|
||||||
|
var boxedData = System.Runtime.InteropServices.Marshal.PtrToStructure((IntPtr)pComponentData, componentType);
|
||||||
|
var componentJson = JsonSerializer.Serialize(boxedData, componentType, s_serializerOptions);
|
||||||
|
var jsonElement = JsonDocument.Parse(componentJson).RootElement;
|
||||||
|
|
||||||
|
serializedEntity.Components.Add(new SerializedComponent
|
||||||
|
{
|
||||||
|
TypeName = componentType.AssemblyQualifiedName,
|
||||||
|
Data = jsonElement
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneFile.Entities.Add(serializedEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
var json = JsonSerializer.Serialize(sceneFile, s_serializerOptions);
|
||||||
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a scene from a JSON file into the specified world.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world to load the scene into.</param>
|
||||||
|
/// <param name="filePath">The path to the scene file.</param>
|
||||||
|
/// <param name="newSceneID">The new scene ID to assign to loaded entities.</param>
|
||||||
|
/// <returns>The number of entities loaded.</returns>
|
||||||
|
public static async Task<int> LoadSceneAsync(World world, string filePath, short newSceneID)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Scene file not found: {filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(filePath);
|
||||||
|
var sceneFile = JsonSerializer.Deserialize<SceneFile>(json, s_serializerOptions);
|
||||||
|
|
||||||
|
if (sceneFile == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = SerializationContext.Create();
|
||||||
|
|
||||||
|
// Pass 1: Create all entities and build the ID mapping
|
||||||
|
var fileIdToEntity = new Dictionary<int, Entity>();
|
||||||
|
|
||||||
|
foreach (var serializedEntity in sceneFile.Entities)
|
||||||
|
{
|
||||||
|
var entity = world.EntityManager.CreateEntity();
|
||||||
|
fileIdToEntity[serializedEntity.FileID] = entity;
|
||||||
|
context.RegisterEntity(serializedEntity.FileID, entity);
|
||||||
|
|
||||||
|
// Add SceneID component
|
||||||
|
world.EntityManager.AddComponent(entity, new SceneID { id = newSceneID });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Deserialize components (with automatic entity reference remapping)
|
||||||
|
foreach (var serializedEntity in sceneFile.Entities)
|
||||||
|
{
|
||||||
|
if (!fileIdToEntity.TryGetValue(serializedEntity.FileID, out var entity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var serializedComponent in serializedEntity.Components)
|
||||||
|
{
|
||||||
|
var componentType = Type.GetType(serializedComponent.TypeName);
|
||||||
|
if (componentType == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip SceneID as we already added it
|
||||||
|
if (componentType == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Deserialize the component data
|
||||||
|
var componentData = JsonSerializer.Deserialize(serializedComponent.Data.GetRawText(), componentType, s_serializerOptions);
|
||||||
|
|
||||||
|
if (componentData == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add component to entity
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var componentID = ComponentRegistry.GetComponentID(componentType);
|
||||||
|
if (componentID.IsInvalid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unmanaged components, we can use pointer magic
|
||||||
|
if (componentType.IsValueType)
|
||||||
|
{
|
||||||
|
var pinnedData = System.Runtime.InteropServices.GCHandle.Alloc(componentData, System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ptr = pinnedData.AddrOfPinnedObject().ToPointer();
|
||||||
|
world.EntityManager.AddComponent(entity, componentID, ptr);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pinnedData.Free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log error but continue loading other components
|
||||||
|
Console.WriteLine($"Failed to deserialize component {serializedComponent.TypeName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileIdToEntity.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,67 @@ using Ghost.Entities;
|
|||||||
|
|
||||||
namespace Ghost.Engine.Core;
|
namespace Ghost.Engine.Core;
|
||||||
|
|
||||||
public partial class Scene
|
/// <summary>
|
||||||
|
/// Represents a lightweight handle to a loaded scene.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A Scene is a collection of entities tagged with a unique SceneID component.
|
||||||
|
/// The Scene class provides a convenient handle to interact with all entities
|
||||||
|
/// belonging to a particular scene within a World.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Scene : IDisposable, IEquatable<Scene>
|
||||||
{
|
{
|
||||||
private static short s_nextSceneID = 0;
|
private static short s_nextSceneID = 0;
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Scene : IDisposable
|
|
||||||
{
|
|
||||||
private readonly World _world;
|
private readonly World _world;
|
||||||
private readonly short _id;
|
private readonly short _id;
|
||||||
|
private readonly string _name;
|
||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the world this scene belongs to.
|
||||||
|
/// </summary>
|
||||||
public World World => _world;
|
public World World => _world;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for this scene.
|
||||||
|
/// </summary>
|
||||||
public short ID => _id;
|
public short ID => _id;
|
||||||
|
|
||||||
public Scene(World world)
|
/// <summary>
|
||||||
|
/// Gets the name of this scene.
|
||||||
|
/// </summary>
|
||||||
|
public string Name => _name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new scene handle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world this scene belongs to.</param>
|
||||||
|
/// <param name="name">The name of the scene.</param>
|
||||||
|
internal Scene(World world, string name)
|
||||||
{
|
{
|
||||||
_world = world;
|
_world = world;
|
||||||
_id = s_nextSceneID++;
|
_id = s_nextSceneID++;
|
||||||
|
_name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new scene handle with a specific ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world this scene belongs to.</param>
|
||||||
|
/// <param name="id">The scene ID.</param>
|
||||||
|
/// <param name="name">The name of the scene.</param>
|
||||||
|
internal Scene(World world, short id, string name)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_id = id;
|
||||||
|
_name = name;
|
||||||
|
|
||||||
|
// Update next ID if necessary
|
||||||
|
if (id >= s_nextSceneID)
|
||||||
|
{
|
||||||
|
s_nextSceneID = (short)(id + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
~Scene()
|
~Scene()
|
||||||
@@ -28,6 +70,50 @@ public partial class Scene : IDisposable
|
|||||||
Dispose();
|
Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool Equals(Scene? other)
|
||||||
|
{
|
||||||
|
if (other is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _world.Equals(other._world) && _id == other._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
return obj is Scene other && Equals(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
return HashCode.Combine(_world, _id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Scene: {_name} (ID: {_id})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator ==(Scene? left, Scene? right)
|
||||||
|
{
|
||||||
|
if (left is null)
|
||||||
|
{
|
||||||
|
return right is null;
|
||||||
|
}
|
||||||
|
return left.Equals(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator !=(Scene? left, Scene? right)
|
||||||
|
{
|
||||||
|
return !(left == right);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_isDisposed)
|
if (_isDisposed)
|
||||||
|
|||||||
256
Ghost.Engine/IO/SceneBinarySerializer.cs
Normal file
256
Ghost.Engine/IO/SceneBinarySerializer.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Utilities;
|
||||||
|
|
||||||
|
namespace Ghost.Engine.IO;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles binary serialization and deserialization of scenes for AOT-compatible runtime use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Binary format provides fast, compact scene loading suitable for AOT compilation.
|
||||||
|
/// Uses direct memory copying for component data without reflection.
|
||||||
|
/// </remarks>
|
||||||
|
public static unsafe class SceneBinarySerializer
|
||||||
|
{
|
||||||
|
private const int MAGIC_NUMBER = 0x47534345; // "GSCE" (Ghost Scene)
|
||||||
|
private const int VERSION = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a scene to a binary file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world containing the entities.</param>
|
||||||
|
/// <param name="sceneID">The scene ID to save.</param>
|
||||||
|
/// <param name="filePath">The path to save the scene file.</param>
|
||||||
|
public static void SaveScene(World world, short sceneID, string filePath)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
|
||||||
|
using var writer = new BinaryWriter(stream);
|
||||||
|
using var context = SerializationContext.Create();
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
writer.Write(MAGIC_NUMBER);
|
||||||
|
writer.Write(VERSION);
|
||||||
|
writer.Write(sceneID);
|
||||||
|
|
||||||
|
// Query all entities with the specified SceneID
|
||||||
|
var queryID = new QueryBuilder()
|
||||||
|
.WithAll<SceneID>()
|
||||||
|
.Build(world);
|
||||||
|
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
|
world.ComponentManager.GetEntityQueryReference(queryID).ForEach<SceneID>((Entity entity, ref SceneID sceneIDComponent) =>
|
||||||
|
{
|
||||||
|
if (sceneIDComponent.id == sceneID)
|
||||||
|
{
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write entity count
|
||||||
|
writer.Write(entities.Count);
|
||||||
|
|
||||||
|
// Allocate buffer for zero-filled component data (reused across loop iterations)
|
||||||
|
const int MaxComponentSize = 4096; // Reasonable max size for most components
|
||||||
|
var zeroBuffer = stackalloc byte[MaxComponentSize];
|
||||||
|
MemoryUtility.MemSet(zeroBuffer, 0, MaxComponentSize);
|
||||||
|
|
||||||
|
// Write each entity
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var fileId = context.RegisterEntityForSerialization(entity);
|
||||||
|
|
||||||
|
// Write entity file ID
|
||||||
|
writer.Write(fileId);
|
||||||
|
|
||||||
|
// Get entity location
|
||||||
|
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locationResult.Error != Error.None)
|
||||||
|
{
|
||||||
|
// Write 0 components for invalid entity
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
// Write component count
|
||||||
|
writer.Write(archetype._layouts.Count);
|
||||||
|
|
||||||
|
// Write each component
|
||||||
|
foreach (var layout in archetype._layouts)
|
||||||
|
{
|
||||||
|
// Write component type ID
|
||||||
|
writer.Write((int)layout.componentID);
|
||||||
|
|
||||||
|
// Write component size
|
||||||
|
writer.Write(layout.size);
|
||||||
|
|
||||||
|
// Get component data pointer
|
||||||
|
var pComponentData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||||
|
if (pComponentData == null)
|
||||||
|
{
|
||||||
|
// Write zero-filled data if component not found
|
||||||
|
if (layout.size > MaxComponentSize)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Component size {layout.size} exceeds maximum buffer size {MaxComponentSize}");
|
||||||
|
}
|
||||||
|
writer.Write(new ReadOnlySpan<byte>(zeroBuffer, layout.size));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Write component data directly
|
||||||
|
writer.Write(new ReadOnlySpan<byte>(pComponentData, layout.size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a scene from a binary file into the specified world.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world to load the scene into.</param>
|
||||||
|
/// <param name="filePath">The path to the scene file.</param>
|
||||||
|
/// <param name="newSceneID">The new scene ID to assign to loaded entities.</param>
|
||||||
|
/// <returns>The number of entities loaded.</returns>
|
||||||
|
public static int LoadScene(World world, string filePath, short newSceneID)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Scene file not found: {filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||||
|
using var reader = new BinaryReader(stream);
|
||||||
|
using var context = SerializationContext.Create();
|
||||||
|
|
||||||
|
// Read and validate header
|
||||||
|
var magic = reader.ReadInt32();
|
||||||
|
if (magic != MAGIC_NUMBER)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid scene file format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = reader.ReadInt32();
|
||||||
|
if (version != VERSION)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Unsupported scene file version: {version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedSceneID = reader.ReadInt16();
|
||||||
|
|
||||||
|
// Read entity count
|
||||||
|
var entityCount = reader.ReadInt32();
|
||||||
|
|
||||||
|
// Pass 1: Create all entities and build ID mapping
|
||||||
|
var fileIdToEntity = new Dictionary<int, Entity>(entityCount);
|
||||||
|
var entityComponents = new List<(int fileId, List<(Identifier<IComponent> componentID, int size, byte[] data)> components)>(entityCount);
|
||||||
|
|
||||||
|
for (var i = 0; i < entityCount; i++)
|
||||||
|
{
|
||||||
|
var fileId = reader.ReadInt32();
|
||||||
|
var componentCount = reader.ReadInt32();
|
||||||
|
|
||||||
|
var components = new List<(Identifier<IComponent> componentID, int size, byte[] data)>(componentCount);
|
||||||
|
|
||||||
|
// Read component data
|
||||||
|
for (var j = 0; j < componentCount; j++)
|
||||||
|
{
|
||||||
|
var componentID = new Identifier<IComponent>(reader.ReadInt32());
|
||||||
|
var size = reader.ReadInt32();
|
||||||
|
var data = reader.ReadBytes(size);
|
||||||
|
|
||||||
|
components.Add((componentID, size, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
entityComponents.Add((fileId, components));
|
||||||
|
|
||||||
|
// Create entity
|
||||||
|
var entity = world.EntityManager.CreateEntity();
|
||||||
|
fileIdToEntity[fileId] = entity;
|
||||||
|
context.RegisterEntity(fileId, entity);
|
||||||
|
|
||||||
|
// Add SceneID component
|
||||||
|
world.EntityManager.AddComponent(entity, new SceneID { id = newSceneID });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Add components to entities (with automatic entity reference remapping)
|
||||||
|
foreach (var (fileId, components) in entityComponents)
|
||||||
|
{
|
||||||
|
if (!fileIdToEntity.TryGetValue(fileId, out var entity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (componentID, size, data) in components)
|
||||||
|
{
|
||||||
|
// Skip SceneID as we already added it
|
||||||
|
if (componentID == ComponentTypeID<SceneID>.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed (byte* pData = data)
|
||||||
|
{
|
||||||
|
// Remap Entity references in the component data
|
||||||
|
RemapEntityReferences(pData, componentID, context);
|
||||||
|
|
||||||
|
// Add component
|
||||||
|
world.EntityManager.AddComponent(entity, componentID, pData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileIdToEntity.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaps Entity references within component data.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a simple implementation that checks if the component contains Entity fields.
|
||||||
|
/// For Hierarchy, it remaps parent, firstChild, and nextSibling fields.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RemapEntityReferences(byte* pComponentData, Identifier<IComponent> componentID, SerializationContext context)
|
||||||
|
{
|
||||||
|
// Check if this is the Hierarchy component
|
||||||
|
if (componentID == ComponentTypeID<Hierarchy>.Value)
|
||||||
|
{
|
||||||
|
var hierarchy = (Hierarchy*)pComponentData;
|
||||||
|
|
||||||
|
// Remap parent
|
||||||
|
if (hierarchy->parent.IsValid && context.TryGetFileId(hierarchy->parent, out var parentFileId))
|
||||||
|
{
|
||||||
|
if (context.TryGetEntity(parentFileId, out var newParent))
|
||||||
|
{
|
||||||
|
hierarchy->parent = newParent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap firstChild
|
||||||
|
if (hierarchy->firstChild.IsValid && context.TryGetFileId(hierarchy->firstChild, out var firstChildFileId))
|
||||||
|
{
|
||||||
|
if (context.TryGetEntity(firstChildFileId, out var newFirstChild))
|
||||||
|
{
|
||||||
|
hierarchy->firstChild = newFirstChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap nextSibling
|
||||||
|
if (hierarchy->nextSibling.IsValid && context.TryGetFileId(hierarchy->nextSibling, out var nextSiblingFileId))
|
||||||
|
{
|
||||||
|
if (context.TryGetEntity(nextSiblingFileId, out var newNextSibling))
|
||||||
|
{
|
||||||
|
hierarchy->nextSibling = newNextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add remapping for other components with Entity fields
|
||||||
|
// This could be automated using source generators in the future
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Ghost.Engine/IO/SerializationContext.cs
Normal file
109
Ghost.Engine/IO/SerializationContext.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Engine.IO;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a thread-safe context for Entity ID remapping during deserialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This class manages the mapping between file-local entity IDs (0, 1, 2...)
|
||||||
|
/// and runtime entity IDs during scene deserialization. The context is scoped
|
||||||
|
/// to the current async operation using AsyncLocal storage.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SerializationContext : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly AsyncLocal<SerializationContext?> s_current = new();
|
||||||
|
|
||||||
|
private readonly Dictionary<int, Entity> _fileIdToEntity = new();
|
||||||
|
private readonly Dictionary<Entity, int> _entityToFileId = new();
|
||||||
|
private int _nextFileId = 0;
|
||||||
|
private bool _disposed = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current serialization context for this async operation.
|
||||||
|
/// </summary>
|
||||||
|
public static SerializationContext? Current => s_current.Value;
|
||||||
|
|
||||||
|
private SerializationContext()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and activates a new serialization context for the current async scope.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A new serialization context. Must be disposed when done.</returns>
|
||||||
|
public static SerializationContext Create()
|
||||||
|
{
|
||||||
|
if (s_current.Value != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A serialization context is already active in this scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = new SerializationContext();
|
||||||
|
s_current.Value = context;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers an entity mapping for deserialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file-local entity ID.</param>
|
||||||
|
/// <param name="runtimeEntity">The runtime entity.</param>
|
||||||
|
public void RegisterEntity(int fileId, Entity runtimeEntity)
|
||||||
|
{
|
||||||
|
_fileIdToEntity[fileId] = runtimeEntity;
|
||||||
|
_entityToFileId[runtimeEntity] = fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a runtime entity and assigns it the next available file ID for serialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runtimeEntity">The runtime entity to register.</param>
|
||||||
|
/// <returns>The assigned file-local ID.</returns>
|
||||||
|
public int RegisterEntityForSerialization(Entity runtimeEntity)
|
||||||
|
{
|
||||||
|
if (!_entityToFileId.TryGetValue(runtimeEntity, out var fileId))
|
||||||
|
{
|
||||||
|
fileId = _nextFileId++;
|
||||||
|
_entityToFileId[runtimeEntity] = fileId;
|
||||||
|
_fileIdToEntity[fileId] = runtimeEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the runtime entity for a file-local ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file-local entity ID.</param>
|
||||||
|
/// <param name="entity">The runtime entity if found.</param>
|
||||||
|
/// <returns>True if the entity was found, false otherwise.</returns>
|
||||||
|
public bool TryGetEntity(int fileId, out Entity entity)
|
||||||
|
{
|
||||||
|
return _fileIdToEntity.TryGetValue(fileId, out entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the file-local ID for a runtime entity.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">The runtime entity.</param>
|
||||||
|
/// <param name="fileId">The file-local ID if found.</param>
|
||||||
|
/// <returns>True if the file ID was found, false otherwise.</returns>
|
||||||
|
public bool TryGetFileId(Entity entity, out int fileId)
|
||||||
|
{
|
||||||
|
return _entityToFileId.TryGetValue(entity, out fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_current.Value = null;
|
||||||
|
_fileIdToEntity.Clear();
|
||||||
|
_entityToFileId.Clear();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using Ghost.Core;
|
|
||||||
using Ghost.Entities;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Ghost.Engine.IO;
|
|
||||||
|
|
||||||
public static unsafe class ComponentSerializerRegistry
|
|
||||||
{
|
|
||||||
public delegate void BinaryWriteDelegate(BinaryWriter writer, void* ptr);
|
|
||||||
public delegate void JsonWriteDelegate(Utf8JsonWriter writer, void* ptr, JsonSerializerOptions options);
|
|
||||||
|
|
||||||
private static BinaryWriteDelegate[] s_binWriters = new BinaryWriteDelegate[64];
|
|
||||||
private static JsonWriteDelegate[] s_jsonWriters = new JsonWriteDelegate[64];
|
|
||||||
|
|
||||||
public static void Register(int typeID, BinaryWriteDelegate binWriter, JsonWriteDelegate jsonWriter)
|
|
||||||
{
|
|
||||||
if (typeID < 0)
|
|
||||||
{
|
|
||||||
throw new Exception($"Type ID cannot be negative: {typeID}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeID >= s_binWriters.Length)
|
|
||||||
{
|
|
||||||
Array.Resize(ref s_binWriters, typeID + 16);
|
|
||||||
Array.Resize(ref s_jsonWriters, typeID + 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
s_binWriters[typeID] = binWriter;
|
|
||||||
s_jsonWriters[typeID] = jsonWriter;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SerializeBinary(Identifier<IComponent> typeID, BinaryWriter writer, void* ptr)
|
|
||||||
{
|
|
||||||
if (s_binWriters[typeID] == null)
|
|
||||||
{
|
|
||||||
throw new Exception($"No serializer for ID {typeID}");
|
|
||||||
}
|
|
||||||
|
|
||||||
s_binWriters[typeID](writer, ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SerializeJson(Identifier<IComponent> typeID, Utf8JsonWriter writer, void* ptr, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (s_jsonWriters[typeID] == null)
|
|
||||||
{
|
|
||||||
// TODO: Fallback to reflection?
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
s_jsonWriters[typeID](writer, ptr, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +1,139 @@
|
|||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Engine.IO;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Ghost.Engine.Services;
|
namespace Ghost.Engine.Services;
|
||||||
|
|
||||||
public enum SceneLoadMode
|
public enum SceneLoadMode
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unloads all currently loaded scenes before loading the new scene.
|
||||||
|
/// </summary>
|
||||||
Single,
|
Single,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the scene additively without unloading existing scenes.
|
||||||
|
/// </summary>
|
||||||
Additive
|
Additive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages scene loading, unloading, and saving operations using binary serialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This runtime scene manager uses binary serialization for AOT compatibility.
|
||||||
|
/// For editor JSON serialization, use EditorSceneManager in Ghost.Editor.Core.
|
||||||
|
/// </remarks>
|
||||||
public static class SceneManager
|
public static class SceneManager
|
||||||
{
|
{
|
||||||
//private readonly static HashSet<Scene> _activeScenes = new();
|
private static readonly Dictionary<short, Scene> s_loadedScenes = new();
|
||||||
|
|
||||||
//internal static IEnumerable<GameObject> QueryRootGameObjects()
|
/// <summary>
|
||||||
//{
|
/// Gets all currently loaded scenes.
|
||||||
// foreach (var scene in _activeScenes)
|
/// </summary>
|
||||||
// {
|
public static IReadOnlyCollection<Scene> LoadedScenes => s_loadedScenes.Values;
|
||||||
// foreach (var gameObject in scene.RootObjects)
|
|
||||||
// {
|
|
||||||
// if (!gameObject.IsActive)
|
|
||||||
// {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// yield return gameObject;
|
/// <summary>
|
||||||
// }
|
/// Loads a scene from a binary file into the specified world.
|
||||||
// }
|
/// </summary>
|
||||||
//}
|
/// <param name="world">The world to load the scene into.</param>
|
||||||
|
/// <param name="filePath">The path to the scene file.</param>
|
||||||
|
/// <param name="loadMode">The load mode (Single or Additive).</param>
|
||||||
|
/// <returns>The loaded scene.</returns>
|
||||||
|
public static Scene LoadScene(World world, string filePath, SceneLoadMode loadMode = SceneLoadMode.Single)
|
||||||
|
{
|
||||||
|
if (loadMode == SceneLoadMode.Single)
|
||||||
|
{
|
||||||
|
// Unload all currently loaded scenes in this world
|
||||||
|
var scenesToUnload = s_loadedScenes.Values.Where(s => s.World == world).ToList();
|
||||||
|
foreach (var scene in scenesToUnload)
|
||||||
|
{
|
||||||
|
UnloadScene(scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//public static void LoadScene(Scene scene, SceneLoadMode loadMode)
|
// Generate a new scene ID for this load
|
||||||
//{
|
var sceneName = Path.GetFileNameWithoutExtension(filePath);
|
||||||
// if (loadMode == SceneLoadMode.Single)
|
var newScene = new Scene(world, sceneName);
|
||||||
// {
|
|
||||||
// foreach (var activeScene in _activeScenes)
|
|
||||||
// {
|
|
||||||
// activeScene.Unload();
|
|
||||||
// }
|
|
||||||
// _activeScenes.Clear();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// _activeScenes.Add(scene);
|
// Load the scene data using binary serialization
|
||||||
// scene.Load();
|
SceneBinarySerializer.LoadScene(world, filePath, newScene.ID);
|
||||||
//}
|
|
||||||
|
|
||||||
//public static Task LoadSceneAsync(Scene scene, SceneLoadMode loadMode)
|
// Register the loaded scene
|
||||||
//{
|
s_loadedScenes[newScene.ID] = newScene;
|
||||||
// return Task.Run(() => LoadScene(scene, loadMode));
|
|
||||||
//}
|
return newScene;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a scene to a binary file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scene">The scene to save.</param>
|
||||||
|
/// <param name="filePath">The path to save the scene file.</param>
|
||||||
|
public static void SaveScene(Scene scene, string filePath)
|
||||||
|
{
|
||||||
|
SceneBinarySerializer.SaveScene(scene.World, scene.ID, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unloads a scene, destroying all entities belonging to it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scene">The scene to unload.</param>
|
||||||
|
public static void UnloadScene(Scene scene)
|
||||||
|
{
|
||||||
|
if (!s_loadedScenes.ContainsKey(scene.ID))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all entities with the scene's ID
|
||||||
|
var queryID = new QueryBuilder()
|
||||||
|
.WithAll<SceneID>()
|
||||||
|
.Build(scene.World);
|
||||||
|
|
||||||
|
var entitiesToDestroy = new List<Entity>();
|
||||||
|
|
||||||
|
scene.World.ComponentManager.GetEntityQueryReference(queryID).ForEach<SceneID>((Entity entity, ref SceneID sceneIDComponent) =>
|
||||||
|
{
|
||||||
|
if (sceneIDComponent.id == scene.ID)
|
||||||
|
{
|
||||||
|
entitiesToDestroy.Add(entity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Destroy all entities in this scene
|
||||||
|
scene.World.EntityManager.DestroyEntities(CollectionsMarshal.AsSpan(entitiesToDestroy));
|
||||||
|
|
||||||
|
// Remove from loaded scenes
|
||||||
|
s_loadedScenes.Remove(scene.ID);
|
||||||
|
|
||||||
|
// Dispose the scene handle
|
||||||
|
scene.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unloads all scenes in the specified world.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="world">The world whose scenes to unload.</param>
|
||||||
|
public static void UnloadAllScenes(World world)
|
||||||
|
{
|
||||||
|
var scenesToUnload = s_loadedScenes.Values.Where(s => s.World == world).ToList();
|
||||||
|
foreach (var scene in scenesToUnload)
|
||||||
|
{
|
||||||
|
UnloadScene(scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get a loaded scene by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sceneID">The scene ID to find.</param>
|
||||||
|
/// <param name="scene">The found scene, or null if not loaded.</param>
|
||||||
|
/// <returns>True if the scene was found, false otherwise.</returns>
|
||||||
|
public static bool TryGetScene(short sceneID, out Scene? scene)
|
||||||
|
{
|
||||||
|
return s_loadedScenes.TryGetValue(sceneID, out scene);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
291
SCENE_SERIALIZATION.md
Normal file
291
SCENE_SERIALIZATION.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Scene Serialization Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a dual-format scene serialization system for GhostEngine:
|
||||||
|
- **Binary format** for runtime (AOT-compatible, fast)
|
||||||
|
- **JSON format** for editor (reflection-based, human-readable)
|
||||||
|
|
||||||
|
Both formats support automatic Entity reference remapping.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two Serialization Paths
|
||||||
|
|
||||||
|
#### **Runtime Path (Ghost.Engine)** - AOT-Compatible
|
||||||
|
```
|
||||||
|
SceneManager → SceneBinarySerializer → SerializationContext
|
||||||
|
```
|
||||||
|
- Binary format using direct memory operations (`memcpy`)
|
||||||
|
- No reflection, no System.Text.Json dependency
|
||||||
|
- Fast, compact, suitable for shipping builds
|
||||||
|
- Synchronous operations
|
||||||
|
|
||||||
|
#### **Editor Path (Ghost.Editor.Core)** - Reflection-Based
|
||||||
|
```
|
||||||
|
EditorSceneManager → SceneSerializer → EntityJsonConverter → SerializationContext
|
||||||
|
```
|
||||||
|
- JSON format using System.Text.Json with reflection
|
||||||
|
- Human-readable, debuggable
|
||||||
|
- Automatic Entity remapping via custom converter
|
||||||
|
- Async operations
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
#### 1. **SerializationContext.cs** (Ghost.Engine/IO)
|
||||||
|
- **Shared by both runtime and editor**
|
||||||
|
- Thread-safe context using `AsyncLocal<T>` for managing Entity ID remapping
|
||||||
|
- Maps file-local IDs (0, 1, 2...) to runtime Entity instances
|
||||||
|
- Bidirectional mapping for both serialization and deserialization
|
||||||
|
- Usage pattern:
|
||||||
|
```csharp
|
||||||
|
using var context = SerializationContext.Create();
|
||||||
|
context.RegisterEntity(fileId, runtimeEntity);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **SceneBinarySerializer.cs** (Ghost.Engine/IO)
|
||||||
|
- **Runtime binary serialization** - AOT-compatible
|
||||||
|
- Static utility class with synchronous methods
|
||||||
|
- **Serialize**: Writes entities to BinaryWriter using raw memory operations
|
||||||
|
- Format: Magic number (0x47534345 "GSCE"), version, entity count, component data
|
||||||
|
- Uses `memcpy` for component data - zero reflection
|
||||||
|
- Implements Entity reference remapping for Hierarchy component
|
||||||
|
- **Deserialize**: Two-pass loading strategy
|
||||||
|
- **Pass 1**: Create all entities, build ID mapping
|
||||||
|
- **Pass 2**: Read and copy component data, remap Entity references
|
||||||
|
- **RemapEntityReferences**: Manual remapping for components with Entity fields (currently Hierarchy)
|
||||||
|
|
||||||
|
#### 3. **EntityJsonConverter.cs** (Ghost.Editor.Core/Serializer/Converters)
|
||||||
|
- **Editor-only** custom `JsonConverter<Entity>`
|
||||||
|
- Automatically remaps Entity references during JSON serialization/deserialization
|
||||||
|
- During **serialization**: Writes file-local ID from SerializationContext
|
||||||
|
- During **deserialization**: Reads file-local ID and translates to runtime Entity
|
||||||
|
- Enables deep Entity reference remapping in nested components (e.g., Hierarchy)
|
||||||
|
|
||||||
|
#### 4. **SceneSerializer.cs** (Ghost.Editor.Core/Serializer)
|
||||||
|
- **Editor-only** static utility class for JSON scene file I/O
|
||||||
|
- **SaveSceneAsync**: Queries entities by SceneID, serializes components using reflection
|
||||||
|
- **LoadSceneAsync**: Two-pass loading strategy with automatic Entity remapping
|
||||||
|
- **Pass 1**: Create all entities, build ID mapping
|
||||||
|
- **Pass 2**: Deserialize components with automatic Entity remapping via EntityJsonConverter
|
||||||
|
- File format: JSON with entities array containing component type names and data
|
||||||
|
|
||||||
|
#### 5. **Scene.cs** (Ghost.Engine/Core)
|
||||||
|
- Lightweight handle class with World reference, SceneID, and Name
|
||||||
|
- No longer owns the World - respects "database pattern"
|
||||||
|
- Constructor: `Scene(World world, string name)`
|
||||||
|
- Implements IDisposable and IEquatable
|
||||||
|
|
||||||
|
#### 6. **SceneManager.cs** (Ghost.Engine/Services)
|
||||||
|
- **Runtime scene lifecycle manager** - uses binary serialization
|
||||||
|
- **LoadScene**: Synchronous, loads from binary file, supports Single/Additive modes
|
||||||
|
- **SaveScene**: Synchronous, saves scene to binary file
|
||||||
|
- **UnloadScene**: Efficiently destroys all entities with matching SceneID
|
||||||
|
- Maintains registry of loaded scenes per World
|
||||||
|
|
||||||
|
#### 7. **EditorSceneManager.cs** (Ghost.Editor.Core/SceneGraph)
|
||||||
|
- **Editor scene lifecycle manager** - uses JSON serialization
|
||||||
|
- **SaveSceneAsync**: Asynchronous, saves scene to JSON file
|
||||||
|
- Integrates with editor workflows and UI
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### 1. Dual Serialization Formats
|
||||||
|
- **Binary for Runtime**: Fast, compact, AOT-compatible for shipping builds
|
||||||
|
- No reflection or System.Text.Json dependency in Ghost.Engine
|
||||||
|
- Direct memory operations using unsafe pointers
|
||||||
|
- Synchronous operations suitable for runtime loading
|
||||||
|
- **JSON for Editor**: Human-readable, debuggable, reflection-based
|
||||||
|
- Located in Ghost.Editor.Core (not in runtime path)
|
||||||
|
- Async operations for editor workflows
|
||||||
|
- Automatic Entity remapping via custom JsonConverter
|
||||||
|
|
||||||
|
### 2. World-Centric Architecture
|
||||||
|
- World is the data container (the "database")
|
||||||
|
- Scene is a lightweight handle/view into that data
|
||||||
|
- SceneManager orchestrates the I/O and entity management
|
||||||
|
- Respects separation of concerns: World doesn't know about scenes
|
||||||
|
|
||||||
|
### 3. Component Tagging
|
||||||
|
- Uses `SceneID` component (currently IComponent, ready for ISharedComponent upgrade)
|
||||||
|
- Each entity stores its scene membership
|
||||||
|
- Enables efficient querying and batch operations
|
||||||
|
|
||||||
|
### 4. Entity Reference Remapping
|
||||||
|
- "Smart Serializer" strategy with two-pass loading
|
||||||
|
- File uses sequential IDs (0, 1, 2...)
|
||||||
|
- Runtime creates new Entities with different IDs
|
||||||
|
- SerializationContext handles the translation
|
||||||
|
- **Binary format**: Manual remapping in `RemapEntityReferences` method
|
||||||
|
- **JSON format**: Automatic remapping via `EntityJsonConverter`
|
||||||
|
- Works for Hierarchy and any other component with Entity fields
|
||||||
|
|
||||||
|
### 5. AOT Compatibility
|
||||||
|
- Ghost.Engine has zero reflection-based serialization
|
||||||
|
- All JSON/reflection code isolated to Ghost.Editor.Core
|
||||||
|
- Binary serializer uses only unsafe pointers and memcpy
|
||||||
|
- Suitable for IL2CPP and NativeAOT compilation
|
||||||
|
|
||||||
|
## Binary Format Specification
|
||||||
|
|
||||||
|
```
|
||||||
|
Header:
|
||||||
|
4 bytes: Magic number (0x47534345 "GSCE")
|
||||||
|
4 bytes: Version number (int32)
|
||||||
|
4 bytes: Entity count (int32)
|
||||||
|
|
||||||
|
For each entity:
|
||||||
|
4 bytes: File ID (int32)
|
||||||
|
4 bytes: Component count (int32)
|
||||||
|
|
||||||
|
For each component:
|
||||||
|
4 bytes: Component Type ID (int32)
|
||||||
|
4 bytes: Component Size (int32)
|
||||||
|
N bytes: Raw component data (memcpy from archetype)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Runtime Usage (Binary)
|
||||||
|
```csharp
|
||||||
|
// Create a world
|
||||||
|
var world = World.Create();
|
||||||
|
|
||||||
|
// Load a scene additively (synchronous)
|
||||||
|
var scene = SceneManager.LoadScene(world, "path/to/scene.bin", SceneLoadMode.Additive);
|
||||||
|
|
||||||
|
// Save the scene (synchronous)
|
||||||
|
SceneManager.SaveScene(scene, "path/to/scene.bin");
|
||||||
|
|
||||||
|
// Unload the scene
|
||||||
|
SceneManager.UnloadScene(scene);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Editor Usage (JSON)
|
||||||
|
```csharp
|
||||||
|
// In editor code
|
||||||
|
var world = World.Create();
|
||||||
|
|
||||||
|
// Save scene to JSON (async)
|
||||||
|
await EditorSceneManager.SaveSceneAsync(scene, "path/to/scene.json");
|
||||||
|
|
||||||
|
// JSON is human-readable and can be version-controlled
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Optimizations
|
||||||
|
|
||||||
|
### When ISharedComponent is Available
|
||||||
|
- Change `SceneID` from `IComponent` to `ISharedComponent`
|
||||||
|
- Entities with same SceneID will be grouped in same chunks
|
||||||
|
- Unloading becomes O(chunks) instead of O(entities)
|
||||||
|
- Can free entire memory blocks instead of individual entities
|
||||||
|
|
||||||
|
### Entity Remapping Source Generator
|
||||||
|
- Currently `RemapEntityReferences` in `SceneBinarySerializer` manually handles Hierarchy
|
||||||
|
- Could implement a source generator to automatically detect Entity fields in all components
|
||||||
|
- Would eliminate need for manual per-component remapping code
|
||||||
|
- Pattern: `[SerializableEntity]` attribute on fields containing Entity references
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
- Binary format is uncompressed raw data
|
||||||
|
- Could add optional compression (LZ4, Zstandard) for smaller file sizes
|
||||||
|
- Trade-off: loading time vs disk space
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### Created in Ghost.Engine (Runtime)
|
||||||
|
- `Ghost.Engine/IO/SerializationContext.cs` - Shared ID remapping context
|
||||||
|
- `Ghost.Engine/IO/SceneBinarySerializer.cs` - AOT-compatible binary serialization
|
||||||
|
- `Ghost.Engine/Components/SceneID.cs` - Scene tagging component
|
||||||
|
|
||||||
|
### Created in Ghost.Editor.Core (Editor)
|
||||||
|
- `Ghost.Editor.Core/Serializer/SceneSerializer.cs` - JSON serialization (moved from Ghost.Engine)
|
||||||
|
- `Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs` - Entity remapping for JSON (moved from Ghost.Engine)
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- `Ghost.Engine/Core/Scene.cs` - Refactored to lightweight handle
|
||||||
|
- `Ghost.Engine/Services/SceneManager.cs` - Runtime scene lifecycle with binary serialization
|
||||||
|
- `Ghost.Editor.Core/SceneGraph/EditorSceneManager.cs` - Editor scene lifecycle with JSON serialization
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
- `Ghost.Engine/IO/SerializerRegistry.cs` - Obsolete ComponentSerializerRegistry
|
||||||
|
- `Ghost.Editor.Core/Serializer/SceneNodeSerializer.cs` - Obsolete
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Binary Serialization Details
|
||||||
|
- Uses `BinaryWriter`/`BinaryReader` for primitive types (int, etc.)
|
||||||
|
- Component data copied with `Unsafe.CopyBlock` (memcpy equivalent)
|
||||||
|
- Stackalloc buffer reused for zero-filled missing components (prevents stack overflow)
|
||||||
|
- Entity remapping performed after all entities created (two-pass loading)
|
||||||
|
|
||||||
|
### JSON Serialization Details
|
||||||
|
- Uses `System.Text.Json` with `JsonSerializerOptions`
|
||||||
|
- `EntityJsonConverter` registered as custom converter
|
||||||
|
- Automatic Entity field detection and remapping during deserialization
|
||||||
|
- Human-readable format suitable for version control
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
- `SerializationContext` uses `AsyncLocal<T>` for thread-safe context isolation
|
||||||
|
- Binary serializer is not thread-safe (single-threaded runtime loading)
|
||||||
|
- JSON serializer uses async methods but should not be called concurrently for same World
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Missing components write zero-filled data (graceful degradation)
|
||||||
|
- Unknown component types in JSON are skipped with warning
|
||||||
|
- Invalid Entity references remap to Entity.Null
|
||||||
|
- File format version checked on load (future-proofing)
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **Manual Entity Remapping in Binary Format**
|
||||||
|
- Currently only Hierarchy component is remapped
|
||||||
|
- Other components with Entity fields need manual handling
|
||||||
|
- Solution: Implement source generator for automatic detection
|
||||||
|
|
||||||
|
2. **Component Size Limit**
|
||||||
|
- Binary serializer uses 4KB stackalloc buffer for zero-fills
|
||||||
|
- Components larger than 4KB will throw exception if missing
|
||||||
|
- Solution: Increase MaxComponentSize constant if needed
|
||||||
|
|
||||||
|
3. **SceneNode Integration**
|
||||||
|
- Legacy SceneNode class in Ghost.Editor.Core still exists
|
||||||
|
- May need integration with new Scene/SceneSerializer system
|
||||||
|
- Future work: Decide on SceneNode vs Scene unification
|
||||||
|
|
||||||
|
4. **No Compression**
|
||||||
|
- Binary format is uncompressed
|
||||||
|
- Large scenes may have bigger file sizes than necessary
|
||||||
|
- Future optimization: Add LZ4/Zstandard compression layer
|
||||||
|
|
||||||
|
5. **Managed Components**
|
||||||
|
- Current implementation assumes all IComponent types are unmanaged
|
||||||
|
- ScriptComponent and ManagedEntity may need separate handling
|
||||||
|
- Future work: Add managed reference serialization
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Binary Format Round-Trip**
|
||||||
|
- Create entities with various components
|
||||||
|
- Save to binary file
|
||||||
|
- Load into new World
|
||||||
|
- Verify all component data matches
|
||||||
|
|
||||||
|
2. **Entity Reference Remapping**
|
||||||
|
- Create parent-child hierarchies
|
||||||
|
- Serialize and deserialize
|
||||||
|
- Verify parent/child Entity references updated correctly
|
||||||
|
|
||||||
|
3. **Additive Loading**
|
||||||
|
- Load multiple scenes into same World
|
||||||
|
- Verify SceneID tagging works correctly
|
||||||
|
- Unload specific scenes and verify entities destroyed
|
||||||
|
|
||||||
|
4. **JSON Compatibility**
|
||||||
|
- Save same scene to both JSON and binary
|
||||||
|
- Verify both formats produce equivalent results when loaded
|
||||||
|
- Test JSON editing by hand (human-readable requirement)
|
||||||
|
|
||||||
|
5. **AOT Compatibility**
|
||||||
|
- Build Ghost.Engine with NativeAOT or IL2CPP
|
||||||
|
- Verify no reflection or dynamic code generation warnings
|
||||||
|
- Test binary serialization in AOT-compiled build
|
||||||
Reference in New Issue
Block a user