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

@@ -2,25 +2,67 @@ using Ghost.Entities;
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;
}
public partial class Scene : IDisposable
{
private readonly World _world;
private readonly short _id;
private readonly string _name;
private bool _isDisposed;
/// <summary>
/// Gets the world this scene belongs to.
/// </summary>
public World World => _world;
/// <summary>
/// Gets the unique identifier for this scene.
/// </summary>
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;
_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()
@@ -28,6 +70,50 @@ public partial class Scene : IDisposable
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()
{
if (_isDisposed)

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

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

View File

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

View File

@@ -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;
public enum SceneLoadMode
{
/// <summary>
/// Unloads all currently loaded scenes before loading the new scene.
/// </summary>
Single,
/// <summary>
/// Loads the scene additively without unloading existing scenes.
/// </summary>
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
{
//private readonly static HashSet<Scene> _activeScenes = new();
private static readonly Dictionary<short, Scene> s_loadedScenes = new();
//internal static IEnumerable<GameObject> QueryRootGameObjects()
//{
// foreach (var scene in _activeScenes)
// {
// foreach (var gameObject in scene.RootObjects)
// {
// if (!gameObject.IsActive)
// {
// continue;
// }
/// <summary>
/// Gets all currently loaded scenes.
/// </summary>
public static IReadOnlyCollection<Scene> LoadedScenes => s_loadedScenes.Values;
// 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)
//{
// if (loadMode == SceneLoadMode.Single)
// {
// foreach (var activeScene in _activeScenes)
// {
// activeScene.Unload();
// }
// _activeScenes.Clear();
// }
// Generate a new scene ID for this load
var sceneName = Path.GetFileNameWithoutExtension(filePath);
var newScene = new Scene(world, sceneName);
// _activeScenes.Add(scene);
// scene.Load();
//}
// Load the scene data using binary serialization
SceneBinarySerializer.LoadScene(world, filePath, newScene.ID);
//public static Task LoadSceneAsync(Scene scene, SceneLoadMode loadMode)
//{
// return Task.Run(() => LoadScene(scene, loadMode));
//}
// Register the loaded scene
s_loadedScenes[newScene.ID] = newScene;
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);
}
}