Add simple scene graph

This commit is contained in:
2026-01-25 18:37:45 +09:00
parent 364fbf9208
commit 0201f0fc33
11 changed files with 1272 additions and 250 deletions

View File

@@ -1,17 +1,19 @@
using Ghost.Editor.Core.Progress;
using Ghost.Editor.Core.Resources;
using Ghost.Editor.Core.Serializer;
using Ghost.Editor.Core.Utilities;
using Ghost.Engine.Core;
using System.Text.Json;
namespace Ghost.Editor.Core.SceneGraph;
public enum OpenWorldMode
{
Single,
Additive,
AdditiveWithoutLoading
}
/// <summary>
/// Editor-specific scene manager that uses JSON serialization for human-readable scene files.
/// </summary>
/// <remarks>
/// This manager provides JSON-based serialization suitable for editor workflows.
/// Runtime applications should use SceneManager with binary serialization.
/// </remarks>
public static class EditorSceneManager
{
// 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>? 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)
{
if (s_loadedWorlds.ContainsKey(worldPath)
@@ -33,21 +39,34 @@ public static class EditorSceneManager
var progressService = EditorApplication.GetService<IProgressService>();
progressService.ShowIndeterminateProgress("Loading world...");
// Unload existing scenes
foreach (var world in s_loadedWorlds)
{
world.Value.Unload();
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);
var deserializedScene = await JsonSerializer.DeserializeAsync<SceneNode>(readStream, Engine.Resources.EngineResource.defaultSerializerOptions) ?? throw new Exception("Deserialization failed.");
s_loadedWorlds.Clear();
s_loadedWorlds[worldPath] = deserializedScene;
await deserializedScene.LoadAsync();
progressService.HideProgress();
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);
}
}

View File

@@ -1,17 +1,14 @@
using Ghost.Editor.Core.AssetHandle;
using Ghost.Editor.Core.Inspector;
using Ghost.Editor.Core.Resources;
using Ghost.Editor.Core.Serializer;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Engine.IO;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.SceneGraph;
[CustomSerializer(typeof(SceneNodeSerializer))]
public partial class SceneNode : SceneGraphNode, IEquatable<SceneNode>
{
private Scene _scene;

View 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();
}
}

View File

@@ -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();
}
}

View 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;
}
}