feat: add scene serialization (JSON + binary) with import pipeline

Implement scene save/load for editor and runtime.
Editor JSON (.gscene) uses Utf8JsonWriter for inline component objects.
Runtime binary (.imported) stores marshalled component data with
entity field offset metadata for AOT-safe remapping.

- SceneSerializationService: save from EditorWorld, load into EditorWorld
- SceneAsset + SceneAssetHandler: .gscene import/pack pipeline
- AssetManager.Scene + SceneLoader: runtime binary deserialization
- Scene: [JsonConstructor] + [JsonIgnore] for round-trip
- Component: GetComponentIDByName for editor-side type lookup
- 10 unit tests (save, load, round-trip, unknown comp, invalid version)

Also guard DSLShaderCompiler editor code with #if GHOST_EDITOR,
add GC.SuppressFinalize to EditorWorldService, and switch Archetype
debug fields from #if GHOST_EDITOR to #if DEBUG.
This commit is contained in:
2026-05-10 16:21:56 +09:00
parent 7e1db7b908
commit a95ff01366
12 changed files with 1540 additions and 6 deletions

View File

@@ -20,6 +20,7 @@ public struct DSLShaderError
public static class DSLShaderCompiler
{
#if GHOST_EDITOR
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
{
if (semantic == null)
@@ -85,11 +86,13 @@ public static class DSLShaderCompiler
return sb.ToString();
}
#endif
// TODO: Implement shader inheritance resolution, including property and pass merging.
// Currently, we just ignore inheritance.
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
{
#if GHOST_EDITOR
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
{
propertyInfo = default;
@@ -154,6 +157,10 @@ public static class DSLShaderCompiler
}
return descriptor;
#else
return Result.Failure("GHOST_EDITOR is not defined");
#endif
}
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
@@ -294,6 +301,7 @@ public static class DSLShaderCompiler
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
{
#if GHOST_EDITOR
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
{
propertyInfo = default;
@@ -320,5 +328,8 @@ public static class DSLShaderCompiler
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
};
#else
return Result.Failure("GHOST_EDITOR is not defined");
#endif
}
}

View File

@@ -0,0 +1,51 @@
using Ghost.Core;
using Ghost.Engine;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Assets;
[Guid(GUID)]
public sealed class SceneAsset : IAsset
{
public const string GUID = "1B5E3F2A-8D91-4C67-BE32-A0F9C6D4E781";
private static readonly Guid s_typeID = Guid.Parse(GUID);
public Guid ID
{
get;
}
public Guid TypeID => s_typeID;
public IAssetSettings? Settings
{
get;
}
public string SceneName
{
get; set;
}
public int EntityCount
{
get; set;
}
public SceneAsset(Guid id, IAssetSettings? settings)
{
ID = id;
Settings = settings;
SceneName = string.Empty;
EntityCount = 0;
}
public void Dispose()
{
}
}
public sealed class SceneAssetSettings : IAssetSettings
{
}

View File

