# Asset Loading & Runtime Resolution Architecture ## Design Principles 1. **Editor handlers are translators** — they convert diverse source formats into a small set of uniform cooked binary representations. They never ship. 2. **Runtime loaders are consumers** — they read cooked binary data into runtime objects. They're tiny, AOT-safe, and explicitly registered. 3. **The `.imported` file is the contract** — it's the boundary between editor and runtime. Both sides agree on the binary format; neither needs to know about the other. --- ## Architecture Overview ``` ┌──────────────────────────────────────────────────────────────────────────────────────┐ │ Ghost.Core (runtime) │ │ │ │ AssetRef — Type-safe GUID reference (like TSoftObjectPtr) │ │ RuntimeAsset — Base class for all runtime asset objects │ │ IContentProvider — Abstract: "give me bytes for this GUID" │ │ IRuntimeAssetLoader — Abstract: "decode cooked bytes into a RuntimeAsset" │ │ AssetState — Unloaded / Loading / Loaded / Ready / Failed │ └───────────────────────────────────────────────────────────┬──────────────────────────┘ │ ┌───────────────────────────────────────────┼───────────────────────── ┐ │ │ │ ┌─────────────▼──────────────┐ ┌─────────────▼──────────────┐ │ │ Ghost.Editor.Core │ │ Ghost.Engine │ │ │ (editor only — never │ │ (ships in builds) │ │ │ ships in builds) │ │ │ │ │ │ │ RuntimeLoaderRegistry │ │ │ IImportableAssetHandler │ │ AssetManager │ │ │ AssetHandlerRegistry │ │ EditorContentProvider │ │ │ ImportCoordinator │ │ PackedContentProvider │ │ │ AssetCatalog (SQLite) │ │ │ │ │ FileSystemWatcher │ │ CookedTextureLoader │ │ │ │ │ CookedMeshLoader │ │ │ TextureAssetHandler │ │ CookedMaterialLoader │ │ │ MaterialHandler │ │ CookedShaderLoader │ │ │ MaterialXHandler │ │ CookedAudioLoader │ │ │ FBXHandler │ └────────────────────────────┘ │ │ ShaderGraphHandler │ │ └────────────────────────────┘ │ │ │ │ ImportAsync() writes │ │ cooked binary data │ │ │ │ │ ▼ │ │ Library/Imports/.imported ─────────────────────────────────│ │ (cooked binary = the contract between editor and runtime) │ └──────────────────────────────────────────────────────────────────────┘ ``` ### The Funnel: Many Source Formats → Few Cooked Formats ``` Editor handlers (translators): Cooked format: Runtime loaders (consumers): .png ─┐ .jpg ├─→ TextureAssetHandler ──→ CookedTexture ←── CookedTextureLoader .tga ┤ (header + DDS blob) (reads header + DDS) .hdr ┘ .gmat ───→ MaterialHandler ────→ CookedMaterial ←── CookedMaterialLoader .mtlx ──→ MaterialXHandler ──→ (property buffer + (reads props + refs) AssetRef[] deps) .fbx ────→ FBXHandler ────────→ CookedMesh ←── CookedMeshLoader .gltf ──→ GLTFHandler ───────→ (vertices + indices + (reads vertex/index data) meshlets + bounding box) .ghostshader → ShaderHandler ─→ CookedShader ←── CookedShaderLoader (compiled DXIL bytecode) (reads bytecode) ``` > [!IMPORTANT] > The runtime never needs to know that a texture came from a `.png` vs `.tga`, or that a material > was authored as MaterialX vs a JSON `.gmat`. It only sees the cooked output. **Adding a new source > format (e.g., OpenEXR) only requires a new editor handler — the runtime is untouched.** --- ## Layer 1: Core Types (`Ghost.Core`) ### `AssetRef` — Type-safe GUID reference Replaces raw `Guid` fields everywhere. This is your equivalent of Unreal's `TSoftObjectPtr` / Unity's `AssetReference`. ```csharp namespace Ghost.Core; /// /// A serializable, type-safe reference to an asset. /// The GUID is resolved at load time by the AssetManager. /// This is data only — it doesn't know how to load anything. /// [StructLayout(LayoutKind.Sequential)] public readonly struct AssetRef : IEquatable> { public readonly Guid Guid; public AssetRef(Guid guid) => Guid = guid; public bool IsValid => Guid != Guid.Empty; public static AssetRef Null => default; public bool Equals(AssetRef other) => Guid == other.Guid; public override int GetHashCode() => Guid.GetHashCode(); public override bool Equals(object? obj) => obj is AssetRef r && Equals(r); public override string ToString() => $"AssetRef<{typeof(T).Name}>({Guid:N})"; public static bool operator ==(AssetRef a, AssetRef b) => a.Equals(b); public static bool operator !=(AssetRef a, AssetRef b) => !a.Equals(b); } ``` Usage in serialized data: ```csharp // In a material's serialized properties — just GUIDs, not loaded objects public struct MaterialProperties { public AssetRef AlbedoMap; public AssetRef NormalMap; public AssetRef MetallicMap; // ... } ``` ### `RuntimeAsset` — Base class ```csharp namespace Ghost.Core; /// /// Base class for all runtime asset objects. /// Produced by IRuntimeAssetLoader from cooked binary data. /// public abstract class RuntimeAsset { public Guid ID { get; } /// /// The cooked format type ID. Used to find the right loader. /// Each RuntimeAsset subclass has exactly one ID. /// public abstract Guid TypeID { get; } protected RuntimeAsset(Guid id) => ID = id; } ``` ### `IContentProvider` — Where bytes come from ```csharp namespace Ghost.Core; /// /// Abstracts the storage backend for cooked asset data. /// Editor: reads from Library/Imports/ (loose .imported files) /// Runtime: reads from .gpak (packed archive with manifest) /// public interface IContentProvider { /// /// Returns true if cooked data exists for this asset. /// bool HasAsset(Guid guid); /// /// Open a read stream positioned at the start of the cooked payload. /// Caller disposes the stream. /// ValueTask> OpenReadAsync(Guid guid, CancellationToken token = default); /// /// Read the dependency list for an asset. /// Editor: reads from SQLite catalog (mirrors .gmeta). /// Runtime: reads from pack manifest. /// Guid[] GetDependencies(Guid guid); /// /// Get the cooked type ID for an asset. /// This determines which IRuntimeAssetLoader to use. /// Guid GetCookedTypeId(Guid guid); } ``` ### `IRuntimeAssetLoader` — The decoder interface ```csharp namespace Ghost.Core; /// /// Reads a cooked asset blob and produces a RuntimeAsset. /// Each cooked format type has exactly one loader implementation. /// Implementations live in Ghost.Engine, are tiny, and have zero editor dependencies. /// public interface IRuntimeAssetLoader { /// /// The cooked format type ID this loader handles. /// Must match the TypeID of the RuntimeAsset subclass it produces. /// Guid CookedTypeId { get; } /// /// Read cooked binary data → RuntimeAsset. /// Stream is positioned at the start of the cooked payload. /// ValueTask> LoadAsync( Stream cookedData, Guid assetId, CancellationToken token = default); } ``` ### `AssetState` — Lifecycle tracking ```csharp namespace Ghost.Core; public enum AssetState : byte { /// Not in memory. Unloaded = 0, /// IO in progress (background thread). Loading = 1, /// CPU data ready, GPU resources not yet created. Loaded = 2, /// GPU upload complete, fully usable for rendering. Ready = 3, /// Load or upload failed. Check error message. Failed = 4, } ``` --- ## Layer 2: Editor Handlers (`Ghost.Editor.Core`) — Translators These are unchanged from your current implementation. They're the cook step. ### `IImportableAssetHandler` — The translator interface ```csharp namespace Ghost.Editor.Core.AssetHandler; /// /// Editor-only. Translates a source file into cooked binary format. /// The cooked output is written to Library/Imports/.imported. /// /// Each handler understands ONE source format family and produces /// ONE cooked format that a runtime loader can consume. /// public interface IImportableAssetHandler { /// Create default import settings for this source format. IAssetSettings? CreateDefaultSettings(); /// /// Translate source → cooked binary. /// sourceStream: raw source file (.png, .fbx, .gmat, etc.) /// targetStream: cooked output (.imported file) /// settings: from .gmeta sidecar (JSON-serialized) /// ValueTask ImportAsync( Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default); } /// /// Editor-only. Handles saving modified assets back to source format. /// Not needed at runtime — the runtime only reads cooked data. /// public interface IAssetHandler { /// /// Save a modified asset back to its source file format. /// Used by the editor when the user edits properties in the inspector. /// ValueTask SaveAsync( RuntimeAsset asset, Stream targetStream, CancellationToken token = default); } ``` ### `AssetHandlerRegistry` — Editor-only discovery Uses `TypeCache` (reflection/assembly scan). Only exists in the editor. ```csharp namespace Ghost.Editor.Core.AssetHandler; /// /// Editor-only. One-time scan at startup → O(1) lookups. /// Maps file extensions → import handlers. /// Uses TypeCache (reflection), NOT AOT-safe — but that's fine, /// because this never ships in builds. /// internal sealed class AssetHandlerRegistry { private readonly Dictionary _byExtension; private readonly Dictionary _byTypeId; private readonly Dictionary _versionByTypeId; public AssetHandlerRegistry() { // Scan assemblies via TypeCache — editor-only, reflection-based foreach (var typeInfo in TypeCache.GetTypes()) { // ... existing scan logic unchanged ... } } public IImportableAssetHandler? GetByExtension(string ext) { /* ... */ } public IImportableAssetHandler? GetByTypeId(Guid typeId) { /* ... */ } public int GetVersionByTypeId(Guid typeId) { /* ... */ } } ``` ### Example: `TextureAssetHandler` (editor translator) ```csharp namespace Ghost.Editor.Core.AssetHandler; /// /// Editor-only translator. /// Reads: .png, .jpg, .tga, .hdr (via ImageMagick) /// Writes: ImageContentHeader + NVTT-compressed DDS data /// The runtime CookedTextureLoader reads this output. /// [CustomAssetHandler(ID = TextureAsset._TYPE_ID, SupportedExtensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })] internal class TextureAssetHandler : IImportableAssetHandler { public IAssetSettings? CreateDefaultSettings() => new TextureAssetSettings(); public async ValueTask ImportAsync( Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default) { var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings(); // 1. Decode source format (ImageMagick — editor-only dependency) using var image = new MagickImage(sourceStream); var bytes = image.ToByteArray(); // 2. Write cooked header var header = new ImageContentHeader { width = image.Width, height = image.Height, depth = image.Depth, colorComponents = image.ChannelCount, }; targetStream.Write(MemoryMarshal.AsBytes(new Span(ref header))); // 3. Write NVTT-compressed DDS data (editor-only dependency) await TextureProcessor.CompressToStreamAsync( targetStream, id, bytes, image.Width, image.Height, image.Depth, textureSettings, token); return Result.Success(); } } ``` > [!NOTE] > Notice the handler pulls in `ImageMagick` and `TextureProcessor` (NVTT) — these are heavy > editor-only dependencies. The runtime never touches them. --- ## Layer 3: Runtime Loaders (`Ghost.Engine`) — Consumers ### `RuntimeLoaderRegistry` — Explicit, AOT-safe registration ```csharp namespace Ghost.Engine; /// /// Maps cooked format type IDs to their runtime loaders. /// Registered explicitly at startup — no reflection, no assembly scanning. /// Fully AOT-compatible and trimmable. /// public sealed class RuntimeLoaderRegistry { private readonly Dictionary _loaders = new(); /// /// Register a runtime loader for a cooked format type. /// Call this at engine startup before any assets are loaded. /// public void Register(IRuntimeAssetLoader loader) { _loaders[loader.CookedTypeId] = loader; } /// /// Get the loader for a given cooked type ID. /// Returns null if no loader is registered (unknown asset type). /// public IRuntimeAssetLoader? GetLoader(Guid cookedTypeId) { _loaders.TryGetValue(cookedTypeId, out var loader); return loader; } } ``` Startup registration — explicit, no reflection: ```csharp // In Ghost.Engine startup (EngineCore.Initialize or similar) var loaders = new RuntimeLoaderRegistry(); loaders.Register(new CookedTextureLoader()); loaders.Register(new CookedMeshLoader()); loaders.Register(new CookedMaterialLoader()); loaders.Register(new CookedShaderLoader()); // That's it. ~5 lines for all asset types the runtime will ever need. ``` ### `CookedTextureLoader` — Runtime texture reader ```csharp namespace Ghost.Engine.Assets; /// /// Reads cooked texture data written by TextureAssetHandler. /// Format: ImageContentHeader (64 bytes) + DDS blob (rest of stream). /// Produces a TextureAsset with CPU-side data ready for GPU upload. /// internal sealed class CookedTextureLoader : IRuntimeAssetLoader { public Guid CookedTypeId => TextureAsset.s_typeGuid; public async ValueTask> LoadAsync( Stream cookedData, Guid assetId, CancellationToken token = default) { try { // Read the fixed-size header var header = new ImageContentHeader(); cookedData.ReadExactly( MemoryMarshal.AsBytes(new Span(ref header))); // Read remaining DDS data var dataSize = (int)(cookedData.Length - cookedData.Position); var imageData = new byte[dataSize]; await cookedData.ReadExactlyAsync(imageData, token).ConfigureAwait(false); return new TextureAsset(imageData, header, assetId); } catch (Exception ex) { return Result.Failure($"Failed to load cooked texture: {ex.Message}"); } } } ``` ### `CookedMeshLoader` — Runtime mesh reader ```csharp namespace Ghost.Engine.Assets; internal sealed class CookedMeshLoader : IRuntimeAssetLoader { public Guid CookedTypeId => MeshAsset.s_typeGuid; public async ValueTask> LoadAsync( Stream cookedData, Guid assetId, CancellationToken token = default) { // Read: vertex count (uint) + index count (uint) // + raw Vertex[] data + raw uint[] indices // + bounding box + meshlet data // Exact layout matches what FBXHandler/GLTFHandler ImportAsync writes. // ... } } ``` ### `CookedMaterialLoader` — Runtime material reader ```csharp namespace Ghost.Engine.Assets; internal sealed class CookedMaterialLoader : IRuntimeAssetLoader { public Guid CookedTypeId => MaterialAsset.s_typeGuid; public async ValueTask> LoadAsync( Stream cookedData, Guid assetId, CancellationToken token = default) { // Read: shader reference (GUID) // + property buffer (raw bytes matching shader's property layout) // + texture slot bindings (array of AssetRef) // // Note: the material doesn't care if it was authored as .gmat, // MaterialX, or Shader Graph. All of those editor formats cook // down to the same binary layout. // ... } } ``` --- ## Layer 4: `AssetManager` — The Heart (`Ghost.Engine`) Central runtime service. Manages the full lifecycle: resolve → load → upload. ```csharp namespace Ghost.Engine; /// /// Central asset lifecycle manager. Works identically in editor and shipped builds. /// The only difference is which IContentProvider is injected. /// /// Responsibilities: /// - Resolve AssetRef → loaded, GPU-ready RuntimeAsset /// - Load dependencies before dependents (materials wait for textures) /// - Schedule GPU uploads per-frame (bounded, non-blocking) /// - Reference counting for unloading (future) /// public sealed class AssetManager : IDisposable { private readonly IContentProvider _contentProvider; private readonly RuntimeLoaderRegistry _loaders; // ── Per-asset tracking ── private readonly Dictionary _entries = new(); private readonly Lock _lock = new(); // ── Upload queue — drained once per frame by render thread ── private readonly Queue _pendingUploads = new(); private struct AssetEntry { public RuntimeAsset? Asset; public AssetState State; public int RefCount; public Task? LoadTask; public string? Error; } public AssetManager(IContentProvider contentProvider, RuntimeLoaderRegistry loaders) { _contentProvider = contentProvider; _loaders = loaders; } // ──────────────────────────────────────────────────────────── // Public API // ──────────────────────────────────────────────────────────── /// /// Non-blocking resolve. Returns the asset if Ready, null otherwise. /// If the asset hasn't been requested yet, kicks off async loading. /// Callers should use fallback resources when this returns null. /// public T? Resolve(AssetRef assetRef) where T : RuntimeAsset { if (!assetRef.IsValid) return null; lock (_lock) { if (_entries.TryGetValue(assetRef.Guid, out var entry)) { return entry.State == AssetState.Ready ? entry.Asset as T : null; } // First request — start loading BeginLoad(assetRef.Guid); return null; } } /// /// Blocking load. Returns when the asset reaches at least Loaded state. /// GPU upload still happens via ProcessUploads on the render thread. /// Use for loading screens or synchronous initialization. /// public async ValueTask LoadAsync( AssetRef assetRef, CancellationToken token = default) where T : RuntimeAsset { if (!assetRef.IsValid) return null; Task? loadTask; lock (_lock) { if (!_entries.TryGetValue(assetRef.Guid, out var entry)) { BeginLoad(assetRef.Guid); entry = _entries[assetRef.Guid]; } if (entry.State >= AssetState.Loaded) return entry.Asset as T; loadTask = entry.LoadTask; } if (loadTask is not null) await loadTask.WaitAsync(token).ConfigureAwait(false); lock (_lock) { return _entries.TryGetValue(assetRef.Guid, out var e) ? e.Asset as T : null; } } /// /// Query the current state of an asset. /// public AssetState GetState(Guid guid) { lock (_lock) { return _entries.TryGetValue(guid, out var e) ? e.State : AssetState.Unloaded; } } // ──────────────────────────────────────────────────────────── // GPU Upload (called from render thread) // ──────────────────────────────────────────────────────────── /// /// Process queued GPU uploads. Must be called once per frame from /// within a RenderContext scope (render thread, active command buffer). /// /// maxUploadsPerFrame bounds GPU work per frame to avoid stalls. /// public void ProcessUploads(RenderContext ctx, int maxUploadsPerFrame = 4) { for (int i = 0; i < maxUploadsPerFrame; i++) { Guid guid; lock (_lock) { if (!_pendingUploads.TryDequeue(out guid)) break; } lock (_lock) { if (!_entries.TryGetValue(guid, out var entry) || entry.Asset is null) continue; // Dispatch upload based on runtime asset type var success = entry.Asset switch { TextureAsset tex => UploadTexture(tex, ctx), // MeshAsset mesh => UploadMesh(mesh, ctx), // MaterialAsset mat => ResolveMaterialBindings(mat, ctx), _ => true, // No GPU upload needed for this type }; if (success) { entry.State = AssetState.Ready; _entries[guid] = entry; } else { // Re-queue for next frame (e.g., GPU memory pressure) _pendingUploads.Enqueue(guid); } } } } // ──────────────────────────────────────────────────────────── // Internal: Load pipeline // ──────────────────────────────────────────────────────────── private void BeginLoad(Guid guid) { // Must be called under _lock var entry = new AssetEntry { State = AssetState.Loading, RefCount = 1, }; entry.LoadTask = Task.Run(async () => await ExecuteLoadAsync(guid)); _entries[guid] = entry; } private async Task ExecuteLoadAsync(Guid guid) { try { // ── Step 1: Load dependencies first (recursive, depth-first) ── var deps = _contentProvider.GetDependencies(guid); foreach (var dep in deps) { // Ensure each dependency is loaded before we proceed Task? depTask = null; lock (_lock) { if (!_entries.TryGetValue(dep, out var depEntry)) { BeginLoad(dep); depEntry = _entries[dep]; } depTask = depEntry.LoadTask; } if (depTask is not null) await depTask.ConfigureAwait(false); } // ── Step 2: Find the right runtime loader ── var cookedTypeId = _contentProvider.GetCookedTypeId(guid); var loader = _loaders.GetLoader(cookedTypeId); if (loader is null) { SetFailed(guid, $"No runtime loader for cooked type {cookedTypeId:N}"); return; } // ── Step 3: Read cooked data via content provider ── var streamResult = await _contentProvider.OpenReadAsync(guid); if (streamResult.IsFailure) { SetFailed(guid, streamResult.Message ?? "Failed to open asset"); return; } // ── Step 4: Decode cooked binary → RuntimeAsset ── RuntimeAsset asset; await using (var stream = streamResult.Value) { var loadResult = await loader.LoadAsync(stream, guid); if (loadResult.IsFailure) { SetFailed(guid, loadResult.Message ?? "Loader failed"); return; } asset = loadResult.Value; } // ── Step 5: Mark loaded + queue for GPU upload ── lock (_lock) { var entry = _entries[guid]; entry.Asset = asset; entry.State = AssetState.Loaded; _entries[guid] = entry; _pendingUploads.Enqueue(guid); } } catch (Exception ex) { SetFailed(guid, ex.Message); } } private void SetFailed(Guid guid, string error) { lock (_lock) { var entry = _entries.GetValueOrDefault(guid); entry.State = AssetState.Failed; entry.Error = error; _entries[guid] = entry; } Logger.LogError($"Asset {guid:N} load failed: {error}"); } // ──────────────────────────────────────────────────────────── // Internal: GPU upload dispatchers // ──────────────────────────────────────────────────────────── private bool UploadTexture(TextureAsset tex, RenderContext ctx) { if (tex.IsUploaded) return true; var desc = new TextureDesc { Width = tex.Width, Height = tex.Height, // Format determined from cooked header / settings }; var handle = ctx.CreateTexture(in desc, tex.TextureData.Span, $"Tex_{tex.ID:N}"); tex.SetGPUHandle(handle); return true; } // private bool UploadMesh(MeshAsset mesh, RenderContext ctx) { /* ... */ } /// /// Resolve material bindings: /// looks up AssetRef fields → already-uploaded Handle. /// // private bool ResolveMaterialBindings(MaterialAsset mat, RenderContext ctx) // { // // Dependencies loaded first, so textures should be Ready by now // var albedo = Resolve(mat.Properties.AlbedoMap); // var normal = Resolve(mat.Properties.NormalMap); // // var gpuMat = ctx.ResourceManager.CreateMaterial(mat.ShaderHandle); // ref var matRef = ref ctx.ResourceManager // .GetMaterialReference(gpuMat).GetValueOrThrow(); // // matRef.SetTexture("_AlbedoMap", albedo?.GPUTexture ?? _fallbackTexture); // matRef.SetTexture("_NormalMap", normal?.GPUTexture ?? _fallbackNormalMap); // // mat.GPUMaterial = gpuMat; // return true; // } public void Dispose() { // TODO: Release all GPU resources via ResourceManager } } ``` --- ## Layer 5: Content Providers (`Ghost.Editor.Core` / `Ghost.Engine`) ### `EditorContentProvider` — Loose files in Library/ ```csharp namespace Ghost.Editor.Core.Services; /// /// Editor-time content provider. /// Reads cooked data from Library/Imports/.imported (loose files). /// Reads metadata from the SQLite AssetCatalog (which mirrors .gmeta). /// internal sealed class EditorContentProvider : IContentProvider { private readonly AssetCatalog _catalog; private readonly string _libraryRoot; public EditorContentProvider(AssetCatalog catalog, string libraryRoot) { _catalog = catalog; _libraryRoot = libraryRoot; } public bool HasAsset(Guid guid) { var path = Path.Combine(_libraryRoot, "Imports", $"{guid:N}.imported"); return File.Exists(path); } public async ValueTask> OpenReadAsync( Guid guid, CancellationToken token = default) { var path = Path.Combine(_libraryRoot, "Imports", $"{guid:N}.imported"); if (!File.Exists(path)) return Result.Failure("Asset not imported yet"); var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); return Result.Success(stream); } public Guid[] GetDependencies(Guid guid) => _catalog.GetDependencies(guid); public Guid GetCookedTypeId(Guid guid) => _catalog.GetHandlerTypeId(guid); } ``` ### `PackedContentProvider` — Packed archive for shipped builds ```csharp namespace Ghost.Engine; /// /// Runtime content provider for shipped builds. /// Reads cooked data from a single .gpak archive. /// All metadata (dependencies, type IDs) comes from an in-memory manifest. /// internal sealed class PackedContentProvider : IContentProvider, IDisposable { private readonly PackManifest _manifest; private readonly FileStream _pakStream; public PackedContentProvider(string pakPath, string manifestPath) { _pakStream = new FileStream(pakPath, FileMode.Open, FileAccess.Read, FileShare.Read); _manifest = PackManifest.Load(manifestPath); } public bool HasAsset(Guid guid) => _manifest.Contains(guid); public ValueTask> OpenReadAsync( Guid guid, CancellationToken token = default) { if (!_manifest.TryGetEntry(guid, out var entry)) return new(Result.Failure("Asset not in pack")); // Bounded sub-stream — reads only [offset, offset+size) from the .gpak Stream slice = new SubStream(_pakStream, entry.Offset, entry.Size); return new(Result.Success(slice)); } public Guid[] GetDependencies(Guid guid) => _manifest.GetDependencies(guid); public Guid GetCookedTypeId(Guid guid) => _manifest.GetCookedTypeId(guid); public void Dispose() => _pakStream.Dispose(); } ``` ### `PackManifest` — Flat lookup table ```csharp namespace Ghost.Engine; /// /// Binary manifest for packed builds. /// Format: header (asset count) + array of PackEntry sorted by GUID. /// Loaded once at startup into memory. Binary search for O(log n) lookups. /// internal sealed class PackManifest { public readonly struct PackEntry { public readonly Guid Guid; public readonly long Offset; public readonly int Size; public readonly Guid CookedTypeId; public readonly int DependencyOffset; // index into _allDependencies public readonly int DependencyCount; } private readonly PackEntry[] _entries; // sorted by GUID private readonly Guid[] _allDependencies; // flat pool, referenced by offset+count public bool Contains(Guid guid) => FindEntry(guid) >= 0; public bool TryGetEntry(Guid guid, out PackEntry entry) { var idx = FindEntry(guid); if (idx < 0) { entry = default; return false; } entry = _entries[idx]; return true; } public Guid GetCookedTypeId(Guid guid) { var idx = FindEntry(guid); return idx >= 0 ? _entries[idx].CookedTypeId : Guid.Empty; } public Guid[] GetDependencies(Guid guid) { var idx = FindEntry(guid); if (idx < 0) return []; var e = _entries[idx]; return _allDependencies.AsSpan(e.DependencyOffset, e.DependencyCount).ToArray(); } private int FindEntry(Guid guid) { // Binary search over sorted entries // ... } public static PackManifest Load(string path) { // Read binary manifest file // ... } } ``` --- ## Build Pipeline The build pipeline runs in the editor and produces the `.gpak` + manifest: ``` Build steps: 1. Enumerate all .gmeta files under Assets/ 2. For each: read GUID, handler type ID, dependencies from .gmeta 3. Concatenate all corresponding Library/Imports/.imported files into a single content.gpak, recording each asset's offset+size 4. Write content.manifest with the PackEntry[] and dependency pool Output: GameData/ content.gpak ← concatenated cooked binary blobs content.manifest ← GUID → { offset, size, cookedTypeId, deps[] } ``` ```csharp // Ghost.Editor.Core (future) internal static class BuildPipeline { public static void BuildPack(string assetsRoot, string libraryRoot, string outputDir) { var entries = new List(); var allDeps = new List(); using var pakStream = File.Create(Path.Combine(outputDir, "content.gpak")); foreach (var metaPath in Directory.EnumerateFiles( assetsRoot, "*.gmeta", SearchOption.AllDirectories)) { var meta = AssetMetaIO.ReadAsync(metaPath).AsTask().Result; if (meta is null) continue; var importedPath = Path.Combine(libraryRoot, "Imports", $"{meta.Guid:N}.imported"); if (!File.Exists(importedPath)) continue; var offset = pakStream.Position; using (var src = File.OpenRead(importedPath)) { src.CopyTo(pakStream); } var size = (int)(pakStream.Position - offset); entries.Add(new PackManifest.PackEntry { Guid = meta.Guid, Offset = offset, Size = size, CookedTypeId = meta.HandlerTypeId ?? Guid.Empty, DependencyOffset = allDeps.Count, DependencyCount = meta.Dependencies.Length, }); allDeps.AddRange(meta.Dependencies); } // Sort entries by GUID for binary search entries.Sort((a, b) => a.Guid.CompareTo(b.Guid)); // Write manifest PackManifest.Write(Path.Combine(outputDir, "content.manifest"), entries, allDeps); } } ``` --- ## Dependency Resolution: Material → Texture Example ### In the editor (`.gmeta` tracks dependencies) When the user creates a material and assigns `hero_albedo.png` as the albedo map: ```json // hero_material.gmat.gmeta { "guid": "AAAA-...", "handlerTypeId": "MATERIAL-HANDLER-GUID", "dependencies": [ "BBBB-...", // hero_albedo.png's GUID "CCCC-..." // hero_normal.png's GUID ], "settings": { /* material settings */ } } ``` ### At runtime (AssetManager resolves the chain) ``` 1. Game code: assetManager.Resolve(materialRef) // GUID-A 2. AssetManager sees GUID-A is Unloaded → BeginLoad(GUID-A) 3. ExecuteLoadAsync(GUID-A): a. GetDependencies(GUID-A) → [GUID-B, GUID-C] b. BeginLoad(GUID-B) → CookedTextureLoader reads DDS → TextureAsset c. BeginLoad(GUID-C) → CookedTextureLoader reads DDS → TextureAsset d. await both dependency tasks e. CookedMaterialLoader reads property buffer → MaterialAsset f. Queue all three for GPU upload 4. ProcessUploads (next frame): a. Upload GUID-B (texture) → Handle stored on TextureAsset b. Upload GUID-C (texture) → Handle stored on TextureAsset c. Upload GUID-A (material) → resolves AssetRef fields to already-uploaded textures d. All three marked Ready 5. Next Resolve(materialRef) → returns MaterialAsset with working GPU bindings ``` > [!NOTE] > Between steps 1 and 5, `Resolve()` returns `null`. The renderer uses a fallback material > (e.g., solid magenta or checkerboard) until the real material is Ready. This is the same > behavior as Unreal's streaming system. --- ## Startup: Editor vs Runtime ### Editor startup ```csharp // Ghost.Editor.Core — full editor with import, FSW, catalog var catalog = new AssetCatalog(dbPath); var contentProvider = new EditorContentProvider(catalog, libraryRoot); // Editor-only: import pipeline + file watching var editorHandlerRegistry = new AssetHandlerRegistry(); // TypeCache scan var importCoordinator = new ImportCoordinator(catalog, editorHandlerRegistry, assetsRoot, libraryRoot); var assetRegistry = new AssetRegistry(assetsRoot, catalog, importCoordinator, ...); // Shared runtime: load + resolve + upload var runtimeLoaders = new RuntimeLoaderRegistry(); runtimeLoaders.Register(new CookedTextureLoader()); runtimeLoaders.Register(new CookedMeshLoader()); runtimeLoaders.Register(new CookedMaterialLoader()); runtimeLoaders.Register(new CookedShaderLoader()); var assetManager = new AssetManager(contentProvider, runtimeLoaders); ``` ### Runtime startup (shipped build) ```csharp // Ghost.Engine — no editor, no import, no FSW, no SQLite var contentProvider = new PackedContentProvider("GameData/content.gpak", "GameData/content.manifest"); var runtimeLoaders = new RuntimeLoaderRegistry(); runtimeLoaders.Register(new CookedTextureLoader()); runtimeLoaders.Register(new CookedMeshLoader()); runtimeLoaders.Register(new CookedMaterialLoader()); runtimeLoaders.Register(new CookedShaderLoader()); var assetManager = new AssetManager(contentProvider, runtimeLoaders); // Same AssetManager class, same runtime loaders — only IContentProvider differs ``` --- ## Streaming Roadmap ### Level 1: Demand loading (implement now) ``` Resolve(ref) → Unloaded → Loading (background IO) → Loaded → upload queued → Ready ``` - Returns `null` while loading — callers use fallback resources - `ProcessUploads` bounds GPU work per frame (default: 4 uploads/frame) ### Level 2: Priority-based loading queue (implement later) ```csharp // Replace Queue with PriorityQueue private readonly PriorityQueue _loadQueue = new(); // Priority driven by renderer: distance, screen-space coverage, visibility public void RequestWithPriority(Guid guid, float priority) { _loadQueue.Enqueue(guid, priority); } ``` ### Level 3: Texture mip streaming (implement much later) - Store each mip level as a separate chunk in `.gpak` - Load mip 0 (tiny) immediately, stream higher mips on demand - `IContentProvider.OpenReadAsync` extended with mip-level parameter - Requires extending `TextureAsset` to support partial/progressive GPU upload --- ## Implementation Order ``` Phase 1 (now): AssetRef in Ghost.Core RuntimeAsset base class in Ghost.Core IRuntimeAssetLoader interface in Ghost.Core IContentProvider interface in Ghost.Core Phase 2 (next): CookedTextureLoader in Ghost.Engine RuntimeLoaderRegistry in Ghost.Engine EditorContentProvider in Ghost.Editor.Core AssetManager (basic load + upload) in Ghost.Engine ProcessUploads integration into render loop Phase 3 (soon): Dependency resolution (material → texture) Fallback resources (checkerboard, default normal, error material) Reference counting / unloading Phase 4 (later): Build pipeline (cook → .gpak + manifest) PackedContentProvider in Ghost.Engine PackManifest binary format Phase 5 (future): Priority-based streaming Mip streaming Asset bundles / split packs for DLC ``` --- ## Open Design Questions > [!IMPORTANT] > 1. **`RenderContext` is a `ref struct`** — you can't store it or pass it to async methods. > `ProcessUploads` must be called synchronously within a `RenderContext` scope (inside a > frame's command recording). Does your current render loop have a clear synchronous point > where this can be called? (e.g., beginning of frame, before render graph execution) > [!IMPORTANT] > 2. **`RuntimeAsset` vs your current `Asset`** — should `RuntimeAsset` replace the existing > `Asset` base class entirely, or should `Asset` in `Ghost.Editor.Core` remain separate? > My recommendation: rename the existing `Asset` to `RuntimeAsset` and move it to `Ghost.Core`, > since the runtime needs it too. The current `Asset` is already close to what's needed. > [!WARNING] > 3. **Thread safety of `AssetManager._entries`** — the current design uses a single `Lock` for > all operations. This is simple but could become a bottleneck if many systems call `Resolve()` > simultaneously. Consider `ReaderWriterLockSlim` or a `ConcurrentDictionary` with state > transitions protected by per-entry locks. This can be optimized later.