Files
GhostEngine/docs/specs/asset_loading_design.md
Misaki abd5ad74d5 Refactor asset pipeline: new registry, loader, and runtime
Major overhaul of asset system:
- Split assets into source, .gmeta (JSON), and cooked .imported binaries
- Replaced Asset base class; added TextureAsset, TextureLoader
- AssetManager now uses job-based, dependency-aware loading
- Unified IAssetHandler API; removed legacy handler interfaces
- Updated D3D12 allocator and graphics code for new resource model
- Improved error handling, memory management, and GPU upload logic
- Updated docs and removed obsolete code/interfaces
2026-04-18 01:46:37 +09:00

44 KiB

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<T>           — 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/<guid>.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<T> — Type-safe GUID reference

Replaces raw Guid fields everywhere. This is your equivalent of Unreal's TSoftObjectPtr / Unity's AssetReference.

namespace Ghost.Core;

/// <summary>
/// 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.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public readonly struct AssetRef<T> : IEquatable<AssetRef<T>>
{
    public readonly Guid Guid;

    public AssetRef(Guid guid) => Guid = guid;

    public bool IsValid => Guid != Guid.Empty;
    public static AssetRef<T> Null => default;

    public bool Equals(AssetRef<T> other) => Guid == other.Guid;
    public override int GetHashCode() => Guid.GetHashCode();
    public override bool Equals(object? obj) => obj is AssetRef<T> r && Equals(r);
    public override string ToString() => $"AssetRef<{typeof(T).Name}>({Guid:N})";

    public static bool operator ==(AssetRef<T> a, AssetRef<T> b) => a.Equals(b);
    public static bool operator !=(AssetRef<T> a, AssetRef<T> b) => !a.Equals(b);
}

Usage in serialized data:

// In a material's serialized properties — just GUIDs, not loaded objects
public struct MaterialProperties
{
    public AssetRef<TextureAsset> AlbedoMap;
    public AssetRef<TextureAsset> NormalMap;
    public AssetRef<TextureAsset> MetallicMap;
    // ...
}

RuntimeAsset — Base class

namespace Ghost.Core;

/// <summary>
/// Base class for all runtime asset objects. 
/// Produced by IRuntimeAssetLoader from cooked binary data.
/// </summary>
public abstract class RuntimeAsset
{
    public Guid ID { get; }

    /// <summary>
    /// The cooked format type ID. Used to find the right loader.
    /// Each RuntimeAsset subclass has exactly one ID.
    /// </summary>
    public abstract Guid TypeID { get; }

    protected RuntimeAsset(Guid id) => ID = id;
}

IContentProvider — Where bytes come from

namespace Ghost.Core;

/// <summary>
/// Abstracts the storage backend for cooked asset data.
/// Editor: reads from Library/Imports/ (loose .imported files)
/// Runtime: reads from .gpak (packed archive with manifest)
/// </summary>
public interface IContentProvider
{
    /// <summary>
    /// Returns true if cooked data exists for this asset.
    /// </summary>
    bool HasAsset(Guid guid);

    /// <summary>
    /// Open a read stream positioned at the start of the cooked payload.
    /// Caller disposes the stream.
    /// </summary>
    ValueTask<Result<Stream>> OpenReadAsync(Guid guid, CancellationToken token = default);

    /// <summary>
    /// Read the dependency list for an asset.
    /// Editor: reads from SQLite catalog (mirrors .gmeta).
    /// Runtime: reads from pack manifest.
    /// </summary>
    Guid[] GetDependencies(Guid guid);

    /// <summary>
    /// Get the cooked type ID for an asset. 
    /// This determines which IRuntimeAssetLoader to use.
    /// </summary>
    Guid GetCookedTypeId(Guid guid);
}

IRuntimeAssetLoader — The decoder interface

namespace Ghost.Core;

/// <summary>
/// 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.
/// </summary>
public interface IRuntimeAssetLoader
{
    /// <summary>
    /// The cooked format type ID this loader handles.
    /// Must match the TypeID of the RuntimeAsset subclass it produces.
    /// </summary>
    Guid CookedTypeId { get; }

    /// <summary>
    /// Read cooked binary data → RuntimeAsset.
    /// Stream is positioned at the start of the cooked payload.
    /// </summary>
    ValueTask<Result<RuntimeAsset>> LoadAsync(
        Stream cookedData, Guid assetId, CancellationToken token = default);
}