@@ -0,0 +1,98 @@
using Ghost.Core;
using Ghost.Editor.Core.Services;
using Ghost.Engine;
namespace Ghost.Editor.Core.Assets;
[CustomAssetHandler(AssetTypeId = SceneAsset.GUID, RuntimeAssetType = AssetType.Scene, Extensions = new[] { ".gscene" })]
internal class SceneAssetHandler : IImportableAssetHandler, IPackableAssetHandler
{
public IAssetSettings? CreateDefaultSettings(string ext)
{
return new SceneAssetSettings();
}
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
try
{
if (!File.Exists(assetPath))
{
return Result.Failure("Scene file does not exist.");
}
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
var asset = new SceneAsset(id, settings)
{
SceneName = Path.GetFileNameWithoutExtension(assetPath),
EntityCount = data?.Entities?.Count ?? 0,
};
return Result.Success<IAsset>(asset);
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
}
public ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
{
if (asset is not SceneAsset sceneAsset)
{
return ValueTask.FromResult(Result.Failure("Asset type is not SceneAsset"));
}
return ValueTask.FromResult(Result.Failure("Scene saving is handled by SceneSerializationService directly."));
}
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
{
try
{
if (!File.Exists(sourcePath))
{
return Result.Failure("Source scene file does not exist.");
}
var data = await SceneSerializationService.DeserializeSceneFileAsync(sourcePath, token);
if (data == null)
{
return Result.Failure("Failed to deserialize scene file.");
}
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
SceneSerializationService.SerializeToBinary(data, stream);
return Result.Success(Array.Empty<ImportedSubAsset>());
}
catch (Exception ex)
{
return Result.Failure($"Failed to import scene asset: {ex.Message}");
}
}
public async ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
{
try
{
if (!File.Exists(assetPath))
{
return Result.Failure("Scene file does not exist.");
}
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
if (data == null)
{
return Result.Failure("Failed to deserialize scene file.");
}
SceneSerializationService.SerializeToBinary(data, targetStream);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to pack scene asset: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Ghost.Editor.Core.SceneGraph;
public enum SceneLoadingType
{
Single = 0,
Additive = 1,
}

View File

@@ -50,5 +50,6 @@ public class EditorWorldService : IDisposable
public void Dispose()
{
World.Destroy(EditorWorld.ID);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,608 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.SceneGraph;
using Ghost.Editor.Core.Utilities;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.Services;
[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(SceneSerializationService))]
public class SceneSerializationService : IDisposable
{
private const int SCENE_FORMAT_VERSION = 1;
private static readonly Dictionary<Type, FieldInfo[]> s_entityFieldsCache = new();
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
IncludeFields = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new EntityJsonConverter() },
};
private sealed class EntityJsonConverter : JsonConverter<Entity>
{
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var localId = reader.GetInt32();
return new Entity(localId, localId >= 0 ? 0 : -1);
}
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.ID);
}
}
private readonly EditorWorldService _worldService;
private readonly IAssetRegistry _assetRegistry;
public SceneSerializationService(EditorWorldService worldService, IAssetRegistry assetRegistry)
{
_worldService = worldService;
_assetRegistry = assetRegistry;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int FileLocalIndexOf(Dictionary<Entity, int> reverseMap, Entity entity)
{
if (reverseMap.TryGetValue(entity, out var index))
{
return index;
}
return -1;
}
private static bool IsEntityType(Type type)
{
return type == typeof(Entity);
}
private static FieldInfo[] GetEntityFields(Type type)
{
if (!s_entityFieldsCache.TryGetValue(type, out var fields))
{
var list = new List<FieldInfo>();
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
{
if (IsEntityType(field.FieldType))
{
list.Add(field);
}
}
fields = list.ToArray();
s_entityFieldsCache[type] = fields;
}
return fields;
}
private static object RemapEntityFieldsToLocal(object boxed, Type type, Dictionary<Entity, int> reverseMap)
{
var entityFields = GetEntityFields(type);
foreach (var field in entityFields)
{
var entity = (Entity)field.GetValue(boxed)!;
var localIndex = FileLocalIndexOf(reverseMap, entity);
field.SetValue(boxed, new Entity(localIndex, localIndex >= 0 ? 0 : -1));
}
return boxed;
}
private static object RemapLocalFieldsToEntity(object boxed, Type type, Dictionary<int, Entity> forwardMap)
{
var entityFields = GetEntityFields(type);
foreach (var field in entityFields)
{
var localAsEntity = (Entity)field.GetValue(boxed)!;
var localIndex = localAsEntity.ID;
if (!forwardMap.TryGetValue(localIndex, out var entity))
{
entity = Entity.Invalid;
}
field.SetValue(boxed, entity);
}
return boxed;
}
#region Binary Serialization
private static readonly byte[] SCENE_MAGIC = Encoding.UTF8.GetBytes("GSCN");
private static uint GetTypeNameHash(string typeName)
{
var hash = 2166136261u;
foreach (var c in typeName)
{
hash ^= c;
hash *= 16777619u;
}
return hash;
}
private static int[] GetEntityFieldOffsetsFromJson(string typeName, string componentJson)
{
var type = Type.GetType(typeName);
if (type == null)
{
return Array.Empty<int>();
}
var entityFields = GetEntityFields(type);
if (entityFields.Length == 0)
{
return Array.Empty<int>();
}
var offsets = new int[entityFields.Length];
for (var i = 0; i < entityFields.Length; i++)
{
offsets[i] = (int)Marshal.OffsetOf(type, entityFields[i].Name);
}
return offsets;
}
public static unsafe void SerializeToBinary(SceneSaveData data, Stream targetStream)
{
using var writer = new BinaryWriter(targetStream, Encoding.UTF8, true);
writer.Write(SCENE_MAGIC);
writer.Write(SCENE_FORMAT_VERSION);
writer.Write(data.Entities?.Count ?? 0);
if (data.Entities == null)
{
return;
}
foreach (var entity in data.Entities)
{
if (entity.Components == null)
{
writer.Write(0);
continue;
}
writer.Write(entity.Components.Count);
foreach (var (typeName, componentElement) in entity.Components)
{
var typeHash = GetTypeNameHash(typeName);
var componentType = Type.GetType(typeName);
if (componentType == null)
{
writer.Write(typeHash);
var nameBytes = Encoding.UTF8.GetBytes(typeName);
writer.Write(nameBytes.Length);
writer.Write(nameBytes);
var jsonBytes = Encoding.UTF8.GetBytes(componentElement.GetRawText());
writer.Write(jsonBytes.Length);
writer.Write(jsonBytes);
writer.Write(0);
continue;
}
var boxed = componentElement.Deserialize(componentType, s_jsonOptions);
if (boxed == null)
{
continue;
}
var compInfo = ComponentRegistry.GetComponentInfo(componentType);
var rawBytes = new byte[compInfo.size];
fixed (byte* pDest = rawBytes)
{
Marshal.StructureToPtr(boxed, (nint)pDest, false);
}
var entityFieldOffsets = GetEntityFields(componentType);
var offsets = new int[entityFieldOffsets.Length];
for (var i = 0; i < entityFieldOffsets.Length; i++)
{
offsets[i] = (int)Marshal.OffsetOf(componentType, entityFieldOffsets[i].Name);
}
writer.Write(typeHash);
var nameBytes2 = Encoding.UTF8.GetBytes(typeName);
writer.Write(nameBytes2.Length);
writer.Write(nameBytes2);
writer.Write(rawBytes.Length);
writer.Write(rawBytes);
writer.Write(offsets.Length);
foreach (var off in offsets)
{
writer.Write(off);
}
}
}
}
#endregion
#region Scene File Deserialization (static, used by handler too)
public static async ValueTask<SceneSaveData?> DeserializeSceneFileAsync(string jsonPath, CancellationToken token = default)
{
var json = await File.ReadAllTextAsync(jsonPath, token);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var data = new SceneSaveData
{
FormatVersion = root.TryGetProperty("formatVersion", out var v) ? v.GetInt32() : 1,
};
if (root.TryGetProperty("entities", out var entitiesElement))
{
foreach (var entityElement in entitiesElement.EnumerateArray())
{
var entityData = new EntitySaveData();
if (entityElement.TryGetProperty("components", out var componentsElement))
{
foreach (var componentProperty in componentsElement.EnumerateObject())
{
entityData.Components[componentProperty.Name] = componentProperty.Value.Clone();
}
}
data.Entities.Add(entityData);
}
}
return data;
}
#endregion
#region Load Scene into Editor World
public unsafe Result LoadSceneIntoEditorWorld(SceneSaveData data, SceneLoadingType loadingType = SceneLoadingType.Single)
{
if (loadingType == SceneLoadingType.Single)
{
ClearEditorWorld();
}
var world = _worldService.EditorWorld;
var entityCount = data.Entities.Count;
if (entityCount == 0)
{
goto RebuildAndReturn;
}
var forwardMap = new Dictionary<int, Entity>(entityCount);
var scope = AllocationManager.CreateStackScope();
var typeIds = new UnsafeArray<UnsafeList<Identifier<IComponent>>>(entityCount, scope.AllocationHandle);
for (var i = 0; i < typeIds.Length; i++)
{
typeIds[i] = new UnsafeList<Identifier<IComponent>>(16, scope.AllocationHandle);
}
try
{
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
{
var entityData = data.Entities[fileIndex];
ref var list = ref typeIds[fileIndex];
foreach (var (typeName, _) in entityData.Components)
{
var compId = ComponentRegistry.GetComponentIDByName(typeName);
if (compId.IsInvalid)
{
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
if (type == null)
{
continue;
}
compId = RegisterComponentByType(type);
}
list.Add(compId);
}
if (list.Count == 0)
{
continue;
}
using var componentSet = new ComponentSet(scope.AllocationHandle, list);
var entity = world.EntityManager.CreateEntity(componentSet);
forwardMap[fileIndex] = entity;
}
using var buffer = new MemoryBlock(1024, 16, scope.AllocationHandle);
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
{
if (!forwardMap.TryGetValue(fileIndex, out var entity))
{
continue;
}
var entityData = data.Entities[fileIndex];
ref var list = ref typeIds[fileIndex];
var idx = 0;
foreach (var (typeName, componentElement) in entityData.Components)
{
var compId = list[idx++];
if (compId.IsInvalid)
{
continue;
}
var componentType = ComponentRegistry.s_runtimeIDToType[compId];
var boxed = componentElement.Deserialize(componentType, s_jsonOptions);
if (boxed == null)
{
continue;
}
boxed = RemapLocalFieldsToEntity(boxed, componentType, forwardMap);
Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
world.EntityManager.SetComponent(entity, compId, buffer.GetUnsafePtr());
}
}
}
finally
{
scope.Dispose();
for (var i = 0; i < typeIds.Length; i++)
{
typeIds[i].Dispose();
}
typeIds.Dispose();
}
RebuildAndReturn:
_worldService.RebuildSceneGraph();
return Result.Success();
}
private static Identifier<IComponent> RegisterComponentByType(Type type)
{
var getOrRegisterMethod = typeof(ComponentRegistry).GetMethod(
"GetOrRegisterComponentID",
BindingFlags.NonPublic | BindingFlags.Static,
Array.Empty<Type>());
if (getOrRegisterMethod == null)
{
return Identifier<IComponent>.Invalid;
}
if (type == null)
{
return Identifier<IComponent>.Invalid;
}
var genericMethod = getOrRegisterMethod.MakeGenericMethod(type);
return (Identifier<IComponent>)genericMethod.Invoke(null, null)!;
}
private unsafe void ClearEditorWorld()
{
var world = _worldService.EditorWorld;
using var scope = AllocationManager.CreateStackScope();
using var entitiesToDestroy = new UnsafeList<Entity>(128, scope.AllocationHandle);
for (var archIdx = 0; archIdx < world.ComponentManager.ArchetypeCount; archIdx++)
{
ref var archetype = ref world.ComponentManager.GetArchetypeReference(archIdx);
for (var chunkIdx = 0; chunkIdx < archetype.ChunkCount; chunkIdx++)
{
ref var chunk = ref archetype.GetChunkReference(chunkIdx);
var entitySpan = new Span<Entity>((byte*)chunk.GetUnsafePtr() + archetype.EntityIDsOffset, chunk._count);
entitiesToDestroy.AddRange(entitySpan);
}
}
world.EntityManager.DestroyEntities(entitiesToDestroy.AsSpan());
}
#endregion
#region Save Scene from Editor World
public unsafe Result SaveSceneFromEditorWorld(string filePath, Scene scene)
{
var world = _worldService.EditorWorld;
using var scope = AllocationManager.CreateStackScope();
using var sceneEntities = SceneManager.GetSceneEntities(scene, world, scope.AllocationHandle);
if (sceneEntities.Count == 0)
{
return Result.Failure("No entities found for the specified scene.");
}
var entities = new List<Entity>(sceneEntities.Count);
for (var i = 0; i < sceneEntities.Count; i++)
{
entities.Add(sceneEntities[i]);
}
var sorted = SortEntitiesByHierarchy(world, entities);
var reverseMap = new Dictionary<Entity, int>();
for (var i = 0; i < sorted.Count; i++)
{
reverseMap[sorted[i]] = i;
}
var data = new SceneSaveData
{
FormatVersion = SCENE_FORMAT_VERSION,
};
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteNumber("formatVersion", SCENE_FORMAT_VERSION);
writer.WriteStartArray("entities");
foreach (var entity in sorted)
{
var locationResult = world.EntityManager.GetEntityLocation(entity);
if (!locationResult.IsSuccess)
{
continue;
}
var location = locationResult.Value;
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
writer.WriteStartObject();
writer.WriteStartObject("components");
foreach (var layout in archetype._layouts)
{
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
var fullName = type.FullName ?? type.Name;
var compInfo = ComponentRegistry.GetComponentInfo(layout.componentID);
var pData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
if (pData == null)
{
continue;
}
var boxed = Marshal.PtrToStructure((nint)pData, type);
if (boxed == null)
{
continue;
}
boxed = RemapEntityFieldsToLocal(boxed, type, reverseMap);
writer.WritePropertyName(fullName);
JsonSerializer.Serialize(writer, boxed, type, s_jsonOptions);
}
writer.WriteEndObject();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
File.WriteAllBytes(filePath, stream.ToArray());
return Result.Success();
}
private static List<Entity> SortEntitiesByHierarchy(World world, List<Entity> entities)
{
var entitySet = new HashSet<Entity>(entities);
var roots = new List<Entity>();
var childrenMap = new Dictionary<Entity, List<Entity>>();
foreach (var entity in entities)
{
if (!world.EntityManager.HasComponent<Hierarchy>(entity))
{
roots.Add(entity);
continue;
}
ref var hierarchy = ref world.EntityManager.GetComponent<Hierarchy>(entity);
if (hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
{
if (!childrenMap.TryGetValue(hierarchy.parent, out var list))
{
list = new List<Entity>();
childrenMap[hierarchy.parent] = list;
}
list.Add(entity);
}
else
{
roots.Add(entity);
}
}
var sorted = new List<Entity>(entities.Count);
foreach (var root in roots)
{
AddEntityAndDescendants(sorted, root, childrenMap);
}
return sorted;
}
private static void AddEntityAndDescendants(List<Entity> sorted, Entity entity, Dictionary<Entity, List<Entity>> childrenMap)
{
sorted.Add(entity);
if (childrenMap.TryGetValue(entity, out var children))
{
foreach (var child in children)
{
AddEntityAndDescendants(sorted, child, childrenMap);
}
}
}
#endregion
public void Dispose()
{
}
}
#region Data Model
public sealed class SceneSaveData
{
public int FormatVersion
{
get; set;
} = 1;
public List<EntitySaveData> Entities
{
get; set;
} = new();
}
public sealed class EntitySaveData
{
public Dictionary<string, JsonElement> Components
{
get; set;
} = new();
}
#endregion

View File

@@ -0,0 +1,267 @@
using Ghost.Core;
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
using System.Text;
namespace Ghost.Engine;
internal partial class AssetEntry
{
private static void RegisterSceneCallback()
{
s_onCreation[(int)AssetType.Scene] = static (e) =>
{
};
s_onParseRawData[(int)AssetType.Scene] = static (e) => e.ParseSceneData();
s_onRecordUpload[(int)AssetType.Scene] = static (e, ctx) => Result.Success();
s_onUploadComplete[(int)AssetType.Scene] = static (e, ctx) =>
{
};
s_onReleaseResource[(int)AssetType.Scene] = static (e) =>
{
};
}
private unsafe Result ParseSceneData()
{
var pData = (byte*)_rawData.GetUnsafePtr();
var dataSize = _rawData.Size;
if (dataSize < 12u)
{
return Result.Failure("Scene binary data is too small.");
}
var magic = Encoding.UTF8.GetString(pData, 4);
if (magic != "GSCN")
{
return Result.Failure("Invalid scene binary magic number.");
}
return Result.Success();
}
}
internal partial class AssetManager
{
internal unsafe void* GetSceneRawDataPtr(Guid assetID)
{
var entry = GetOrCreateEntry(assetID);
Logger.DebugAssert(entry.AssetType == AssetType.Scene);
Logger.DebugAssert(entry.State >= AssetState.Loaded);
return entry.RawData.GetUnsafePtr();
}
internal int ReleaseScene(Guid assetID)
{
if (assetID == Guid.Empty)
{
return 0;
}
if (!_entries.TryGetValue(assetID, out var entry) || entry.AssetType != AssetType.Scene)
{
return 0;
}
return entry.Release();
}
}
public static class SceneLoader
{
private struct BinaryEntityInfo
{
public int entityIndex;
public int componentCount;
public struct ComponentInfo
{
public uint typeHash;
public string typeName;
public Identifier<IComponent> typeID;
public int dataSize;
public int dataOffset;
public int entityFieldCount;
public int[] entityFieldOffsets;
}
public ComponentInfo[] components;
}
public static unsafe Result<int> LoadSceneIntoWorld(World world, void* pRawData, int dataSize)
{
RegisterKnownComponentTypes();
var pData = (byte*)pRawData;
var offset = 0;
var magic = Encoding.UTF8.GetString(pData + offset, 4);
offset += 4;
if (magic != "GSCN")
{
return Result.Failure("Invalid scene binary magic.");
}
var version = ReadInt32(pData, ref offset);
if (version != 1)
{
return Result.Failure($"Unsupported scene binary version: {version}");
}
var entityCount = ReadInt32(pData, ref offset);
var entityInfos = new BinaryEntityInfo[entityCount];
var forwardMap = new Dictionary<int, Entity>(entityCount);
for (var i = 0; i < entityCount; i++)
{
var compCount = ReadInt32(pData, ref offset);
var comps = new BinaryEntityInfo.ComponentInfo[compCount];
for (var j = 0; j < compCount; j++)
{
var typeHash = (uint)ReadInt32(pData, ref offset);
var nameLength = ReadInt32(pData, ref offset);
var typeName = Encoding.UTF8.GetString(pData + offset, nameLength);
offset += nameLength;
var dataSz = ReadInt32(pData, ref offset);
var dataOff = offset;
offset += dataSz;
var fieldCount = ReadInt32(pData, ref offset);
var fieldOffsets = new int[fieldCount];
for (var f = 0; f < fieldCount; f++)
{
fieldOffsets[f] = ReadInt32(pData, ref offset);
}
var typeID = ComponentRegistry.GetComponentIDByName(typeName);
comps[j] = new BinaryEntityInfo.ComponentInfo
{
typeHash = typeHash,
typeName = typeName,
typeID = typeID,
dataSize = dataSz,
dataOffset = dataOff,
entityFieldCount = fieldCount,
entityFieldOffsets = fieldOffsets,
};
}
entityInfos[i] = new BinaryEntityInfo
{
entityIndex = i,
componentCount = compCount,
components = comps,
};
}
var pTypeIds = stackalloc Identifier<IComponent>[32];
for (var i = 0; i < entityCount; i++)
{
var info = entityInfos[i];
var validCount = 0;
for (var j = 0; j < info.componentCount; j++)
{
if (info.components[j].typeID.IsValid)
{
if (validCount < 32)
{
pTypeIds[validCount] = info.components[j].typeID;
}
validCount++;
}
}
if (validCount > 0 && validCount <= 32)
{
using var scope = AllocationManager.CreateStackScope();
using var set = new ComponentSet(scope.AllocationHandle, new ReadOnlySpan<Identifier<IComponent>>(pTypeIds, validCount));
var entity = world.EntityManager.CreateEntity(set);
forwardMap[i] = entity;
}
}
for (var i = 0; i < entityCount; i++)
{
if (!forwardMap.TryGetValue(i, out var entity))
{
continue;
}
var info = entityInfos[i];
for (var j = 0; j < info.componentCount; j++)
{
var comp = info.components[j];
if (!comp.typeID.IsValid)
{
continue;
}
var compSize = ComponentRegistry.GetComponentInfo(comp.typeID).size;
var pSrc = pData + comp.dataOffset;
world.EntityManager.SetComponent(entity, comp.typeID, pSrc);
}
}
for (var i = 0; i < entityCount; i++)
{
if (!forwardMap.TryGetValue(i, out var entity))
{
continue;
}
var info = entityInfos[i];
for (var j = 0; j < info.componentCount; j++)
{
var comp = info.components[j];
if (!comp.typeID.IsValid || comp.entityFieldCount == 0)
{
continue;
}
var pComponent = world.EntityManager.GetComponent(entity, comp.typeID);
if (pComponent == null)
{
continue;
}
for (var f = 0; f < comp.entityFieldCount; f++)
{
var fieldOffset = comp.entityFieldOffsets[f];
var pField = (byte*)pComponent + fieldOffset;
var fileLocalIndex = *(int*)pField;
if (!forwardMap.TryGetValue(fileLocalIndex, out var remappedEntity))
{
remappedEntity = Entity.Invalid;
}
*(Entity*)pField = remappedEntity;
}
}
}
return Result.Success(entityCount);
}
private static void RegisterKnownComponentTypes()
{
_ = ComponentTypeID<Ghost.Engine.Components.Hierarchy>.Value;
_ = ComponentTypeID<Ghost.Engine.Components.LocalToWorld>.Value;
_ = ComponentTypeID<Ghost.Engine.Components.SceneID>.Value;
}
private static unsafe int ReadInt32(byte* pData, ref int offset)
{
var value = *(int*)(pData + offset);
offset += 4;
return value;
}
}

View File

@@ -1,6 +1,7 @@
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
using System.Text.Json.Serialization;
namespace Ghost.Engine.Core;
@@ -19,6 +20,7 @@ public readonly struct Scene : IEquatable<Scene>
/// <summary>
/// Gets whether this scene is valid.
/// </summary>
[JsonIgnore]
public bool IsValid => _id >= 0;
/// <summary>
@@ -26,7 +28,8 @@ public readonly struct Scene : IEquatable<Scene>
/// </summary>
public static Scene Invalid => new(-1);
internal Scene(short id)
[JsonConstructor]
public Scene(short id)
{
_id = id;
}

View File

@@ -116,7 +116,7 @@ internal unsafe struct Chunk : IDisposable
internal int _count;
internal readonly int _capacity;
#if GHOST_EDITOR
#if DEBUG
// For debugging purpose
internal int _worldID;
internal int _archetypeID;

View File

@@ -44,7 +44,7 @@ internal static class ComponentRegistry
private static readonly Dictionary<IntPtr, int> s_typeHandleToID = new();
private static readonly Dictionary<string, int> s_nameToRuntimeID = new();
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
internal static readonly Dictionary<int, Type> s_runtimeIDToType = new();
#endif
@@ -83,7 +83,7 @@ internal static class ComponentRegistry
s_typeHandleToID[typeHandle] = newID;
s_nameToRuntimeID[stableName] = newID;
#if GHOST_EDITOR
#if DEBUG || GHOST_EDITOR
s_runtimeIDToType[newID.Value] = typeof(T);
#endif
@@ -106,8 +106,22 @@ internal static class ComponentRegistry
return Identifier<IComponent>.Invalid;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ComponentInfo GetComponentInfo(Identifier<IComponent> typeId)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Identifier<IComponent> GetComponentIDByName(string fullName)
{
lock (s_registeredComponents)
{
if (s_nameToRuntimeID.TryGetValue(fullName, out var id))
{
return id;
}
}
return Identifier<IComponent>.Invalid;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ComponentInfo GetComponentInfo(Identifier<IComponent> typeId)
{
lock (s_registeredComponents)
{

View File

@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
namespace Ghost.Entities;
@@ -17,12 +18,14 @@ public readonly record struct Entity
get => _id;
}
[JsonIgnore]
public GenerationID Generation
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => _generation;
}
[JsonIgnore]
public bool IsValid
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@@ -0,0 +1,471 @@
using Ghost.Editor.Core;
using Ghost.Editor.Core.Assets;
using Ghost.Editor.Core.SceneGraph;
using Ghost.Editor.Core.Services;
using Ghost.Engine;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace Ghost.UnitTest;
[TestClass]
[DoNotParallelize]
public class SceneSerializationTests
{
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType)
{
return null;
}
}
private string _projectRoot = null!;
private string _previousCurrentDirectory = null!;
private EditorWorldService _worldService = null!;
private SceneSerializationService _serializationService = null!;
[TestInitialize]
public void Setup()
{
AllocationManager.Initialize(AllocationManagerDesc.Default);
_previousCurrentDirectory = Environment.CurrentDirectory;
_projectRoot = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_projectRoot);
EditorApplication.Initialize(new EmptyServiceProvider(), _projectRoot, "SceneTest");
_worldService = new EditorWorldService();
_serializationService = new SceneSerializationService(_worldService, null!);
}
[TestCleanup]
public void Cleanup()
{
_worldService.Dispose();
AllocationManager.Dispose();
Environment.CurrentDirectory = _previousCurrentDirectory;
if (Directory.Exists(_projectRoot))
{
try
{
Directory.Delete(_projectRoot, true);
}
catch (IOException)
{
Thread.Sleep(100);
if (Directory.Exists(_projectRoot))
{
Directory.Delete(_projectRoot, true);
}
}
}
}
private Entity CreateSceneEntity(Scene scene)
{
var world = _worldService.EditorWorld;
var entity = world.EntityManager.CreateEntity();
world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
return entity;
}
private Entity CreateEntityWithHierarchy(Scene scene, Entity parent)
{
var world = _worldService.EditorWorld;
var entity = world.EntityManager.CreateEntity();
world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
world.EntityManager.AddComponent(entity, Hierarchy.Root);
world.EntityManager.AddComponent(entity, new LocalToWorld());
if (parent.IsValid)
{
HierarchyUtility.SetParent(world, entity, parent);
}
return entity;
}
[TestMethod]
public async Task SaveAndLoad_RoundTrip_PreservesEntityCount()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
CreateEntityWithHierarchy(scene, Entity.Invalid);
CreateEntityWithHierarchy(scene, Entity.Invalid);
var filePath = Path.Combine(_projectRoot, "TestScene.gscene");
var saveResult = _serializationService.SaveSceneFromEditorWorld(filePath, scene);
Assert.IsTrue(saveResult.IsSuccess, saveResult.Message);
Assert.IsTrue(File.Exists(filePath));
var json = await File.ReadAllTextAsync(filePath, TestContext.CancellationToken);
Assert.IsTrue(json.Length > 10, $"JSON too short: {json}");
var data = await SceneSerializationService.DeserializeSceneFileAsync(filePath, TestContext.CancellationToken);
Assert.IsNotNull(data);
Assert.AreEqual(3, data!.Entities.Count, $"JSON content: {json}");
// Verify saved JSON is valid
Assert.AreEqual(3, data.Entities.Count, $"data contained {data.Entities.Count} entities");
foreach (var ent in data.Entities)
{
Assert.IsTrue(ent.Components.Count > 0, "Entity has no components");
}
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
var world = _worldService.EditorWorld;
using var scope = AllocationManager.CreateStackScope();
using var entities = SceneManager.GetSceneEntities(scene, world, scope.AllocationHandle);
Assert.AreEqual(3, entities.Count, $"Expected 3 entities for scene {scene.ID} but found {entities.Count}");
}
[TestMethod]
public async Task SaveAndLoad_HierarchyRelations_Preserved()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
var parent = CreateEntityWithHierarchy(scene, Entity.Invalid);
var child = CreateEntityWithHierarchy(scene, parent);
var world = _worldService.EditorWorld;
var filePath = Path.Combine(_projectRoot, "HierarchyScene.gscene");
var saveResult = _serializationService.SaveSceneFromEditorWorld(filePath, scene);
Assert.IsTrue(saveResult.IsSuccess, saveResult.Message);
var data = await SceneSerializationService.DeserializeSceneFileAsync(filePath, TestContext.CancellationToken);
Assert.IsNotNull(data);
Assert.AreEqual(3, data!.Entities.Count);
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
var queryID = new QueryBuilder().WithAll<SceneID, Hierarchy>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
var entities = new List<Entity>();
foreach (var chunk in query.GetChunkIterator())
{
var chunkEntities = chunk.GetEntities();
entities.AddRange(chunkEntities.ToArray());
}
var children = entities.Where(e =>
{
ref var h = ref world.EntityManager.GetComponent<Hierarchy>(e);
return h.parent.IsValid;
}).ToList();
Assert.AreEqual(1, children.Count);
}
[TestMethod]
public async Task JsonFormat_EntityFieldsBecomeIntIndices()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
var parent = CreateEntityWithHierarchy(scene, Entity.Invalid);
var child = CreateEntityWithHierarchy(scene, parent);
var filePath = Path.Combine(_projectRoot, "JsonFormat.gscene");
_serializationService.SaveSceneFromEditorWorld(filePath, scene);
var json = await File.ReadAllTextAsync(filePath, TestContext.CancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.AreEqual(1, root.GetProperty("formatVersion").GetInt32());
var entities = root.GetProperty("entities");
Assert.AreEqual(3, entities.GetArrayLength());
var hasParentChild = false;
foreach (var entityElement in entities.EnumerateArray())
{
var components = entityElement.GetProperty("components");
if (components.TryGetProperty("Ghost.Engine.Components.Hierarchy", out var hierarchyElement))
{
var jsonStr = hierarchyElement.GetRawText();
var hierarchyDoc = JsonDocument.Parse(jsonStr);
if (hierarchyDoc.RootElement.TryGetProperty("parent", out var parentProp))
{
var parentValue = parentProp.GetInt32();
if (parentValue != -1)
{
hasParentChild = true;
}
}
if (hierarchyDoc.RootElement.TryGetProperty("firstChild", out var firstChildProp))
{
var firstChildValue = firstChildProp.GetInt32();
if (firstChildValue != -1)
{
hasParentChild = true;
}
}
}
}
Assert.IsTrue(hasParentChild, "Expected at least one parent-child relationship in the JSON output.");
}
[TestMethod]
public async Task ImportAsync_ProducesValidBinary()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
CreateEntityWithHierarchy(scene, Entity.Invalid);
var sourcePath = Path.Combine(_projectRoot, "ImportScene.gscene");
_serializationService.SaveSceneFromEditorWorld(sourcePath, scene);
var targetPath = ImportCoordinator.GetImportedAssetPath(Guid.NewGuid());
var handler = new SceneAssetHandler();
var result = await handler.ImportAsync(sourcePath, targetPath, Guid.NewGuid(), null, TestContext.CancellationToken);
Assert.IsTrue(result.IsSuccess, result.Message);
Assert.IsTrue(File.Exists(targetPath));
var binary = await File.ReadAllBytesAsync(targetPath, TestContext.CancellationToken);
Assert.IsTrue(binary.Length > 0);
var magic = Encoding.UTF8.GetString(binary, 0, 4);
Assert.AreEqual("GSCN", magic);
var version = MemoryMarshal.Read<int>(binary.AsSpan(4));
Assert.AreEqual(1, version);
var entityCount = MemoryMarshal.Read<int>(binary.AsSpan(8));
Assert.AreEqual(2, entityCount);
}
[TestMethod]
public void SceneLoadingType_Single_ReplacesEntities()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
CreateEntityWithHierarchy(scene, Entity.Invalid);
var scene2 = SceneManager.CreateScene();
CreateSceneEntity(scene2);
CreateEntityWithHierarchy(scene2, Entity.Invalid);
var world = _worldService.EditorWorld;
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
var initialCount = 0;
foreach (var chunk in query.GetChunkIterator())
{
initialCount += chunk.EntityCount;
}
// EditorWorldService creates 1 default scene entity, plus 4 from this test = 5
Assert.AreEqual(5, initialCount, "Expected 5 entities (1 default + 4 from test).");
var filePath = Path.Combine(_projectRoot, "SingleLoad.gscene");
_serializationService.SaveSceneFromEditorWorld(filePath, scene);
var data = SceneSerializationService.DeserializeSceneFileAsync(filePath).Result;
Assert.IsNotNull(data);
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data!, SceneLoadingType.Single);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
var afterCount = 0;
foreach (var chunk in query.GetChunkIterator())
{
afterCount += chunk.EntityCount;
}
Assert.AreEqual(2, afterCount, "Expected exactly 2 entities after Single load (previous entities cleared).");
}
[TestMethod]
public void Save_EmptyScene_ProducesEmptyFile()
{
var scene = SceneManager.CreateScene();
var filePath = Path.Combine(_projectRoot, "EmptyScene.gscene");
var saveResult = _serializationService.SaveSceneFromEditorWorld(filePath, scene);
Assert.IsTrue(saveResult.IsFailure, "Empty scene should fail to save.");
Assert.AreEqual("No entities found for the specified scene.", saveResult.Message);
}
[TestMethod]
public async Task Load_UnknownComponent_SkipsGracefully()
{
var json = @"
{
""formatVersion"": 1,
""entities"": [
{
""components"": {
""Some.Unknown.Component"": { ""foo"": 1 },
""Ghost.Engine.Components.Hierarchy"": { ""parent"": -1, ""firstChild"": -1, ""nextSibling"": -1 }
}
}
]
}";
var filePath = Path.Combine(_projectRoot, "UnknownComp.gscene");
await File.WriteAllTextAsync(filePath, json, TestContext.CancellationToken);
var data = await SceneSerializationService.DeserializeSceneFileAsync(filePath, TestContext.CancellationToken);
Assert.IsNotNull(data);
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data!);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
var world = _worldService.EditorWorld;
var queryID = new QueryBuilder().WithAll<Hierarchy>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
var entityFound = false;
foreach (var chunk in query.GetChunkIterator())
{
if (chunk.EntityCount > 0)
{
entityFound = true;
break;
}
}
Assert.IsTrue(entityFound, "Expected entity with Hierarchy component to be loaded.");
}
[TestMethod]
public async Task Load_InvalidVersion_StillLoads()
{
var json = @"
{
""formatVersion"": 999,
""entities"": [
{
""components"": {
""Ghost.Engine.Components.Hierarchy"": { ""parent"": -1, ""firstChild"": -1, ""nextSibling"": -1 }
}
}
]
}";
var filePath = Path.Combine(_projectRoot, "FutureVersion.gscene");
await File.WriteAllTextAsync(filePath, json, TestContext.CancellationToken);
var data = await SceneSerializationService.DeserializeSceneFileAsync(filePath, TestContext.CancellationToken);
Assert.IsNotNull(data);
Assert.AreEqual(999, data!.FormatVersion);
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
}
[TestMethod]
public unsafe void BinaryFormat_RoundTrip_ProducesLoadableData()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
var parent = CreateEntityWithHierarchy(scene, Entity.Invalid);
CreateEntityWithHierarchy(scene, parent);
var jsonPath = Path.Combine(_projectRoot, "BinaryRoundTrip.gscene");
_serializationService.SaveSceneFromEditorWorld(jsonPath, scene);
var data = SceneSerializationService.DeserializeSceneFileAsync(jsonPath).Result;
Assert.IsNotNull(data);
using var stream = new MemoryStream();
SceneSerializationService.SerializeToBinary(data!, stream);
var binary = stream.ToArray();
var world = World.Create(entityCapacity: 64);
try
{
fixed (byte* pBinary = binary)
{
var result = SceneLoader.LoadSceneIntoWorld(world, pBinary, binary.Length);
Assert.IsTrue(result.IsSuccess, result.Message);
Assert.AreEqual(3, result.Value);
}
var queryID = new QueryBuilder().WithAll<Hierarchy>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
var entityCount = 0;
foreach (var chunk in query.GetChunkIterator())
{
entityCount += chunk.EntityCount;
}
Assert.AreEqual(2, entityCount, "Expected 2 entities with Hierarchy component.");
}
finally
{
world.Dispose();
}
}
[TestMethod]
public async Task SaveAndLoad_LocalToWorld_Preserved()
{
var scene = SceneManager.CreateScene();
CreateSceneEntity(scene);
var entity = CreateEntityWithHierarchy(scene, Entity.Invalid);
var world = _worldService.EditorWorld;
var testMatrix = new Misaki.HighPerformance.Mathematics.float4x4(
1, 0, 0, 10,
0, 1, 0, 20,
0, 0, 1, 30,
0, 0, 0, 1
);
world.EntityManager.SetComponent(entity, new LocalToWorld { matrix = testMatrix });
var filePath = Path.Combine(_projectRoot, "TransformScene.gscene");
_serializationService.SaveSceneFromEditorWorld(filePath, scene);
var data = await SceneSerializationService.DeserializeSceneFileAsync(filePath, TestContext.CancellationToken);
Assert.IsNotNull(data);
var loadResult = _serializationService.LoadSceneIntoEditorWorld(data!);
Assert.IsTrue(loadResult.IsSuccess, loadResult.Message);
var queryID = new QueryBuilder().WithAll<LocalToWorld>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
foreach (var chunk in query.GetChunkIterator())
{
var ltws = chunk.GetComponentData<LocalToWorld>();
var found = false;
foreach (ref readonly var ltw in ltws)
{
if (ltw.matrix.c3.x == 10 && ltw.matrix.c3.y == 20 && ltw.matrix.c3.z == 30)
{
found = true;
break;
}
}
if (found)
{
return;
}
}
Assert.Fail("LocalToWorld component with expected transform was not found after round-trip.");
}
public TestContext TestContext
{
get; set;
} = null!;
}