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
|
public static class DSLShaderCompiler
|
||||||
{
|
{
|
||||||
|
#if GHOST_EDITOR
|
||||||
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
||||||
{
|
{
|
||||||
if (semantic == null)
|
if (semantic == null)
|
||||||
@@ -85,11 +86,13 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
// Currently, we just ignore inheritance.
|
// Currently, we just ignore inheritance.
|
||||||
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_EDITOR
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -154,6 +157,10 @@ public static class DSLShaderCompiler
|
|||||||
}
|
}
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
||||||
@@ -294,6 +301,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_EDITOR
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -320,5 +328,8 @@ public static class DSLShaderCompiler
|
|||||||
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
||||||
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
World.Destroy(EditorWorld.ID);
|
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 Ghost.Entities;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Ghost.Engine.Core;
|
namespace Ghost.Engine.Core;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public readonly struct Scene : IEquatable<Scene>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets whether this scene is valid.
|
/// Gets whether this scene is valid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
public bool IsValid => _id >= 0;
|
public bool IsValid => _id >= 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -26,7 +28,8 @@ public readonly struct Scene : IEquatable<Scene>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static Scene Invalid => new(-1);
|
public static Scene Invalid => new(-1);
|
||||||
|
|
||||||
internal Scene(short id)
|
[JsonConstructor]
|
||||||
|
public Scene(short id)
|
||||||
{
|
{
|
||||||
_id = id;
|
_id = id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ internal unsafe struct Chunk : IDisposable
|
|||||||
internal int _count;
|
internal int _count;
|
||||||
internal readonly int _capacity;
|
internal readonly int _capacity;
|
||||||
|
|
||||||
#if GHOST_EDITOR
|
#if DEBUG
|
||||||
// For debugging purpose
|
// For debugging purpose
|
||||||
internal int _worldID;
|
internal int _worldID;
|
||||||
internal int _archetypeID;
|
internal int _archetypeID;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ internal static class ComponentRegistry
|
|||||||
private static readonly Dictionary<IntPtr, int> s_typeHandleToID = new();
|
private static readonly Dictionary<IntPtr, int> s_typeHandleToID = new();
|
||||||
private static readonly Dictionary<string, int> s_nameToRuntimeID = 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();
|
internal static readonly Dictionary<int, Type> s_runtimeIDToType = new();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ internal static class ComponentRegistry
|
|||||||
|
|
||||||
s_typeHandleToID[typeHandle] = newID;
|
s_typeHandleToID[typeHandle] = newID;
|
||||||
s_nameToRuntimeID[stableName] = newID;
|
s_nameToRuntimeID[stableName] = newID;
|
||||||
#if GHOST_EDITOR
|
#if DEBUG || GHOST_EDITOR
|
||||||
s_runtimeIDToType[newID.Value] = typeof(T);
|
s_runtimeIDToType[newID.Value] = typeof(T);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -106,6 +106,20 @@ internal static class ComponentRegistry
|
|||||||
return Identifier<IComponent>.Invalid;
|
return Identifier<IComponent>.Invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static ComponentInfo GetComponentInfo(Identifier<IComponent> typeId)
|
public static ComponentInfo GetComponentInfo(Identifier<IComponent> typeId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Ghost.Entities;
|
namespace Ghost.Entities;
|
||||||
|
|
||||||
@@ -17,12 +18,14 @@ public readonly record struct Entity
|
|||||||
get => _id;
|
get => _id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
public GenerationID Generation
|
public GenerationID Generation
|
||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
get => _generation;
|
get => _generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
public bool IsValid
|
public bool IsValid
|
||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[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