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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
51
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
51
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal 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
|
||||
{
|
||||
}
|
||||
98
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
98
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Ghost.Editor.Core.SceneGraph;
|
||||
|
||||
public enum SceneLoadingType
|
||||
{
|
||||
Single = 0,
|
||||
Additive = 1,
|
||||
}
|
||||
@@ -50,5 +50,6 @@ public class EditorWorldService : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
World.Destroy(EditorWorld.ID);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
267
src/Runtime/Ghost.Engine/AssetManager.Scene.cs
Normal file
267
src/Runtime/Ghost.Engine/AssetManager.Scene.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)]
|
||||
|
||||
471
src/Test/Ghost.UnitTest/SceneSerializationTests.cs
Normal file
471
src/Test/Ghost.UnitTest/SceneSerializationTests.cs
Normal 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!;
|
||||
}
|
||||
Reference in New Issue
Block a user