diff --git a/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs b/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs index 06f43a0..daecfe0 100644 --- a/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs +++ b/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs @@ -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 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 CompileGraphicsShader(Stream stream) @@ -294,6 +301,7 @@ public static class DSLShaderCompiler public static Result 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(), Keywords = semantics.keywords?.ToArray() ?? Array.Empty() }; +#else + return Result.Failure("GHOST_EDITOR is not defined"); +#endif } } diff --git a/src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs b/src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs new file mode 100644 index 0000000..bd4cbeb --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs @@ -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 +{ +} diff --git a/src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs b/src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs new file mode 100644 index 0000000..f581fd9 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs @@ -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> 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(asset); + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + public ValueTask 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> 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()); + } + catch (Exception ex) + { + return Result.Failure($"Failed to import scene asset: {ex.Message}"); + } + } + + public async ValueTask 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}"); + } + } +} diff --git a/src/Editor/Ghost.Editor.Core/SceneGraph/SceneLoadingType.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneLoadingType.cs new file mode 100644 index 0000000..111ec2c --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneLoadingType.cs @@ -0,0 +1,7 @@ +namespace Ghost.Editor.Core.SceneGraph; + +public enum SceneLoadingType +{ + Single = 0, + Additive = 1, +} diff --git a/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs b/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs index 71f86ec..ed0cadb 100644 --- a/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs +++ b/src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs @@ -50,5 +50,6 @@ public class EditorWorldService : IDisposable public void Dispose() { World.Destroy(EditorWorld.ID); + GC.SuppressFinalize(this); } } diff --git a/src/Editor/Ghost.Editor.Core/Services/SceneSerializationService.cs b/src/Editor/Ghost.Editor.Core/Services/SceneSerializationService.cs new file mode 100644 index 0000000..63fa806 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/SceneSerializationService.cs @@ -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 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 + { + 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 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(); + 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 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 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(); + } + + var entityFields = GetEntityFields(type); + if (entityFields.Length == 0) + { + return Array.Empty(); + } + + 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 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(entityCount); + + var scope = AllocationManager.CreateStackScope(); + var typeIds = new UnsafeArray>>(entityCount, scope.AllocationHandle); + for (var i = 0; i < typeIds.Length; i++) + { + typeIds[i] = new UnsafeList>(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 RegisterComponentByType(Type type) + { + var getOrRegisterMethod = typeof(ComponentRegistry).GetMethod( + "GetOrRegisterComponentID", + BindingFlags.NonPublic | BindingFlags.Static, + Array.Empty()); + + if (getOrRegisterMethod == null) + { + return Identifier.Invalid; + } + + if (type == null) + { + return Identifier.Invalid; + } + + var genericMethod = getOrRegisterMethod.MakeGenericMethod(type); + return (Identifier)genericMethod.Invoke(null, null)!; + } + + private unsafe void ClearEditorWorld() + { + var world = _worldService.EditorWorld; + + using var scope = AllocationManager.CreateStackScope(); + using var entitiesToDestroy = new UnsafeList(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((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(sceneEntities.Count); + for (var i = 0; i < sceneEntities.Count; i++) + { + entities.Add(sceneEntities[i]); + } + + var sorted = SortEntitiesByHierarchy(world, entities); + + var reverseMap = new Dictionary(); + 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 SortEntitiesByHierarchy(World world, List entities) + { + var entitySet = new HashSet(entities); + var roots = new List(); + var childrenMap = new Dictionary>(); + + foreach (var entity in entities) + { + if (!world.EntityManager.HasComponent(entity)) + { + roots.Add(entity); + continue; + } + + ref var hierarchy = ref world.EntityManager.GetComponent(entity); + if (hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent)) + { + if (!childrenMap.TryGetValue(hierarchy.parent, out var list)) + { + list = new List(); + childrenMap[hierarchy.parent] = list; + } + + list.Add(entity); + } + else + { + roots.Add(entity); + } + } + + var sorted = new List(entities.Count); + foreach (var root in roots) + { + AddEntityAndDescendants(sorted, root, childrenMap); + } + + return sorted; + } + + private static void AddEntityAndDescendants(List sorted, Entity entity, Dictionary> 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 Entities + { + get; set; + } = new(); +} + +public sealed class EntitySaveData +{ + public Dictionary Components + { + get; set; + } = new(); +} + +#endregion diff --git a/src/Runtime/Ghost.Engine/AssetManager.Scene.cs b/src/Runtime/Ghost.Engine/AssetManager.Scene.cs new file mode 100644 index 0000000..54449a2 --- /dev/null +++ b/src/Runtime/Ghost.Engine/AssetManager.Scene.cs @@ -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 typeID; + public int dataSize; + public int dataOffset; + public int entityFieldCount; + public int[] entityFieldOffsets; + } + + public ComponentInfo[] components; + } + + public static unsafe Result 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(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[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>(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.Value; + _ = ComponentTypeID.Value; + _ = ComponentTypeID.Value; + } + + private static unsafe int ReadInt32(byte* pData, ref int offset) + { + var value = *(int*)(pData + offset); + offset += 4; + return value; + } +} diff --git a/src/Runtime/Ghost.Engine/Core/Scene.cs b/src/Runtime/Ghost.Engine/Core/Scene.cs index c82d4aa..4731674 100644 --- a/src/Runtime/Ghost.Engine/Core/Scene.cs +++ b/src/Runtime/Ghost.Engine/Core/Scene.cs @@ -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 /// /// Gets whether this scene is valid. /// + [JsonIgnore] public bool IsValid => _id >= 0; /// @@ -26,7 +28,8 @@ public readonly struct Scene : IEquatable /// public static Scene Invalid => new(-1); - internal Scene(short id) + [JsonConstructor] + public Scene(short id) { _id = id; } diff --git a/src/Runtime/Ghost.Entities/Archetype.cs b/src/Runtime/Ghost.Entities/Archetype.cs index 8bd0538..08cf653 100644 --- a/src/Runtime/Ghost.Entities/Archetype.cs +++ b/src/Runtime/Ghost.Entities/Archetype.cs @@ -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; diff --git a/src/Runtime/Ghost.Entities/Component.cs b/src/Runtime/Ghost.Entities/Component.cs index e5c264e..9d3f654 100644 --- a/src/Runtime/Ghost.Entities/Component.cs +++ b/src/Runtime/Ghost.Entities/Component.cs @@ -44,7 +44,7 @@ internal static class ComponentRegistry private static readonly Dictionary s_typeHandleToID = new(); private static readonly Dictionary s_nameToRuntimeID = new(); -#if GHOST_EDITOR +#if DEBUG || GHOST_EDITOR internal static readonly Dictionary 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.Invalid; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static ComponentInfo GetComponentInfo(Identifier typeId) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Identifier GetComponentIDByName(string fullName) + { + lock (s_registeredComponents) + { + if (s_nameToRuntimeID.TryGetValue(fullName, out var id)) + { + return id; + } + } + + return Identifier.Invalid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ComponentInfo GetComponentInfo(Identifier typeId) { lock (s_registeredComponents) { diff --git a/src/Runtime/Ghost.Entities/Entity.cs b/src/Runtime/Ghost.Entities/Entity.cs index 35166bf..1f58d47 100644 --- a/src/Runtime/Ghost.Entities/Entity.cs +++ b/src/Runtime/Ghost.Entities/Entity.cs @@ -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)] diff --git a/src/Test/Ghost.UnitTest/SceneSerializationTests.cs b/src/Test/Ghost.UnitTest/SceneSerializationTests.cs new file mode 100644 index 0000000..dc1f861 --- /dev/null +++ b/src/Test/Ghost.UnitTest/SceneSerializationTests.cs @@ -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().Build(world); + ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID); + + var entities = new List(); + 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(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(binary.AsSpan(4)); + Assert.AreEqual(1, version); + + var entityCount = MemoryMarshal.Read(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().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().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().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().Build(world); + ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID); + foreach (var chunk in query.GetChunkIterator()) + { + var ltws = chunk.GetComponentData(); + 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!; +}