11 KiB
11 KiB
Scene Serialization Implementation Summary
Overview
Implemented a dual-format scene serialization system for GhostEngine:
- Binary format for runtime (AOT-compatible, fast)
- JSON format for editor (reflection-based, human-readable)
Both formats support automatic Entity reference remapping.
Architecture
Two Serialization Paths
Runtime Path (Ghost.Engine) - AOT-Compatible
SceneManager → SceneBinarySerializer → SerializationContext
- Binary format using direct memory operations (
memcpy) - No reflection, no System.Text.Json dependency
- Fast, compact, suitable for shipping builds
- Synchronous operations
Editor Path (Ghost.Editor.Core) - Reflection-Based
EditorSceneManager → SceneSerializer → EntityJsonConverter → SerializationContext
- JSON format using System.Text.Json with reflection
- Human-readable, debuggable
- Automatic Entity remapping via custom converter
- Async operations
Core Components
1. SerializationContext.cs (Ghost.Engine/IO)
- Shared by both runtime and editor
- Thread-safe context using
AsyncLocal<T>for managing Entity ID remapping - Maps file-local IDs (0, 1, 2...) to runtime Entity instances
- Bidirectional mapping for both serialization and deserialization
- Usage pattern:
using var context = SerializationContext.Create(); context.RegisterEntity(fileId, runtimeEntity);
2. SceneBinarySerializer.cs (Ghost.Engine/IO)
- Runtime binary serialization - AOT-compatible
- Static utility class with synchronous methods
- Serialize: Writes entities to BinaryWriter using raw memory operations
- Format: Magic number (0x47534345 "GSCE"), version, entity count, component data
- Uses
memcpyfor component data - zero reflection - Implements Entity reference remapping for Hierarchy component
- Deserialize: Two-pass loading strategy
- Pass 1: Create all entities, build ID mapping
- Pass 2: Read and copy component data, remap Entity references
- RemapEntityReferences: Manual remapping for components with Entity fields (currently Hierarchy)
3. EntityJsonConverter.cs (Ghost.Editor.Core/Serializer/Converters)
- Editor-only custom
JsonConverter<Entity> - Automatically remaps Entity references during JSON serialization/deserialization
- During serialization: Writes file-local ID from SerializationContext
- During deserialization: Reads file-local ID and translates to runtime Entity
- Enables deep Entity reference remapping in nested components (e.g., Hierarchy)
4. SceneSerializer.cs (Ghost.Editor.Core/Serializer)
- Editor-only static utility class for JSON scene file I/O
- SaveSceneAsync: Queries entities by SceneID, serializes components using reflection
- LoadSceneAsync: Two-pass loading strategy with automatic Entity remapping
- Pass 1: Create all entities, build ID mapping
- Pass 2: Deserialize components with automatic Entity remapping via EntityJsonConverter
- File format: JSON with entities array containing component type names and data
5. Scene.cs (Ghost.Engine/Core)
- Lightweight handle class with World reference, SceneID, and Name
- No longer owns the World - respects "database pattern"
- Constructor:
Scene(World world, string name) - Implements IDisposable and IEquatable
6. SceneManager.cs (Ghost.Engine/Services)
- Runtime scene lifecycle manager - uses binary serialization
- LoadScene: Synchronous, loads from binary file, supports Single/Additive modes
- SaveScene: Synchronous, saves scene to binary file
- UnloadScene: Efficiently destroys all entities with matching SceneID
- Maintains registry of loaded scenes per World
7. EditorSceneManager.cs (Ghost.Editor.Core/SceneGraph)
- Editor scene lifecycle manager - uses JSON serialization
- SaveSceneAsync: Asynchronous, saves scene to JSON file
- Integrates with editor workflows and UI
Key Design Decisions
1. Dual Serialization Formats
- Binary for Runtime: Fast, compact, AOT-compatible for shipping builds
- No reflection or System.Text.Json dependency in Ghost.Engine
- Direct memory operations using unsafe pointers
- Synchronous operations suitable for runtime loading
- JSON for Editor: Human-readable, debuggable, reflection-based
- Located in Ghost.Editor.Core (not in runtime path)
- Async operations for editor workflows
- Automatic Entity remapping via custom JsonConverter
2. World-Centric Architecture
- World is the data container (the "database")
- Scene is a lightweight handle/view into that data
- SceneManager orchestrates the I/O and entity management
- Respects separation of concerns: World doesn't know about scenes
3. Component Tagging
- Uses
SceneIDcomponent (currently IComponent, ready for ISharedComponent upgrade) - Each entity stores its scene membership
- Enables efficient querying and batch operations
4. Entity Reference Remapping
- "Smart Serializer" strategy with two-pass loading
- File uses sequential IDs (0, 1, 2...)
- Runtime creates new Entities with different IDs
- SerializationContext handles the translation
- Binary format: Manual remapping in
RemapEntityReferencesmethod - JSON format: Automatic remapping via
EntityJsonConverter - Works for Hierarchy and any other component with Entity fields
5. AOT Compatibility
- Ghost.Engine has zero reflection-based serialization
- All JSON/reflection code isolated to Ghost.Editor.Core
- Binary serializer uses only unsafe pointers and memcpy
- Suitable for IL2CPP and NativeAOT compilation
Binary Format Specification
Header:
4 bytes: Magic number (0x47534345 "GSCE")
4 bytes: Version number (int32)
4 bytes: Entity count (int32)
For each entity:
4 bytes: File ID (int32)
4 bytes: Component count (int32)
For each component:
4 bytes: Component Type ID (int32)
4 bytes: Component Size (int32)
N bytes: Raw component data (memcpy from archetype)
Usage Examples
Runtime Usage (Binary)
// Create a world
var world = World.Create();
// Load a scene additively (synchronous)
var scene = SceneManager.LoadScene(world, "path/to/scene.bin", SceneLoadMode.Additive);
// Save the scene (synchronous)
SceneManager.SaveScene(scene, "path/to/scene.bin");
// Unload the scene
SceneManager.UnloadScene(scene);
Editor Usage (JSON)
// In editor code
var world = World.Create();
// Save scene to JSON (async)
await EditorSceneManager.SaveSceneAsync(scene, "path/to/scene.json");
// JSON is human-readable and can be version-controlled
Future Optimizations
When ISharedComponent is Available
- Change
SceneIDfromIComponenttoISharedComponent - Entities with same SceneID will be grouped in same chunks
- Unloading becomes O(chunks) instead of O(entities)
- Can free entire memory blocks instead of individual entities
Entity Remapping Source Generator
- Currently
RemapEntityReferencesinSceneBinarySerializermanually handles Hierarchy - Could implement a source generator to automatically detect Entity fields in all components
- Would eliminate need for manual per-component remapping code
- Pattern:
[SerializableEntity]attribute on fields containing Entity references
Compression
- Binary format is uncompressed raw data
- Could add optional compression (LZ4, Zstandard) for smaller file sizes
- Trade-off: loading time vs disk space
Files Modified/Created
Created in Ghost.Engine (Runtime)
Ghost.Engine/IO/SerializationContext.cs- Shared ID remapping contextGhost.Engine/IO/SceneBinarySerializer.cs- AOT-compatible binary serializationGhost.Engine/Components/SceneID.cs- Scene tagging component
Created in Ghost.Editor.Core (Editor)
Ghost.Editor.Core/Serializer/SceneSerializer.cs- JSON serialization (moved from Ghost.Engine)Ghost.Editor.Core/Serializer/Converters/EntityJsonConverter.cs- Entity remapping for JSON (moved from Ghost.Engine)
Modified
Ghost.Engine/Core/Scene.cs- Refactored to lightweight handleGhost.Engine/Services/SceneManager.cs- Runtime scene lifecycle with binary serializationGhost.Editor.Core/SceneGraph/EditorSceneManager.cs- Editor scene lifecycle with JSON serialization
Deleted
Ghost.Engine/IO/SerializerRegistry.cs- Obsolete ComponentSerializerRegistryGhost.Editor.Core/Serializer/SceneNodeSerializer.cs- Obsolete
Implementation Notes
Binary Serialization Details
- Uses
BinaryWriter/BinaryReaderfor primitive types (int, etc.) - Component data copied with
Unsafe.CopyBlock(memcpy equivalent) - Stackalloc buffer reused for zero-filled missing components (prevents stack overflow)
- Entity remapping performed after all entities created (two-pass loading)
JSON Serialization Details
- Uses
System.Text.JsonwithJsonSerializerOptions EntityJsonConverterregistered as custom converter- Automatic Entity field detection and remapping during deserialization
- Human-readable format suitable for version control
Thread Safety
SerializationContextusesAsyncLocal<T>for thread-safe context isolation- Binary serializer is not thread-safe (single-threaded runtime loading)
- JSON serializer uses async methods but should not be called concurrently for same World
Error Handling
- Missing components write zero-filled data (graceful degradation)
- Unknown component types in JSON are skipped with warning
- Invalid Entity references remap to Entity.Null
- File format version checked on load (future-proofing)
Known Limitations
-
Manual Entity Remapping in Binary Format
- Currently only Hierarchy component is remapped
- Other components with Entity fields need manual handling
- Solution: Implement source generator for automatic detection
-
Component Size Limit
- Binary serializer uses 4KB stackalloc buffer for zero-fills
- Components larger than 4KB will throw exception if missing
- Solution: Increase MaxComponentSize constant if needed
-
SceneNode Integration
- Legacy SceneNode class in Ghost.Editor.Core still exists
- May need integration with new Scene/SceneSerializer system
- Future work: Decide on SceneNode vs Scene unification
-
No Compression
- Binary format is uncompressed
- Large scenes may have bigger file sizes than necessary
- Future optimization: Add LZ4/Zstandard compression layer
-
Managed Components
- Current implementation assumes all IComponent types are unmanaged
- ScriptComponent and ManagedEntity may need separate handling
- Future work: Add managed reference serialization
Testing Recommendations
-
Binary Format Round-Trip
- Create entities with various components
- Save to binary file
- Load into new World
- Verify all component data matches
-
Entity Reference Remapping
- Create parent-child hierarchies
- Serialize and deserialize
- Verify parent/child Entity references updated correctly
-
Additive Loading
- Load multiple scenes into same World
- Verify SceneID tagging works correctly
- Unload specific scenes and verify entities destroyed
-
JSON Compatibility
- Save same scene to both JSON and binary
- Verify both formats produce equivalent results when loaded
- Test JSON editing by hand (human-readable requirement)
-
AOT Compatibility
- Build Ghost.Engine with NativeAOT or IL2CPP
- Verify no reflection or dynamic code generation warnings
- Test binary serialization in AOT-compiled build