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
44 KiB
Asset Loading & Runtime Resolution Architecture
Design Principles
- Editor handlers are translators — they convert diverse source formats into a small set of uniform cooked binary representations. They never ship.
- Runtime loaders are consumers — they read cooked binary data into runtime objects. They're tiny, AOT-safe, and explicitly registered.
- The
.importedfile 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
.pngvs.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
ImageMagickandTextureProcessor(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()returnsnull. 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
nullwhile loading — callers use fallback resources ProcessUploadsbounds 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.OpenReadAsyncextended with mip-level parameter- Requires extending
TextureAssetto 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
RenderContextis aref struct— you can't store it or pass it to async methods.ProcessUploadsmust be called synchronously within aRenderContextscope (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.
RuntimeAssetvs your currentAsset— shouldRuntimeAssetreplace the existingAssetbase class entirely, or shouldAssetinGhost.Editor.Coreremain separate? My recommendation: rename the existingAssettoRuntimeAssetand move it toGhost.Core, since the runtime needs it too. The currentAssetis already close to what's needed.
Warning
3. Thread safety of
AssetManager._entries— the current design uses a singleLockfor all operations. This is simple but could become a bottleneck if many systems callResolve()simultaneously. ConsiderReaderWriterLockSlimor aConcurrentDictionarywith state transitions protected by per-entry locks. This can be optimized later.