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 internal static class FileExtensions
{ {
public const string PROJECT_FILE_EXTENSION = ".ghostproj"; public const string PROJECT_FILE_EXTENSION = ".gproj";
public const string TEMPLATE_FILE_EXTENSION = ".ghosttemplate"; public const string TEMPLATE_FILE_EXTENSION = ".gtmpl";
public const string SCENE_FILE_EXTENSION = ".ghostscene"; public const string SCENE_FILE_EXTENSION = ".gscene";
public const string ASSET_FILE_EXTENSION = ".ghostasset"; public const string ASSET_FILE_EXTENSION = ".gasset";
public const string SHADER_FILE_EXTENSION = ".ghostshader"; public const string SHADER_FILE_EXTENSION = ".gshdr";
public const string MATERIAL_FILE_EXTENSION = ".ghostmaterial"; 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;
}
}

View File

@@ -24,4 +24,12 @@
<ProjectReference Include="..\Ghost.Graphics\Ghost.Graphics.csproj" /> <ProjectReference Include="..\Ghost.Graphics\Ghost.Graphics.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Services\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MemoryPack" Version="1.21.4" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,256 +0,0 @@
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

@@ -1,109 +0,0 @@
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,139 +0,0 @@
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 static readonly Dictionary<short, Scene> s_loadedScenes = new();
/// <summary>
/// Gets all currently loaded scenes.
/// </summary>
public static IReadOnlyCollection<Scene> LoadedScenes => s_loadedScenes.Values;
/// <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);
}
}
// Generate a new scene ID for this load
var sceneName = Path.GetFileNameWithoutExtension(filePath);
var newScene = new Scene(world, sceneName);
// Load the scene data using binary serialization
SceneBinarySerializer.LoadScene(world, filePath, newScene.ID);
// 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);
}
}

View File

@@ -510,6 +510,42 @@ public ref partial struct QueryBuilder
} }
} }
public void WithAll(params Span<Identifier<IComponent>> componentIDs)
{
_all.AddRange(componentIDs, componentIDs.Length);
}
public void WithAny(params Span<Identifier<IComponent>> componentIDs)
{
_any.AddRange(componentIDs, componentIDs.Length);
}
public void WithAbsent(params Span<Identifier<IComponent>> componentIDs)
{
_absent.AddRange(componentIDs, componentIDs.Length);
}
public void WithNone(params Span<Identifier<IComponent>> componentIDs)
{
_none.AddRange(componentIDs, componentIDs.Length);
}
public void WithDisabled(params Span<Identifier<IComponent>> componentIDs)
{
_disabled.AddRange(componentIDs, componentIDs.Length);
}
public void WithPresent(params Span<Identifier<IComponent>> componentIDs)
{
_present.AddRange(componentIDs, componentIDs.Length);
}
public void WithPresentRW(params Span<Identifier<IComponent>> componentIDs)
{
_present.AddRange(componentIDs, componentIDs.Length);
_rw.AddRange(componentIDs, componentIDs.Length);
}
public Identifier<EntityQuery> Build(World world, Allocator allocator = Allocator.Persistent) public Identifier<EntityQuery> Build(World world, Allocator allocator = Allocator.Persistent)
{ {
// 1. Calculate max component ID to size the BitSets // 1. Calculate max component ID to size the BitSets

View File

@@ -106,7 +106,7 @@ internal class MeshRenderPass : IRenderPass
private void CompileBlitShader(ref readonly RenderingContext ctx) private void CompileBlitShader(ref readonly RenderingContext ctx)
{ {
var shaderDescriptor = DSLShaderCompiler.CompileShader("F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Blit.gsdef", "C:/Users/Misaki/Downloads/Archive").GetValueOrThrow(); var shaderDescriptor = DSLShaderCompiler.CompileShader("F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Blit.gshdr", "C:/Users/Misaki/Downloads/Archive").GetValueOrThrow();
_blitShader = ctx.ResourceAllocator.CreateGraphicsShader(shaderDescriptor); _blitShader = ctx.ResourceAllocator.CreateGraphicsShader(shaderDescriptor);
_blitMaterial = ctx.ResourceAllocator.CreateMaterial(_blitShader); _blitMaterial = ctx.ResourceAllocator.CreateMaterial(_blitShader);
@@ -130,7 +130,7 @@ internal class MeshRenderPass : IRenderPass
{ {
CompileBlitShader(in ctx); CompileBlitShader(in ctx);
var shaderDescriptor = DSLShaderCompiler.CompileShader("F:/csharp/GhostEngine/Ghost.Graphics/test.gsdef", "C:/Users/Misaki/Downloads/Archive").GetValueOrThrow(); var shaderDescriptor = DSLShaderCompiler.CompileShader("F:/csharp/GhostEngine/Ghost.Graphics/test.gshdr", "C:/Users/Misaki/Downloads/Archive").GetValueOrThrow();
_shader = ctx.ResourceAllocator.CreateGraphicsShader(shaderDescriptor); _shader = ctx.ResourceAllocator.CreateGraphicsShader(shaderDescriptor);
_material = ctx.ResourceAllocator.CreateMaterial(_shader); _material = ctx.ResourceAllocator.CreateMaterial(_shader);