Remove old SceneGraph

This commit is contained in:
2026-01-25 21:18:16 +09:00
parent 0201f0fc33
commit ba5dc2159e
17 changed files with 120 additions and 1427 deletions

View File

@@ -2,10 +2,10 @@ namespace Ghost.Editor.Core.Resources;
internal static class FileExtensions
{
public const string PROJECT_FILE_EXTENSION = ".ghostproj";
public const string TEMPLATE_FILE_EXTENSION = ".ghosttemplate";
public const string SCENE_FILE_EXTENSION = ".ghostscene";
public const string ASSET_FILE_EXTENSION = ".ghostasset";
public const string SHADER_FILE_EXTENSION = ".ghostshader";
public const string MATERIAL_FILE_EXTENSION = ".ghostmaterial";
public const string PROJECT_FILE_EXTENSION = ".gproj";
public const string TEMPLATE_FILE_EXTENSION = ".gtmpl";
public const string SCENE_FILE_EXTENSION = ".gscene";
public const string ASSET_FILE_EXTENSION = ".gasset";
public const string SHADER_FILE_EXTENSION = ".gshdr";
public const string MATERIAL_FILE_EXTENSION = ".gmat";
}

View File

@@ -1,72 +0,0 @@
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;
/// <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
private static readonly Dictionary<string, SceneNode> s_loadedWorlds = new();
public static IEnumerable<SceneNode> LoadedWorlds => s_loadedWorlds.Values;
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)
|| !File.Exists(worldPath)
|| Path.GetExtension(worldPath) != FileExtensions.SCENE_FILE_EXTENSION)
{
return;
}
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[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,124 +0,0 @@
using Ghost.Editor.Core.Controls.Internal;
using Ghost.Editor.Core.Inspector;
using Ghost.Editor.Core.Resources;
using Ghost.Engine.Editor;
using Ghost.Entities;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using System.Reflection;
namespace Ghost.Editor.Core.SceneGraph;
public partial class EntityNode : SceneGraphNode
{
public SceneNode Owner
{
get;
set;
}
public Entity Entity
{
get;
}
public override SceneGraphNodeType NodeType => SceneGraphNodeType.Entity;
public EntityNode(SceneNode owner, Entity entity, string name)
{
Owner = owner;
Entity = entity;
Name = name;
}
}
public partial class EntityNode : IInspectable
{
public IconSource? Icon => EditorIconSource.entity_24;
public UIElement? HeaderContent
{
get
{
var root = new StackPanel()
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center
};
var nameText = new TextBox
{
Text = Name,
FontWeight = FontWeights.Bold,
};
var idText = new TextBlock
{
Text = $"ID: {Entity.ID} Generation: {Entity.Generation}",
Margin = new Thickness(5, 7, 0, 0),
Opacity = 0.75,
Style = Application.Current.Resources["CaptionTextBlockStyle"] as Style
};
nameText.SetBinding(TextBox.TextProperty, new Binding
{
Source = this,
Path = new PropertyPath(nameof(Name)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
});
root.Children.Add(nameText);
root.Children.Add(idText);
return root;
}
}
public unsafe UIElement? InspectorContent
{
get
{
var r = Owner.Scene.World.EntityManager.GetEntityLocation(Entity);
if (!r)
{
return null;
}
var root = new StackPanel()
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Top
};
var location = r.Value;
ref var archetype = ref Owner.Scene.World.ComponentManager.GetArchetypeReference(location.archetypeID);
var it = archetype._signature.GetIterator();
while (it.Next(out var typeID))
{
var pComponent = archetype.GetComponentData(location.chunkIndex, location.rowIndex, typeID);
if (pComponent == null)
{
continue;
}
if (!ComponentRegistry.s_runtimeIDToType.TryGetValue(typeID, out var t))
{
continue;
}
if (t.GetCustomAttribute<HideEditorAttribute>() != null)
{
continue;
}
var componentView = new ComponentView(t.Name, Owner.Scene.World, Entity, t);
root.Children.Add(componentView);
}
return root;
}
}
}

View File