AssetState — Lifecycle tracking

namespace Ghost.Core;

public enum AssetState : byte
{
    /// <summary>Not in memory.</summary>
    Unloaded = 0,

    /// <summary>IO in progress (background thread).</summary>
    Loading = 1,

    /// <summary>CPU data ready, GPU resources not yet created.</summary>
    Loaded = 2,

    /// <summary>GPU upload complete, fully usable for rendering.</summary>
    Ready = 3,

    /// <summary>Load or upload failed. Check error message.</summary>
    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

namespace Ghost.Editor.Core.AssetHandler;

/// <summary>
/// Editor-only. Translates a source file into cooked binary format.
/// The cooked output is written to Library/Imports/<guid>.imported.
/// 
/// Each handler understands ONE source format family and produces 
/// ONE cooked format that a runtime loader can consume.
/// </summary>
public interface IImportableAssetHandler
{
    /// <summary>Create default import settings for this source format.</summary>
    IAssetSettings? CreateDefaultSettings();

    /// <summary>
    /// Translate source → cooked binary.
    /// sourceStream: raw source file (.png, .fbx, .gmat, etc.)
    /// targetStream: cooked output (.imported file)
    /// settings: from .gmeta sidecar (JSON-serialized)
    /// </summary>
    ValueTask<Result> ImportAsync(
        Stream sourceStream, Stream targetStream, Guid id,
        IAssetSettings? settings, CancellationToken token = default);
}

/// <summary>
/// Editor-only. Handles saving modified assets back to source format.
/// Not needed at runtime — the runtime only reads cooked data.
/// </summary>
public interface IAssetHandler
{
    /// <summary>
    /// Save a modified asset back to its source file format.
    /// Used by the editor when the user edits properties in the inspector.
    /// </summary>
    ValueTask<Result> SaveAsync(
        RuntimeAsset asset, Stream targetStream, CancellationToken token = default);
}

AssetHandlerRegistry — Editor-only discovery

Uses TypeCache (reflection/assembly scan). Only exists in the editor.

namespace Ghost.Editor.Core.AssetHandler;

/// <summary>
/// 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.
/// </summary>
internal sealed class AssetHandlerRegistry
{
    private readonly Dictionary<string, IImportableAssetHandler> _byExtension;
    private readonly Dictionary<Guid, IImportableAssetHandler> _byTypeId;
    private readonly Dictionary<Guid, int> _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)

namespace Ghost.Editor.Core.AssetHandler;

/// <summary>
/// Editor-only translator.
/// Reads: .png, .jpg, .tga, .hdr (via ImageMagick)
/// Writes: ImageContentHeader + NVTT-compressed DDS data
/// The runtime CookedTextureLoader reads this output.
/// </summary>
[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<Result> 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<ImageContentHeader>(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

namespace Ghost.Engine;

/// <summary>
/// Maps cooked format type IDs to their runtime loaders.
/// Registered explicitly at startup — no reflection, no assembly scanning.
/// Fully AOT-compatible and trimmable.
/// </summary>
public sealed class RuntimeLoaderRegistry
{
    private readonly Dictionary<Guid, IRuntimeAssetLoader> _loaders = new();

    /// <summary>
    /// Register a runtime loader for a cooked format type.
    /// Call this at engine startup before any assets are loaded.
    /// </summary>
    public void Register(IRuntimeAssetLoader loader)
    {
        _loaders[loader.CookedTypeId] = loader;
    }

    /// <summary>
    /// Get the loader for a given cooked type ID.
    /// Returns null if no loader is registered (unknown asset type).
    /// </summary>
    public IRuntimeAssetLoader? GetLoader(Guid cookedTypeId)
    {
        _loaders.TryGetValue(cookedTypeId, out var loader);
        return loader;
    }
}

Startup registration — explicit, no reflection:

// 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

namespace Ghost.Engine.Assets;

/// <summary>
/// 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.
/// </summary>
internal sealed class CookedTextureLoader : IRuntimeAssetLoader
{
    public Guid CookedTypeId => TextureAsset.s_typeGuid;

    public async ValueTask<Result<RuntimeAsset>> LoadAsync(
        Stream cookedData, Guid assetId, CancellationToken token = default)
    {
        try
        {
            // Read the fixed-size header
            var header = new ImageContentHeader();
            cookedData.ReadExactly(
                MemoryMarshal.AsBytes(new Span<ImageContentHeader>(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<RuntimeAsset>($"Failed to load cooked texture: {ex.Message}");
        }
    }
}

CookedMeshLoader — Runtime mesh reader

namespace Ghost.Engine.Assets;

internal sealed class CookedMeshLoader : IRuntimeAssetLoader
{
    public Guid CookedTypeId => MeshAsset.s_typeGuid;

    public async ValueTask<Result<RuntimeAsset>> 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

namespace Ghost.Engine.Assets;

internal sealed class CookedMaterialLoader : IRuntimeAssetLoader
{
    public Guid CookedTypeId => MaterialAsset.s_typeGuid;

    public async ValueTask<Result<RuntimeAsset>> 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<TextureAsset>)
        //
        // 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.

namespace Ghost.Engine;

/// <summary>
/// Central asset lifecycle manager. Works identically in editor and shipped builds.
/// The only difference is which IContentProvider is injected.
///
/// Responsibilities:
/// - Resolve AssetRef<T> → 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)
/// </summary>
public sealed class AssetManager : IDisposable
{
    private readonly IContentProvider _contentProvider;
    private readonly RuntimeLoaderRegistry _loaders;

    // ── Per-asset tracking ──
    private readonly Dictionary<Guid, AssetEntry> _entries = new();
    private readonly Lock _lock = new();

    // ── Upload queue — drained once per frame by render thread ──
    private readonly Queue<Guid> _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
    // ────────────────────────────────────────────────────────────

    /// <summary>
    /// 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.
    /// </summary>
    public T? Resolve<T>(AssetRef<T> 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;
        }
    }

    /// <summary>
    /// 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.
    /// </summary>
    public async ValueTask<T?> LoadAsync<T>(
        AssetRef<T> 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;
        }
    }

    /// <summary>
    /// Query the current state of an asset.
    /// </summary>
    public AssetState GetState(Guid guid)
    {
        lock (_lock)
        {
            return _entries.TryGetValue(guid, out var e) ? e.State : AssetState.Unloaded;
        }
    }

    // ────────────────────────────────────────────────────────────
    //  GPU Upload (called from render thread)
    // ────────────────────────────────────────────────────────────

    /// <summary>
    /// 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.
    /// </summary>
    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) { /* ... */ }

    /// <summary>
    /// Resolve material bindings:
    /// looks up AssetRef<TextureAsset> fields → already-uploaded Handle<GPUTexture>.
    /// </summary>
    // 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/

namespace Ghost.Editor.Core.Services;

/// <summary>
/// Editor-time content provider.
/// Reads cooked data from Library/Imports/<guid>.imported (loose files).
/// Reads metadata from the SQLite AssetCatalog (which mirrors .gmeta).
/// </summary>
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<Result<Stream>> OpenReadAsync(
        Guid guid, CancellationToken token = default)
    {
        var path = Path.Combine(_libraryRoot, "Imports", $"{guid:N}.imported");
        if (!File.Exists(path))
            return Result.Failure<Stream>("Asset not imported yet");

        var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
        return Result.Success<Stream>(stream);
    }

    public Guid[] GetDependencies(Guid guid) => _catalog.GetDependencies(guid);
    public Guid GetCookedTypeId(Guid guid) => _catalog.GetHandlerTypeId(guid);
}

PackedContentProvider — Packed archive for shipped builds

namespace Ghost.Engine;

/// <summary>
/// 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.
/// </summary>
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<Result<Stream>> OpenReadAsync(
        Guid guid, CancellationToken token = default)
    {
        if (!_manifest.TryGetEntry(guid, out var entry))
            return new(Result.Failure<Stream>("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

namespace Ghost.Engine;

/// <summary>
/// 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.
/// </summary>
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/<guid>.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[] }
// Ghost.Editor.Core (future)
internal static class BuildPipeline
{
    public static void BuildPack(string assetsRoot, string libraryRoot, string outputDir)
    {
        var entries = new List<PackManifest.PackEntry>();
        var allDeps = new List<Guid>();

        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:

// 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<GPUTexture> stored on TextureAsset
   b. Upload GUID-C (texture) → Handle<GPUTexture> 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

// 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)

// 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)

// Replace Queue<Guid> with PriorityQueue<Guid, float>
private readonly PriorityQueue<Guid, float> _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<T> 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.