@@ -0,0 +1,68 @@
# Architecture Plan: Scene Graph and Scene Representation
The Scene Graph is a hierarchical structure that represents all the objects and entities within a 3D scene in the Ghost Editor.
## Scene Graph (Editor representation of runtime data)
There should be two main types of nodes in the Scene Graph:
1. **Entity Node**: Represents an individual entity within a scene.
2. **Scene Node**: Represents a Scene object, which can contain multiple entities.
### Editor World
Editor contains a different world compares to the runtime world. When user click the Play button, we will create a runtime world and load the scene data from the editor world to the runtime world.
This allows us to
1. Unload the runtime only systems like physics, rendering, etc when user stop playing.
2. Load editor only systems like gizmos, debug, etc when user stop playing.
3. Allow editor only entities like editor camera, editor lights, etc to exist in the editor world without affecting the runtime world.
### Editor Hierarchy
The Scene Graph should be represented as a tree structure in the editor (TreeView in WinUI 3), where:
- The top level nodes represents the loaded Scenes in the editor world.
- Levels below the Scene nodes represents the Entity nodes that belong to that scene.
- Each Entity node can have child Entity nodes representing parent-child relationships between entities.
An example hierarchy could look like this:
```
- Scene 1
- Entity A
- Entity B
- Entity C
- Scene 2
- Entity D
```
## Scene (The runtime representation)
A Scene is a collection of entities with SceneID component from a world that are grouped together. There can be multiple scenes in a world.
### Save a Scene
When save a scene, all entities with the SceneID component matching the scene's ID should be included in the saved data.
When an Entity references another Entity in the same scene, we should store the file local id instead of the global entity id.
For example, if Entity A (id: 10, 5th in scene) references Entity B (id: 20, 50th in scene) in the same scene, in the saved data for Entity A,
we should store 50 (the file local id) as the reference to Entity B instead of 20 (the global entity id).
> We does not allow cross-scene references for now because ideally it's not a good practice to have cross-scene references.
We can use query or singleton pattern to access entities from other scenes if needed because they are in the same world.
### Load a Scene
When loading a scene, we need to reconstruct the entities and their relationships based on the saved data.
1. We allocate the entities in the world and assign them new global entity IDs.
2. We remap the file local IDs to the new global entity IDs and change the references accordingly.
For example if Entity A (file local id: 5) references Entity B (file local id: 50) in the saved data,
we need to find the new global entity IDs for both entities after loading and update the reference in Entity A to point to the new global entity ID of Entity B.
### Data format
The scene data should be stored in a structured format (e.g., JSON or binary) that includes:
- Scene metadata (e.g., name, ID)
- List of entities with their components and properties (Entities must in the order that file local id directly maps to the index in the list)
- References between entities using file local IDs
JSON should only be used in the editor and JSON serialization/deserialization logic should also only exist in the editor codebase (Ghost.Editor.Core). Reflection is allowed here.
Binary format should be used in the runtime for better performance. The runtime codebase (Ghost.Engine) must be aot compatible.
Currently we strict the IComponent to must be unmanaged and blittable types.
However, we also support ManagedEntity and ManagedEntityRef with ScriptComponent to allow OOP like logic for common gameplay logic that DOD pattern is not suitable for.
Serializing/deserializing with those components will be tricky. We can use MemoryPack for binary serialization/deserialization because it supports both unmanaged and managed types.

View File

@@ -1,112 +0,0 @@
using Ghost.Engine.Components;
using Ghost.Entities;
namespace Ghost.Editor.Core.SceneGraph;
public class SceneGraphHelpers
{
/// <summary>
/// Creates a new <see cref="EntityNode"/> entity with default components.
/// </summary>
/// <param name="world">The world context where the entity will be created.</param>
/// <param name="entity">The entity to be wrapped in the <see cref="EntityNode"/>.</param>
public static EntityNode CreateEntityNode(SceneNode owner, Entity entity, string name)
{
owner.Scene.World.EntityManager.AddComponent(entity, new LocalToWorld { matrix = Misaki.HighPerformance.Mathematics.float4x4.identity });
owner.Scene.World.EntityManager.AddComponent(entity, Hierarchy.Root);
return new EntityNode(owner, entity, name);
}
/// <summary>
/// Creates a new <see cref="Entity"/> and <see cref="EntityNode"/> entity with default components.
/// </summary>
/// <param name="owner">The world context where the entity will be created.</param>
public static EntityNode CreateEntityNode(SceneNode owner, string name)
{
var entity = owner.Scene.World.EntityManager.CreateEntity();
return CreateEntityNode(owner, entity, name);
}
/// <summary>
/// Attaches childEntity to parentEntity in the scene graph.
/// </summary>
/// <param name="world">The world context where the entities exist.</param>
/// <param name="parentNode">The parent entity to which the child will be attached.</param>
/// <param name="childNode">The child entity to be attached.</param>
public static void AttachChild(SceneNode scene, EntityNode parentNode, EntityNode childNode)
{
// 1) If the child already has a parent, detach it first
var childHierarchy = scene.Scene.World.EntityManager.GetComponent<Hierarchy>(childNode.Entity);
if (childHierarchy.parent != Entity.Invalid)
{
DetachFromParent(scene, childNode);
}
// 2) Link child to new parent
childHierarchy.parent = parentNode.Entity;
// 3) Insert child at the head of parent's child list
var parentHierarchy = scene.Scene.World.EntityManager.GetComponent<Hierarchy>(parentNode.Entity);
childHierarchy.nextSibling = parentHierarchy.firstChild;
parentHierarchy.firstChild = childNode.Entity;
// 4) Write back
scene.Scene.World.EntityManager.SetComponent(parentNode.Entity, parentHierarchy);
scene.Scene.World.EntityManager.SetComponent(childNode.Entity, childHierarchy);
// 5) Update children list in parent node
parentNode.AddChild(childNode);
}
/// <summary>
/// Detaches the specified entity from its parent in the scene graph.
/// </summary>
/// <param name="world">The world context where the entities exist.</param>
/// <param name="node">The entity to detach from its parent.</param>
public static void DetachFromParent(SceneNode scene, EntityNode node)
{
var hierarchy = scene.Scene.World.EntityManager.GetComponent<Hierarchy>(node.Entity);
var parent = hierarchy.parent;
if (parent == Entity.Invalid)
{
return; // already root
}
var parentHierarchy = scene.Scene.World.EntityManager.GetComponent<Hierarchy>(parent);
// If entity is the first child, simply move head
if (parentHierarchy.firstChild == node.Entity)
{
parentHierarchy.firstChild = hierarchy.nextSibling;
}
else
{
// Otherwise, find the previous sibling in the linked list
var prevSibling = parentHierarchy.firstChild;
while (prevSibling != Entity.Invalid)
{
var prevHierarchy = scene.Scene.World.EntityManager.GetComponent<Hierarchy>(prevSibling);
if (prevHierarchy.nextSibling == node.Entity)
{
prevHierarchy.nextSibling = hierarchy.nextSibling;
scene.Scene.World.EntityManager.SetComponent(prevSibling, prevHierarchy);
break;
}
prevSibling = prevHierarchy.nextSibling;
}
}
// Clear child's references
hierarchy.parent = Entity.Invalid;
hierarchy.nextSibling = Entity.Invalid;
// Write back
scene.Scene.World.EntityManager.SetComponent(parent, parentHierarchy);
scene.Scene.World.EntityManager.SetComponent(node.Entity, hierarchy);
// Remove from parent's children list
scene.EntityNodeLookup[parent].RemoveChild(node);
}
}

View File

@@ -1,54 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace Ghost.Editor.Core.SceneGraph;
public enum SceneGraphNodeType
{
Scene,
Entity,
}
public abstract partial class SceneGraphNode : ObservableObject
{
public ObservableCollection<SceneGraphNode>? Children
{
get;
private set;
}
[ObservableProperty]
public partial string Name
{
get;
set;
}
public abstract SceneGraphNodeType NodeType
{
get;
}
public int ChildCount => Children?.Count ?? 0;
public virtual void AddChild(SceneGraphNode child)
{
Children ??= new();
Children.Add(child);
}
public virtual bool RemoveChild(SceneGraphNode child)
{
return Children?.Remove(child) ?? false;
}
public SceneGraphNode GetChild(int index)
{
if (Children == null || index < 0 || index >= Children.Count)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index is out of range.");
}
return Children[index];
}
}

View File

@@ -1,180 +0,0 @@
using Ghost.Editor.Core.AssetHandle;
using Ghost.Editor.Core.Inspector;
using Ghost.Editor.Core.Resources;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.SceneGraph;
public partial class SceneNode : SceneGraphNode, IEquatable<SceneNode>
{
private Scene _scene;
private Dictionary<Entity, EntityNode> _entityNodeLookup = new();
public Scene Scene => _scene;
public Dictionary<Entity, EntityNode> EntityNodeLookup => _entityNodeLookup;
public override SceneGraphNodeType NodeType => SceneGraphNodeType.Scene;
public SceneNode(Scene scene, string name)
{
_scene = scene;
Name = name;
}
private void UpdateLookup(Entity key, EntityNode value)
{
_entityNodeLookup[key] = value;
if (value.Children == null)
{
return;
}
foreach (var child in value.Children)
{
if (child is EntityNode entityChild)
{
UpdateLookup(entityChild.Entity, entityChild);
}
}
}
public override void AddChild(SceneGraphNode child)
{
if (child is not EntityNode entityNode)
{
throw new ArgumentException("Child must be of type EntityNode.", nameof(child));
}
base.AddChild(entityNode);
UpdateLookup(entityNode.Entity, entityNode);
}
public override bool RemoveChild(SceneGraphNode child)
{
if (child is not EntityNode entityNode)
{
throw new ArgumentException("Child must be of type EntityNode.", nameof(child));
}
var result = base.RemoveChild(child);
if (result)
{
_entityNodeLookup.Remove(entityNode.Entity);
}
return result;
}
private EntityNode BuildNodeRecursive(Entity entity)
{
if (!_entityNodeLookup.TryGetValue(entity, out var node))
{
node = new EntityNode(this, entity, "New Entity");
_entityNodeLookup[entity] = node;
}
var hc = _scene.World.EntityManager.GetComponent<Hierarchy>(entity);
var child = hc.firstChild;
while (child != Entity.Invalid)
{
node.AddChild(BuildNodeRecursive(child));
var childHC = _scene.World.EntityManager.GetComponent<Hierarchy>(child);
child = childHC.nextSibling;
}
return node;
}
private void BuildGraph()
{
var queryID = new QueryBuilder()
.WithAll<Hierarchy>()
.Build(_scene.World);
_scene.World.ComponentManager.GetEntityQueryReference(queryID).ForEach<Hierarchy>((entity, ref hierarchy) =>
{
if (hierarchy.parent == Entity.Invalid)
{
var node = BuildNodeRecursive(entity);
AddChild(node);
}
});
}
public Task LoadAsync()
{
return Task.Run(BuildGraph);
}
public void Unload()
{
_scene = null!;
Children?.Clear();
_entityNodeLookup.Clear();
}
public override string ToString()
{
return $"WorldNode: {Name} (World ID: {_scene.ID})";
}
public override int GetHashCode()
{
return HashCode.Combine(_scene, Name);
}
public override bool Equals(object? obj)
{
return obj is SceneNode other && Equals(other);
}
public bool Equals(SceneNode? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return _scene.Equals(other._scene) && Name == other.Name;
}
public static bool operator ==(SceneNode? left, SceneNode? right)
{
if (left is null)
{
return right is null;
}
return left.Equals(right);
}
public static bool operator !=(SceneNode? left, SceneNode? right)
{
return !(left == right);
}
}
public partial class SceneNode : IInspectable
{
public IconSource? Icon => EditorIconSource.scene_24;
[AssetOpenHandler(FileExtensions.SCENE_FILE_EXTENSION)]
public static async void Open(string path)
{
await EditorSceneManager.LoadSceneAsync(path);
}
public UIElement? HeaderContent => null;
public UIElement? InspectorContent => null;
}

View File

@@ -1,115 +0,0 @@
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,258 +0,0 @@
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;
}
}