diff --git a/docs/specs/asset_loading_design.md b/docs/specs/asset_loading_design.md new file mode 100644 index 0000000..1959f61 --- /dev/null +++ b/docs/specs/asset_loading_design.md @@ -0,0 +1,1216 @@ +# 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. diff --git a/docs/specs/asset_registry_analysis.md b/docs/specs/asset_registry_analysis.md new file mode 100644 index 0000000..361e8ea --- /dev/null +++ b/docs/specs/asset_registry_analysis.md @@ -0,0 +1,310 @@ +# GhostEngine Asset Registry — Design Analysis & Recommendations + +## 1. Your Current Design at a Glance + +Your current approach is **Unreal-style packed binary** (`.gasset`): + +``` +┌──────────────────────────────────────────────┐ +│ AssetMetadata (128 bytes, fixed) │ +│ FormatVersion ─ ID ─ TypeID ─ │ +│ HandlerVersion ─ DependencyCount ─ │ +│ DependenciesOffset ─ SettingsOffset/Size ─ │ +│ ContentOffset/Size │ +├──────────────────────────────────────────────┤ +│ Settings blob (struct → raw bytes) │ +├──────────────────────────────────────────────┤ +│ Content blob (e.g. ImageContentHeader + raw) │ +├──────────────────────────────────────────────┤ +│ Dependencies (Guid[]) │ +└──────────────────────────────────────────────┘ +``` + +The AssetRegistry maintains an in-memory GUID↔path index by reading the first 20 bytes of every `.gasset` on startup, with a `FileSystemWatcher` for live updates. A planned SQLite backend (`AssetRegistry.Backend.cs`) would persist this catalog. + +--- + +## 2. Unreal vs Unity — The Trade-Off Matrix + +| Dimension | Unreal (Packed Binary `.uasset`) | Unity (Raw File + `.meta` sidecar) | +|---|---|---| +| **Source control** | Opaque blobs — merges impossible, diffs useless | Raw files are human-readable; `.meta` is text YAML — mergeable | +| **Import speed** | One file to open per asset | Two opens per asset (source + meta), but meta is tiny | +| **Runtime loading** | One `seek+read` → done (no re-import step) | Must "import" (cook) before runtime loading; raw files are editor-only | +| **Artist iteration** | Must re-import through editor | Can drop a PNG in Explorer & it auto-imports | +| **Dependency tracking** | Embedded in the binary — self-contained | External DB (`.meta` GUIDs + Library/) — can desync | +| **Asset settings versioning** | Binary struct layout is fragile | YAML/JSON → easy to add fields with defaults | +| **Corruption resilience** | One corrupted byte → whole asset lost | Source file is unaffected; re-import fixes derived data | +| **Build pipeline** | Already cooked (or close to it) | Separate cook step needed for builds | +| **Team discoverability** | "What is this .gasset?" → need editor to inspect | "It's a PNG, I can open it anywhere" | + +### Key Insight + +> Unreal doesn't actually store source data inside `.uasset` for most asset types. Unreal stores the **cooked/processed** representation. The source data (FBX, PSD, etc.) lives outside the engine's asset system — artists use a separate "source art" folder. The `.uasset` is a **derived artifact**, not the source of truth. + +Unity's insight was: **leave source files alone, store metadata beside them, and derive everything else into a Library/ cache.** The `.meta` sidecar is tiny (GUID + import settings in YAML), version-control-friendly, and the actual imported data lives in `Library/` (a local, regenerable cache). + +--- + +## 3. Current Design — Issues Found + +### 3.1 Binary Settings Are a Versioning Nightmare + +```csharp +// TextureAssetHandler — writes settings as raw struct bytes +Unsafe.WriteUnaligned(ref address, settings.Basic); +Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, ...), settings.Advanced); +``` + +**Problem:** Adding a single field to `BasicSettings`, `AdvancedSettings`, or `SamplerSettings` changes the struct layout. Every existing `.gasset` file becomes unreadable because the byte offsets shift. You have `HandlerVersion` in the metadata, but no migration logic — and you'd need one per handler per version. + +> [!CAUTION] +> This is the #1 pain point of the Unreal approach in practice. Epic has dedicated teams managing asset versioning with `FArchive` custom serialization + version tags. For a small team, this is a massive maintenance burden. + +### 3.2 Source File Is Destroyed on Import + +```csharp +// OnFileSystemOp — line 224 +File.Delete(assetPath); // ← deletes the original source file! +``` + +After import, the source `.png` is deleted and only the `.gasset` remains. If the user wants to change import settings (e.g. switch from BC7 to BC5 for a normal map), they need to find the original source file elsewhere and re-import. + +### 3.3 Handler Discovery Is O(N × M) per Call + +```csharp +// GetAssetHandlerForExtension — line 326-338 +foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => typeof(IAssetHandler).IsAssignableFrom(type) ...)) +``` + +This scans **every type in every loaded assembly** on each call. It's called from `OnFileSystemOp` (FileSystemWatcher callback — frequent!) and `ImportAssetAsync`. The `_cachedHandler` dictionary helps for repeat loads, but the initial scan is expensive and runs every time a new extension is encountered. + +### 3.4 `async void` in FileSystemWatcher Callback + +```csharp +private async void OnFileSystemOp(object sender, FileSystemEventArgs e) +``` + +If `ImportAsync` throws, the exception is swallowed silently (unobserved). `FileSystemWatcher` callbacks should be synchronous (queue work to a channel/queue), or at minimum wrap the body in `try/catch`. + +### 3.5 Race Conditions in Path Mapping + +```csharp +// ConcurrentDictionary + lock(_pathLock) +_pathToGuid = new ConcurrentDictionary<...>(); // concurrent dict +lock (_pathLock) { _pathToGuid[relativePath] = guid; } // but manually locked +``` + +You're using `ConcurrentDictionary` but also taking a `Lock` for every access. These two strategies conflict — either use a plain `Dictionary<>` + lock, or use `ConcurrentDictionary` lock-free. Mixing them gives the worst of both: allocation overhead of `ConcurrentDictionary` with the contention of a lock. + +### 3.6 Missing Content Hash for Cache Invalidation + +The `TextureProcessor` hashes **settings** to build a cache key (`guid_settingsHash.dds`), but doesn't hash the **source content**. If you replace a PNG with a different image of the same name, the stale cache is served because only the settings hash changed (it didn't). + +### 3.7 No Version Migration Path + +The 128-byte `AssetMetadata` header reserves space for expansion — good! But there's no mechanism to detect "this `.gasset` was written by handler v1 and we're now at v3" and upgrade in place. Currently `HandlerVersion` is written but never read. + +--- + +## 4. Recommendation: Hybrid Architecture + +I recommend a **Unity-inspired hybrid** — keep source files untouched, use lightweight sidecar metadata, and produce a separate cooked cache. Here's the concrete design: + +### 4.1 Three-Layer Architecture + +``` +ProjectRoot/ +├── Assets/ ← Source files (PNG, FBX, HLSL, ...) +│ ├── Textures/ +│ │ ├── hero_diffuse.png ← Source of truth (never modified) +│ │ └── hero_diffuse.png.gmeta ← Sidecar: GUID + import settings (YAML/JSON) +│ └── Models/ +│ ├── character.fbx +│ └── character.fbx.gmeta +│ +├── Library/ ← Derived data cache (local, .gitignore'd) +│ ├── AssetDB.sqlite ← Fast GUID↔path + dependency index +│ ├── Imports/ ← Cooked assets (DDS, compiled meshes, etc.) +│ │ ├── .imported ← Binary cooked data (current .gasset content section) +│ │ └── ... +│ └── Thumbnails/ +│ └── .thumb +│ +└── .ghostignore ← Patterns to exclude from asset scanning +``` + +### 4.2 `.gmeta` Sidecar File + +```yaml +# hero_diffuse.png.gmeta +guid: 0906f4eb-c3f0-431b-bcea-132c88ab0c3f +handler: TextureAssetHandler +handlerVersion: 1 +settings: + textureType: Default + textureShape: Texture2D + isSRGB: true + maxSize: 2048 + filterMode: Anisotropic + wrapMode: Repeat + generateMipmaps: true + compressionLevel: Normal + # ... full settings tree +dependencies: [] +labels: [environment, hero] # optional user tags +``` + +**Why this is better:** + +| Concern | Current `.gasset` | Proposed `.gmeta` | +|---|---|---| +| Add a field | Binary layout breaks | YAML: missing keys → default values | +| Merge conflict | Impossible (binary) | Text merge, trivial | +| Inspect settings | Need editor | Open in any text editor | +| Source file recovery | Destroyed | Untouched, always available | +| Re-import | Need original file | `Library/` rebuild from source + `.gmeta` | +| `git diff` | `Binary files differ` | Readable YAML diff | + +### 4.3 SQLite Catalog (`Library/AssetDB.sqlite`) + +Replace the in-memory `ConcurrentDictionary` mapping with an SQLite database (you already planned this in `AssetRegistry.Backend.cs`): + +```sql +-- Core asset table +CREATE TABLE assets ( + guid BLOB PRIMARY KEY, -- 16 bytes, exactly sizeof(Guid) + path TEXT NOT NULL, -- relative path to .gmeta + handler TEXT NOT NULL, -- handler type name + content_hash TEXT, -- xxHash64 of source file bytes + settings_hash TEXT, -- xxHash64 of import settings + imported_at INTEGER, -- unix timestamp of last successful import + UNIQUE(path) +); + +-- Dependency edges (forward: asset → dependency) +CREATE TABLE dependencies ( + from_guid BLOB NOT NULL REFERENCES assets(guid), + to_guid BLOB NOT NULL REFERENCES assets(guid), + PRIMARY KEY (from_guid, to_guid) +); + +-- Reverse index for "what depends on me?" queries +CREATE INDEX idx_dep_reverse ON dependencies(to_guid); + +-- Full-text search on asset paths and labels +CREATE VIRTUAL TABLE assets_fts USING fts5(path, labels); +``` + +**Startup becomes:** +1. Open SQLite DB → instant GUID↔path from indexed table +2. Diff `Assets/` tree vs DB → find stale/new/deleted `.gmeta` files +3. Queue incremental re-imports only for changed assets + +This is **dramatically faster** than scanning every `.gasset` header on disk (your current `LoadExistingAssets`). + +### 4.4 Import Pipeline + +``` +Source File Changed + │ + ▼ + FileSystemWatcher + │ + ├─── No .gmeta exists? → Generate one (new GUID, default settings) + │ + ▼ + Hash source + settings + │ + ├─── Hash matches DB? → Skip (already imported) + │ + ▼ + Queue ImportJob to background channel + │ + ▼ + ImportWorker (background thread pool) + │ + ├── Read source file + ├── Run handler pipeline (e.g. NVTT compress) + ├── Write Library/Imports/.imported + ├── Update SQLite (content_hash, settings_hash, imported_at) + └── Fire AssetChanged event on main thread +``` + +### 4.5 Handler Registration — Build Once, Cache Forever + +Replace the per-call assembly scan with a startup-once TypeCache approach (you already have this pattern in the engine): + +```csharp +// Startup: build lookup tables once +Dictionary _extensionToHandler; // ".png" → typeof(TextureAssetHandler) +Dictionary _typeIdToHandler; // TypeGuid → handler type + +// Populated once via TypeCache / assembly attribute scan at editor startup +foreach (var type in TypeCache.GetTypesWithAttribute()) +{ + var attr = type.GetCustomAttribute(); + _typeIdToHandler[new Guid(attr.ID)] = type; + foreach (var ext in attr.SupportedExtensions) + _extensionToHandler[ext] = type; +} +``` + +--- + +## 5. What to Keep from Your Current Design + +Your design has several things done well: + +| Element | Verdict | +|---|---| +| `AssetMetadata` fixed-size header with offsets | ✅ Keep for the cooked `.imported` files — great for O(1) seeks | +| `Handle` on `TextureAsset` | ✅ Clean separation of asset data vs GPU resource handle | +| `WeakReference` cache in registry | ✅ Elegant — auto-evicts when nothing holds the asset | +| `IAssetHandler` / `IImportableAssetHandler` split | ✅ Good separation (some assets are import-only, e.g. shaders compiled differently) | +| `AssetReference` with internal/external encoding | ✅ Clever — keeps sub-asset refs compact | +| `TextureProcessor` cache with settings hash | ✅ Great idea, just needs content hash too | +| `Result` return pattern | ✅ Consistent with the rest of GhostEngine | + +--- + +## 6. Summary Recommendation + +``` +┌────────────────────────────────────────────────────────────┐ +│ RECOMMENDED APPROACH │ +│ │ +│ Source files → untouched, checked into git │ +│ .gmeta sidecars → GUID + settings (YAML), in git │ +│ Library/ → derived cache, .gitignored │ +│ AssetDB.sqlite → fast GUID↔path index │ +│ Imports/*.imported → cooked binary (your AssetMetadata │ +│ header + content, no settings) │ +│ │ +│ Binary format → for cooked data only, not settings │ +│ Settings format → YAML/JSON in .gmeta (human + VCS) │ +│ Handler discovery → one-time TypeCache at startup │ +│ Watcher callbacks → queue to Channel, no async void │ +└────────────────────────────────────────────────────────────┘ +``` + +This gives you: +- **Unreal's runtime performance** (cooked binary in Library/ → single seek+read) +- **Unity's artist workflow** (drop files in Assets/, settings are readable text) +- **Clean version control** (text `.gmeta` files merge cleanly) +- **Resilient re-import** (source is never touched; Library/ is regenerable) +- **Zero startup cost** (SQLite index instead of scanning thousands of file headers) + +--- + +## 7. Open Questions for You + +1. **Do you want `.gmeta` in YAML, JSON, or a custom text format?** YAML is more compact and human-friendly, but adds a parser dependency. JSON is built into .NET but more verbose. A custom format is more work. + +2. **Should the cooked `.imported` files keep the 128-byte `AssetMetadata` header?** It's useful for validation on load, but since SQLite already knows the GUID and handler, you could simplify the binary format. + +3. **Do you want hot-reload of import settings?** (Changing `.gmeta` → auto re-import and refresh live asset in editor.) Your current `WeakReference` + `RefreshAsync` already supports this. + +4. **How do you want to handle the `Library/` on first clone?** Options: (a) full re-import from source, (b) share a pre-built Library via LFS, (c) asset server that caches imports. diff --git a/docs/specs/asset_registry_design.md b/docs/specs/asset_registry_design.md new file mode 100644 index 0000000..9ea9c1b --- /dev/null +++ b/docs/specs/asset_registry_design.md @@ -0,0 +1,1625 @@ +# GhostEngine Asset Registry — Detailed Design Document + +> **Audience:** Anyone working on this system alongside me. +> **Scope:** Full rewrite of the asset pipeline — sidecar metadata, SQLite catalog, import queue, cooked cache, and updated handler API. +> **Existing code references** are linked throughout so you can trace every decision back to the current implementation. + +--- + +## Table of Contents + +1. [Design Overview](#1-design-overview) +2. [Project Folder Layout](#2-project-folder-layout) +3. [The `.gmeta` Sidecar File](#3-the-gmeta-sidecar-file) +4. [SQLite Catalog (`AssetDB`)](#4-sqlite-catalog-assetdb) +5. [Handler Registration & TypeCache Integration](#5-handler-registration--typecache-integration) +6. [Import Pipeline](#6-import-pipeline) +7. [Asset Loading (Runtime Path)](#7-asset-loading-runtime-path) +8. [FileSystemWatcher & Change Detection](#8-filesystemwatcher--change-detection) +9. [Dependency Graph](#9-dependency-graph) +10. [Cooked Binary Format](#10-cooked-binary-format) +11. [AssetRegistry Class Redesign](#11-assetregistry-class-redesign) +12. [Handler API Redesign](#12-handler-api-redesign) +13. [Migration Path from Current Code](#13-migration-path-from-current-code) +14. [Thread Safety Model](#14-thread-safety-model) +15. [Error Handling Strategy](#15-error-handling-strategy) +16. [Open Decisions & Trade-offs](#16-open-decisions--trade-offs) + +--- + +## 1. Design Overview + +### Core Principle: Separate source truth from derived data + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ USER'S PROJECT │ +│ │ +│ Assets/ ← Source files + sidecar .gmeta (in git) │ +│ Library/ ← Derived cache (NOT in git, regenerable) │ +│ AssetDB.sqlite ← Fast GUID↔path index + dep graph │ +│ Imports/ ← Cooked binary blobs │ +│ Sources/ ← C# user scripts │ +│ Config/ ← Project config │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Three files per asset, three distinct roles:** + +| File | Location | In VCS? | Role | +|------|----------|---------|------| +| `hero.png` | `Assets/Textures/` | ✅ | Source of truth — never modified by the engine | +| `hero.png.gmeta` | `Assets/Textures/` | ✅ | Identity (GUID) + import settings (JSON) | +| `.imported` | `Library/Imports/` | ❌ | Cooked/processed binary — the runtime loads this | + +The current design ([AssetRegistry.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs)) merges all three concerns into a single `.gasset` file. The rewrite splits them. + +--- + +## 2. Project Folder Layout + +The existing `EditorApplication` ([EditorApplication.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/EditorApplication.cs)) already defines the project folder constants. We add one: + +```csharp +public const string LIBRARY_FOLDER_NAME = "Library"; +public static string LibraryFolderPath => Path.Combine(ProjectPath, LIBRARY_FOLDER_NAME); +``` + +Full project tree at runtime: + +``` +MyProject/ +├── Assets/ ← EditorApplication.AssetsFolderPath +│ ├── Textures/ +│ │ ├── hero_diffuse.png ← source file +│ │ ├── hero_diffuse.png.gmeta ← sidecar metadata +│ │ ├── hero_normal.png +│ │ └── hero_normal.png.gmeta +│ ├── Models/ +│ │ ├── character.fbx +│ │ └── character.fbx.gmeta +│ └── Materials/ +│ ├── hero_material.gasset ← engine-native assets (no source file) +│ └── hero_material.gasset.gmeta +│ +├── Library/ ← EditorApplication.LibraryFolderPath +│ ├── AssetDB.sqlite ← catalog +│ ├── Imports/ +│ │ ├── 0906f4eb-c3f0431b-bcea132c88ab0c3f.imported +│ │ └── ... +│ └── Thumbnails/ +│ └── ... +│ +├── Sources/ ← EditorApplication.SourcesFolderPath +├── Config/ ← EditorApplication.ConfigFolderPath +└── .gitignore ← must include Library/ and Caches/ +``` + +> [!IMPORTANT] +> **Engine-native assets** (materials, prefabs, scenes — things that have no external source file) still need a `.gmeta`. For these, the `.gasset` file IS the source, and the `.gmeta` sits beside it. The `Library/Imports/` entry is either a copy or not needed (the `.gasset` is already in a loadable format). + +--- + +## 3. The `.gmeta` Sidecar File + +### 3.1 Why JSON over YAML/binary + +- `System.Text.Json` ships with .NET — zero new dependencies +- JSON diffs are readable in any git client +- Add/remove fields without breaking existing files (missing keys → defaults) +- `JsonSerializerOptions.WriteIndented` makes it human-readable +- We can use source generators (`System.Text.Json.SourceGeneration`) for AOT-safe serialization in the future + +### 3.2 File naming convention + +The `.gmeta` file name is always `.gmeta`: + +``` +hero_diffuse.png → hero_diffuse.png.gmeta +character.fbx → character.fbx.gmeta +hero_material.gasset → hero_material.gasset.gmeta +``` + +This is the Unity convention. It guarantees a 1:1 mapping and lets `FileSystemWatcher` easily pair source ↔ meta. + +### 3.3 Data model + +```csharp +namespace Ghost.Editor.Core.AssetHandler; + +/// +/// Persisted as a JSON sidecar (.gmeta) next to every source asset. +/// This is the single source of truth for asset identity and import settings. +/// +public sealed class AssetMeta +{ + /// + /// Globally unique identifier for this asset. Generated once, never changes. + /// Even if the file is moved/renamed, this GUID follows it via the .gmeta. + /// + public required Guid Guid { get; init; } + + /// + /// The Guid that identifies which IAssetHandler processes this asset. + /// Maps to CustomAssetHandlerAttribute.ID. + /// Null for auto-detection from file extension. + /// + public Guid? HandlerTypeId { get; set; } + + /// + /// Version of the handler that last imported this asset. + /// Used to detect when a handler upgrade requires re-import. + /// + public int HandlerVersion { get; set; } + + /// + /// xxHash64 of the source file content at last successful import. + /// Stored as hex string for JSON readability. + /// Used to skip re-import when source hasn't changed. + /// + public string? ContentHash { get; set; } + + /// + /// xxHash64 of the serialized import settings at last successful import. + /// If settings change, we know to re-import even if source didn't change. + /// + public string? SettingsHash { get; set; } + + /// + /// UTC timestamp of last successful import. + /// + public DateTime? LastImportedUtc { get; set; } + + /// + /// GUIDs of other assets this asset depends on. + /// For example, a material might reference texture assets. + /// + public Guid[] Dependencies { get; set; } = []; + + /// + /// Optional user-facing labels for search/filtering in the editor. + /// + public string[] Labels { get; set; } = []; + + /// + /// Handler-specific import settings. Stored as a polymorphic JSON object. + /// The concrete type is determined by HandlerTypeId at deserialization time. + /// + public IAssetSettings? Settings { get; set; } +} +``` + +### 3.4 Example on disk + +```json +{ + "guid": "0906f4eb-c3f0-431b-bcea-132c88ab0c3f", + "handlerTypeId": "0906f4eb-c3f0-431b-bcea-132c88ab0c3f", + "handlerVersion": 1, + "contentHash": "A1B2C3D4E5F67890", + "settingsHash": "1234567890ABCDEF", + "lastImportedUtc": "2026-04-14T07:00:00Z", + "dependencies": [], + "labels": ["environment", "hero"], + "settings": { + "$type": "TextureAssetSettings", + "basic": { + "textureType": "Default", + "textureShape": "Texture2D", + "isSRGB": true + }, + "advanced": { + "generateMipmaps": true, + "compressionLevel": "Normal", + "mipmapFilter": "Kaiser" + }, + "sampler": { + "maxSize": 2048, + "filterMode": "Anisotropic", + "wrapMode": "Repeat" + } + } +} +``` + +### 3.5 Settings serialization — the polymorphism problem + +The `IAssetSettings` property is polymorphic — each handler has its own settings type (`TextureAssetSettings`, future `MeshAssetSettings`, etc.). We solve this with `System.Text.Json`'s built-in polymorphism: + +```csharp +// Mark IAssetSettings for polymorphic serialization +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(TextureAssetSettings), "TextureAssetSettings")] +// [JsonDerivedType(typeof(MeshAssetSettings), "MeshAssetSettings")] ← add as you create new types +public interface IAssetSettings; +``` + +> [!NOTE] +> This replaces the current approach of `Unsafe.WriteUnaligned` raw struct bytes +> ([TextureAsset.cs:229-253](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs#L229-L253)). +> The old way breaks whenever you add a field to `TextureAssetSettings.BasicSettings`. +> JSON handles field addition/removal gracefully — missing fields get default values. + +### 3.6 Reading/writing `.gmeta` files + +```csharp +namespace Ghost.Editor.Core.AssetHandler; + +internal static class AssetMetaIO +{ + private static readonly JsonSerializerOptions s_options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + /// + /// Read a .gmeta sidecar file. Returns null if the file doesn't exist. + /// + public static async ValueTask ReadAsync(string metaPath, CancellationToken token = default) + { + if (!File.Exists(metaPath)) + { + return null; + } + + await using var stream = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return await JsonSerializer.DeserializeAsync(stream, s_options, token).ConfigureAwait(false); + } + + /// + /// Write a .gmeta sidecar file atomically (write to .tmp, then rename). + /// + public static async ValueTask WriteAsync(string metaPath, AssetMeta meta, CancellationToken token = default) + { + var tempPath = metaPath + ".tmp"; + await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await JsonSerializer.SerializeAsync(stream, meta, s_options, token).ConfigureAwait(false); + } + + File.Move(tempPath, metaPath, overwrite: true); + } + + /// + /// Given a source file path, returns the expected .gmeta path. + /// + public static string GetMetaPath(string sourceFilePath) + { + return sourceFilePath + ".gmeta"; + } + + /// + /// Given a .gmeta path, returns the source file path. + /// + public static string GetSourcePath(string metaPath) + { + // "hero.png.gmeta" → "hero.png" + return metaPath[..^".gmeta".Length]; + } +} +``` + +> [!TIP] +> The atomic write (write to `.tmp` + `File.Move`) prevents corruption if the editor crashes mid-write. `File.Move` with `overwrite: true` is atomic on NTFS. + +--- + +## 4. SQLite Catalog (`AssetDB`) + +This replaces the current in-memory `ConcurrentDictionary` + `ConcurrentDictionary` ([AssetRegistry.cs:42-43](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L42-L43)) and the current `Dictionary>` dependency graph ([AssetRegistry.cs:48-49](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L48-L49)). + +### 4.1 Why SQLite + +- `Microsoft.Data.Sqlite` is already in [Ghost.Editor.Core.csproj](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj#L18) — **zero additional dependencies** +- Startup is O(1) — open file, tables are already indexed +- Survives editor crashes (WAL mode + transactions) +- Queryable — "find all textures with label X" becomes a SQL query +- Eliminates the current full disk scan on startup ([AssetRegistry.cs:99-134](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L99-L134)) + +### 4.2 Schema + +```sql +-- Database: Library/AssetDB.sqlite +-- Journal mode: WAL (concurrent readers, single writer) +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +-- Schema version for migrations +PRAGMA user_version = 1; + +-- ─── Core tables ─────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS assets ( + -- Asset GUID stored as 16-byte BLOB (matches sizeof(Guid)) + guid BLOB(16) PRIMARY KEY NOT NULL, + + -- Relative path from Assets/ root to the SOURCE file (not .gmeta) + -- e.g. "Textures/hero_diffuse.png" + source_path TEXT NOT NULL, + + -- Handler type GUID (matches CustomAssetHandlerAttribute.ID) + handler_type_id BLOB(16), + + -- Handler version that last imported this asset + handler_version INTEGER NOT NULL DEFAULT 0, + + -- xxHash64 of source file content (hex string) + content_hash TEXT, + + -- xxHash64 of import settings (hex string) + settings_hash TEXT, + + -- Unix timestamp (ms) of last successful import + imported_at_ms INTEGER, + + -- Asset state: 0 = needs import, 1 = imported, 2 = import failed + state INTEGER NOT NULL DEFAULT 0, + + -- Error message if state = 2 + error_message TEXT, + + UNIQUE(source_path) +); + +-- Fast path→guid lookup (most common query during FSW events) +CREATE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path); + +-- ─── Dependency edges ────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS dependencies ( + -- The asset that depends on another + from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, + -- The asset being depended upon + to_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, + PRIMARY KEY (from_guid, to_guid) +); + +-- Reverse lookup: "what depends on asset X?" (for invalidation cascades) +CREATE INDEX IF NOT EXISTS idx_dep_reverse ON dependencies(to_guid); + +-- ─── Labels ──────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS labels ( + guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, + label TEXT NOT NULL, + PRIMARY KEY (guid, label) +); + +CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label); +``` + +### 4.3 The `AssetCatalog` class + +This is a thin C# wrapper. It lives in `AssetRegistry.Backend.cs` (replacing the current empty placeholder): + +```csharp +namespace Ghost.Editor.Core.Services; + +using Microsoft.Data.Sqlite; + +/// +/// Thread-safe SQLite-backed asset catalog. +/// All public methods synchronize internally — callers need no locks. +/// +/// Replaces the in-memory ConcurrentDictionary approach with persistent storage +/// that survives editor restarts. +/// +internal sealed class AssetCatalog : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly Lock _writeLock = new(); + + // ─── Prepared statement cache ────────────────────────────── + // Prepared once, reused on every call — avoids SQL parsing overhead. + + private readonly SqliteCommand _cmdGetGuid; + private readonly SqliteCommand _cmdGetPath; + private readonly SqliteCommand _cmdUpsert; + private readonly SqliteCommand _cmdDelete; + private readonly SqliteCommand _cmdSetState; + private readonly SqliteCommand _cmdGetReferencers; + private readonly SqliteCommand _cmdGetDependencies; + private readonly SqliteCommand _cmdInsertDep; + private readonly SqliteCommand _cmdClearDeps; + + public AssetCatalog(string dbPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + + var connString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + _connection = new SqliteConnection(connString); + _connection.Open(); + + // Enable WAL for concurrent reads + using var pragma = _connection.CreateCommand(); + pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"; + pragma.ExecuteNonQuery(); + + CreateSchema(); + PrepareStatements(); + } + + // ─── Lookup ──────────────────────────────────────────────── + + /// + /// Get the asset GUID for a source path. Returns Guid.Empty if not found. + /// Path is relative to Assets/ root. + /// + public Guid GetGuid(string sourcePath) { /* bind + ExecuteScalar */ } + + /// + /// Get the source path for an asset GUID. Returns null if not found. + /// + public string? GetSourcePath(Guid guid) { /* bind + ExecuteScalar */ } + + // ─── Mutation ────────────────────────────────────────────── + + /// + /// Insert or update an asset entry. Called after reading/creating a .gmeta file. + /// + public void Upsert(AssetMeta meta, string sourcePath) { /* INSERT OR REPLACE */ } + + /// + /// Remove an asset entry. Called when a source file is deleted. + /// CASCADE deletes dependencies and labels automatically. + /// + public bool Remove(string sourcePath) { /* DELETE, return rows affected > 0 */ } + public bool Remove(Guid guid) { /* DELETE, return rows affected > 0 */ } + + /// + /// Mark an asset as needing re-import (state = 0). + /// + public void MarkDirty(Guid guid) { /* UPDATE state = 0 */ } + + /// + /// Mark an asset as successfully imported. + /// Updates content_hash, settings_hash, imported_at_ms, state = 1. + /// + public void MarkImported(Guid guid, string contentHash, string settingsHash) { /* UPDATE */ } + + /// + /// Mark an asset import as failed (state = 2) with error message. + /// + public void MarkFailed(Guid guid, string error) { /* UPDATE */ } + + // ─── Dependencies ────────────────────────────────────────── + + /// + /// Replace all forward dependencies for an asset (transaction). + /// + public void SetDependencies(Guid assetId, ReadOnlySpan dependencies) + { + lock (_writeLock) + { + using var tx = _connection.BeginTransaction(); + + _cmdClearDeps.Parameters[0].Value = assetId.ToByteArray(); + _cmdClearDeps.Transaction = tx; + _cmdClearDeps.ExecuteNonQuery(); + + foreach (var dep in dependencies) + { + _cmdInsertDep.Parameters[0].Value = assetId.ToByteArray(); + _cmdInsertDep.Parameters[1].Value = dep.ToByteArray(); + _cmdInsertDep.Transaction = tx; + _cmdInsertDep.ExecuteNonQuery(); + } + + tx.Commit(); + } + } + + /// + /// Get all assets that depend on the given asset (reverse lookup). + /// Used for invalidation cascade — when asset X changes, all its referencers are dirty. + /// + public List GetReferencers(Guid guid) { /* SELECT from_guid WHERE to_guid = ? */ } + + /// + /// Get all assets that the given asset depends on (forward lookup). + /// + public List GetDependencies(Guid guid) { /* SELECT to_guid WHERE from_guid = ? */ } + + // ─── Queries ─────────────────────────────────────────────── + + /// + /// Get all assets in state 0 (needs import). + /// Used on startup to queue pending imports. + /// + public List<(Guid guid, string sourcePath)> GetDirtyAssets() { /* SELECT WHERE state = 0 */ } + + /// + /// Enumerate all known assets. Used for full consistency checks. + /// + public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll() { /* SELECT */ } + + public void Dispose() + { + _cmdGetGuid.Dispose(); + _cmdGetPath.Dispose(); + // ... dispose all prepared commands ... + _connection.Dispose(); + } +} +``` + +### 4.4 Guid storage in SQLite + +SQLite doesn't have a native GUID type. We store them as 16-byte `BLOB`: + +```csharp +// Writing: +parameter.Value = guid.ToByteArray(); // Guid → byte[16] + +// Reading: +var bytes = (byte[])reader["guid"]; +var guid = new Guid(bytes); // byte[16] → Guid +``` + +This keeps GUIDs compact (16 bytes vs 36+ chars for a string) and equality checks fast (BLOB comparison). + +--- + +## 5. Handler Registration & TypeCache Integration + +### 5.1 The current problem + +The current code scans **all assemblies × all types** on every handler lookup ([AssetRegistry.cs:326-338](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L326-L338) and [AssetRegistry.cs:342-354](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L342-L354)). This is O(N×M) and runs from `FileSystemWatcher` callbacks. + +### 5.2 The fix: `AssetHandlerRegistry` (build once at startup) + +We already have `TypeCache` ([TypeCache.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs)) that scans assemblies marked with `[EngineAssembly]` at startup. But `TypeCache` currently only caches method-level attributes, not class-level ones. Two options: + +**Option A** — Extend `TypeCache` to also cache class-level `DiscoverableAttributeBase` subclasses. This is a broader change that benefits the whole editor. + +**Option B** — Build a dedicated `AssetHandlerRegistry` that does its own scan once. Simpler, self-contained. + +I recommend **Option B** for now (less blast radius), with Option A as a future cleanup: + +```csharp +namespace Ghost.Editor.Core.AssetHandler; + +/// +/// One-time scan at editor startup → two dictionaries. +/// All lookups are O(1) after construction. +/// +internal sealed class AssetHandlerRegistry +{ + // ".png" → handler instance + private readonly Dictionary _byExtension; + // TypeId GUID → handler instance + private readonly Dictionary _byTypeId; + // TypeId GUID → handler version (from attribute or const on the handler) + private readonly Dictionary _versionByTypeId; + + public AssetHandlerRegistry() + { + _byExtension = new Dictionary(StringComparer.OrdinalIgnoreCase); + _byTypeId = new Dictionary(); + _versionByTypeId = new Dictionary(); + + // Scan once using TypeCache's already-loaded types + foreach (var typeInfo in TypeCache.GetTypes()) + { + if (typeInfo.IsAbstract || typeInfo.IsInterface) + continue; + if (!typeof(IAssetHandler).IsAssignableFrom(typeInfo)) + continue; + + var attr = typeInfo.GetCustomAttribute(false); + if (attr is null) + continue; + + var handler = (IAssetHandler)Activator.CreateInstance(typeInfo)!; + var typeId = new Guid(attr.ID); + + _byTypeId[typeId] = handler; + + foreach (var ext in attr.SupportedExtensions) + { + _byExtension[ext] = handler; + } + } + } + + public IAssetHandler? GetByExtension(string extension) + { + _byExtension.TryGetValue(extension, out var handler); + return handler; + } + + public IAssetHandler? GetByTypeId(Guid typeId) + { + _byTypeId.TryGetValue(typeId, out var handler); + return handler; + } + + public IEnumerable GetSupportedExtensions() => _byExtension.Keys; +} +``` + +This replaces the `_cachedHander` dictionary and the two `GetAssetHandlerFor*` methods in the current `AssetRegistry`. + +> [!NOTE] +> We also have `AssetImporterAttribute` in [Attributes.cs:24-34](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Attributes.cs#L24-L34) which serves a similar purpose. As part of this rewrite, we should unify it with `CustomAssetHandlerAttribute` to avoid two parallel discovery systems. + +--- + +## 6. Import Pipeline + +This is the biggest change from the current design. Currently, import happens inline in `OnFileSystemOp` ([AssetRegistry.cs:189-255](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L189-L255)) and `ImportAssetAsync`. The rewrite separates detection from execution. + +### 6.1 Architecture + +``` + FileSystemWatcher Manual "Reimport" button + │ │ + ▼ ▼ + ┌─────────────────────────────────────────────────────┐ + │ ImportCoordinator │ + │ (channels FSW events → determines what to import) │ + └────────────────────────┬────────────────────────────┘ + │ writes ImportJob to + ▼ + Channel + (bounded, backpressure) + │ + ┌───────────────┼──────────────┐ + ▼ ▼ ▼ + ImportWorker ImportWorker ImportWorker + (thread pool) (thread pool) (thread pool) + │ + ▼ + ┌─────────────────┐ + │ IAssetHandler │ + │ .ImportAsync() │ + └────────┬────────┘ + │ writes + ▼ + Library/Imports/.imported ← cooked binary + AssetCatalog.MarkImported() ← update DB + AssetMeta contentHash/settingsHash ← update .gmeta + │ + ▼ + OnAssetChanged event (marshalled to UI thread via DispatcherQueue) +``` + +### 6.2 ImportJob and ImportCoordinator + +```csharp +namespace Ghost.Editor.Core.Services; + +internal enum ImportReason +{ + NewAsset, // .gmeta just created + SourceChanged, // source file content changed + SettingsChanged, // user edited import settings in inspector + HandlerUpgraded, // handler version bumped + ManualReimport, // user clicked "Reimport" in context menu + Startup, // found dirty assets on editor startup +} + +internal readonly record struct ImportJob( + Guid AssetGuid, + string SourcePath, // relative to Assets/ root + string MetaPath, // absolute path to .gmeta + ImportReason Reason +); +``` + +```csharp +internal sealed class ImportCoordinator : IDisposable +{ + private readonly Channel _importChannel; + private readonly AssetCatalog _catalog; + private readonly AssetHandlerRegistry _handlers; + private readonly string _assetsRoot; + private readonly CancellationTokenSource _cts; + private readonly Task[] _workers; + + public event EventHandler? OnAssetChanged; + + public ImportCoordinator(AssetCatalog catalog, AssetHandlerRegistry handlers, string assetsRoot, int workerCount = 2) + { + _catalog = catalog; + _handlers = handlers; + _assetsRoot = assetsRoot; + _cts = new CancellationTokenSource(); + + // Bounded channel provides backpressure if imports pile up + _importChannel = Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = false, + SingleWriter = false, + }); + + // Start N workers reading from the channel + _workers = new Task[workerCount]; + for (var i = 0; i < workerCount; i++) + { + _workers[i] = Task.Run(() => WorkerLoop(_cts.Token)); + } + } + + /// + /// Enqueue an import job. Non-blocking (unless channel is full). + /// + public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default) + { + return _importChannel.Writer.WriteAsync(job, token); + } + + /// + /// Queue all assets in state=0 (dirty) from the catalog. + /// Called once at startup after catalog sync. + /// + public async ValueTask EnqueueDirtyAssetsAsync(CancellationToken token = default) + { + foreach (var (guid, sourcePath) in _catalog.GetDirtyAssets()) + { + var metaPath = AssetMetaIO.GetMetaPath(Path.Combine(_assetsRoot, sourcePath)); + await EnqueueAsync(new ImportJob(guid, sourcePath, metaPath, ImportReason.Startup), token); + } + } + + private async Task WorkerLoop(CancellationToken token) + { + await foreach (var job in _importChannel.Reader.ReadAllAsync(token)) + { + try + { + await ProcessImportAsync(job, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Logger.Error($"Import failed for {job.SourcePath}: {ex.Message}"); + _catalog.MarkFailed(job.AssetGuid, ex.Message); + } + } + } + + private async ValueTask ProcessImportAsync(ImportJob job, CancellationToken token) + { + var fullSourcePath = Path.Combine(_assetsRoot, job.SourcePath); + + // 1. Read .gmeta + var meta = await AssetMetaIO.ReadAsync(job.MetaPath, token); + if (meta is null) + { + Logger.Warning($"Missing .gmeta for {job.SourcePath}, skipping import"); + return; + } + + // 2. Resolve handler + var ext = Path.GetExtension(job.SourcePath); + var handler = meta.HandlerTypeId.HasValue + ? _handlers.GetByTypeId(meta.HandlerTypeId.Value) + : _handlers.GetByExtension(ext); + + if (handler is not IImportableAssetHandler importable) + { + // Not importable (engine-native asset). Nothing to do. + _catalog.MarkImported(job.AssetGuid, "", ""); + return; + } + + // 3. Check if import is actually needed (content + settings hash) + var contentHash = await ComputeFileHashAsync(fullSourcePath, token); + var settingsHash = ComputeSettingsHash(meta.Settings); + + if (job.Reason != ImportReason.ManualReimport + && contentHash == meta.ContentHash + && settingsHash == meta.SettingsHash) + { + // Nothing changed — skip + _catalog.MarkImported(job.AssetGuid, contentHash, settingsHash); + return; + } + + // 4. Do the actual import + var importedPath = GetImportedPath(job.AssetGuid); + Directory.CreateDirectory(Path.GetDirectoryName(importedPath)!); + + await using var sourceStream = new FileStream(fullSourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var targetStream = new FileStream(importedPath, FileMode.Create, FileAccess.Write, FileShare.None); + + var result = await importable.ImportAsync(sourceStream, targetStream, job.AssetGuid, meta.Settings, token); + if (result.IsFailure) + { + _catalog.MarkFailed(job.AssetGuid, result.Message ?? "Unknown error"); + return; + } + + // 5. Update .gmeta with new hashes + meta.ContentHash = contentHash; + meta.SettingsHash = settingsHash; + meta.LastImportedUtc = DateTime.UtcNow; + await AssetMetaIO.WriteAsync(job.MetaPath, meta, token); + + // 6. Update catalog + _catalog.MarkImported(job.AssetGuid, contentHash, settingsHash); + + // 7. Fire change event (marshal to UI thread) + var args = new AssetChangedEventArgs(job.SourcePath, null, AssetChangeType.Modified); + EditorApplication.DispatcherQueue.TryEnqueue(() => + { + OnAssetChanged?.Invoke(/* registry */, args); + }); + } + + private string GetImportedPath(Guid guid) + { + return Path.Combine(EditorApplication.LibraryFolderPath, "Imports", $"{guid:N}.imported"); + } + + private static async ValueTask ComputeFileHashAsync(string filePath, CancellationToken token) + { + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var hash = new XxHash64(); + var buffer = ArrayPool.Shared.Rent(81920); + try + { + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, token)) > 0) + { + hash.Append(buffer.AsSpan(0, bytesRead)); + } + return hash.GetCurrentHashAsUInt64().ToString("X16"); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string ComputeSettingsHash(IAssetSettings? settings) + { + if (settings is null) return ""; + var json = JsonSerializer.SerializeToUtf8Bytes(settings); + return XxHash64.HashToUInt64(json).ToString("X16"); + } + + public void Dispose() + { + _importChannel.Writer.TryComplete(); + _cts.Cancel(); + Task.WaitAll(_workers, TimeSpan.FromSeconds(5)); + _cts.Dispose(); + } +} +``` + +### 6.3 How this fixes the current bugs + +| Current Bug | How the rewrite fixes it | +|---|---| +| `async void` FileSystemWatcher callback ([line 189](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L189)) | FSW handler is synchronous — just writes to `Channel`. All async work happens in worker tasks with proper error handling. | +| Source file deleted after import ([line 224](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L224)) | Source file is never touched. Cooked data goes to `Library/Imports/`. | +| No content hash check ([TextureProcessor.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs#L172-L179)) | `ComputeFileHashAsync` hashes source content. Both content + settings hashes must match to skip import. | +| `_ignoreFileChanges` race ([line 51](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L51)) | We watch **only** the source files and `.gmeta` files; `Library/` writes don't need ignore logic because `FileSystemWatcher` is rooted at `Assets/`. | + +--- + +## 7. Asset Loading (Runtime Path) + +Loading is the hot path — this is what happens when the game/editor needs an asset at runtime. + +### 7.1 Load flow + +``` +LoadAssetAsync(guid) + │ + ├── Check _loadedAssets WeakReference cache (keep from current design) + │ └── if alive → return immediately + │ + ├── Check Library/Imports/.imported exists + │ └── if not → return Result.Failure("Asset not imported") + │ + ├── Resolve handler via AssetHandlerRegistry.GetByTypeId() + │ └── O(1) lookup, no assembly scan + │ + ├── handler.LoadAsync(importedStream, registry, token) + │ └── reads the cooked binary format (Section 10) + │ + └── Store in _loadedAssets with WeakReference +``` + +> [!IMPORTANT] +> The `WeakReference` cache ([AssetRegistry.cs:46](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L46)) and the double-check locking pattern ([AssetRegistry.cs:414-483](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L414-L483)) are good designs — we keep them. The only change is *where* we read from (`.imported` file instead of `.gasset`). + +### 7.2 Handler signature change for LoadAsync + +The handler no longer needs to skip past the metadata header — the registry does that. The handler receives a stream pre-positioned at the content section: + +```csharp +// Current signature (unchanged) +ValueTask> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default); +``` + +But now `sourceStream` is an `FileStream` opened on `Library/Imports/.imported`, not a `.gasset`. The cooked binary format (Section 10) is simpler — it's pure content, no metadata header. + +--- + +## 8. FileSystemWatcher & Change Detection + +### 8.1 What we watch + +Only watch `Assets/` — never `Library/` (we write there, watching ourselves would cause feedback loops). + +```csharp +_watcher = new FileSystemWatcher(EditorApplication.AssetsFolderPath) +{ + IncludeSubdirectories = true, + EnableRaisingEvents = true, + NotifyFilter = NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.DirectoryName, +}; +``` + +### 8.2 Event handling decision tree + +```csharp +private void OnFileSystemEvent(object sender, FileSystemEventArgs e) +{ + var ext = Path.GetExtension(e.FullPath); + var relativePath = Path.GetRelativePath(_assetsRoot, e.FullPath); + + // Ignore .gmeta changes triggered by our own writes + if (_ignoreMetaWrites.TryRemove(e.FullPath, out _)) + return; + + // Ignore temp files + if (ext is ".tmp" or ".gtemp") + return; + + // ─── A .gmeta file changed ─────────────────────────── + if (ext == ".gmeta") + { + if (e.ChangeType == WatcherChangeTypes.Changed) + { + // User edited import settings externally → re-import the source + var sourcePath = AssetMetaIO.GetSourcePath(relativePath); + var meta = AssetMetaIO.ReadAsync(e.FullPath).AsTask().Result; // sync read is OK here, .gmeta is tiny + if (meta is not null) + { + _importCoordinator.EnqueueAsync(new ImportJob( + meta.Guid, sourcePath, e.FullPath, ImportReason.SettingsChanged + )).AsTask(); + } + } + // .gmeta Created/Deleted are handled by the source file events below + return; + } + + // ─── A source file changed ─────────────────────────── + switch (e.ChangeType) + { + case WatcherChangeTypes.Created: + // New source file dropped in → generate .gmeta + queue import + HandleNewSourceFile(e.FullPath, relativePath); + break; + + case WatcherChangeTypes.Changed: + // Source file modified → queue re-import + HandleModifiedSourceFile(e.FullPath, relativePath); + break; + + case WatcherChangeTypes.Deleted: + // Source file deleted → remove from catalog, optionally clean Library/ + HandleDeletedSourceFile(e.FullPath, relativePath); + break; + } +} +``` + +### 8.3 Auto-generating `.gmeta` for new files + +When an artist drops a `hero.png` into `Assets/Textures/`: + +```csharp +private void HandleNewSourceFile(string fullPath, string relativePath) +{ + var ext = Path.GetExtension(relativePath); + var handler = _handlerRegistry.GetByExtension(ext); + if (handler is null) + return; // Unknown file type — ignore + + var metaPath = AssetMetaIO.GetMetaPath(fullPath); + if (File.Exists(metaPath)) + return; // Already has .gmeta (maybe copied together) + + var attr = handler.GetType().GetCustomAttribute()!; + var meta = new AssetMeta + { + Guid = Guid.NewGuid(), + HandlerTypeId = new Guid(attr.ID), + HandlerVersion = 0, + Settings = handler.CreateDefaultSettings(), // new method on IAssetHandler + }; + + // Write .gmeta (suppress FSW for this write) + _ignoreMetaWrites[metaPath] = true; + AssetMetaIO.WriteAsync(metaPath, meta).AsTask().Wait(); + + // Register in catalog + _catalog.Upsert(meta, relativePath); + + // Queue import + _importCoordinator.EnqueueAsync(new ImportJob( + meta.Guid, relativePath, metaPath, ImportReason.NewAsset + )).AsTask(); +} +``` + +--- + +## 9. Dependency Graph + +### 9.1 Storage + +Dependencies are stored in three places (all kept in sync): +1. **`.gmeta`** — `Dependencies: Guid[]` — source of truth, checked into git +2. **SQLite `dependencies` table** — fast queries, regenerable from `.gmeta` +3. **In-memory** — not needed anymore (SQLite is fast enough for editor use) + +The current in-memory `_referencerGraph` / `_dependencyCache` dictionaries ([AssetRegistry.cs:48-49](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L48-L49)) are replaced by SQLite queries. SQLite with WAL mode handles concurrent reads efficiently. + +### 9.2 Invalidation cascade + +When asset A changes, all assets that depend on A may need re-import: + +```csharp +public void InvalidateTransitive(Guid changedAssetGuid) +{ + var visited = new HashSet(); + var queue = new Queue(); + queue.Enqueue(changedAssetGuid); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!visited.Add(current)) + continue; + + _catalog.MarkDirty(current); + + foreach (var referencer in _catalog.GetReferencers(current)) + { + queue.Enqueue(referencer); + } + } + + // Re-queue all dirty assets (except the original, which is already being imported) + foreach (var guid in visited) + { + if (guid != changedAssetGuid) + { + var sourcePath = _catalog.GetSourcePath(guid); + if (sourcePath is not null) + { + var metaPath = AssetMetaIO.GetMetaPath(Path.Combine(_assetsRoot, sourcePath)); + _importCoordinator.EnqueueAsync(new ImportJob(guid, sourcePath, metaPath, ImportReason.SourceChanged)).AsTask(); + } + } + } +} +``` + +--- + +## 10. Cooked Binary Format + +The `.imported` files in `Library/Imports/` are the cooked binary format. This is **the only place we use binary serialization** — settings are in `.gmeta` (JSON), only the processed content blob is binary. + +### 10.1 File structure + +We keep the `AssetMetadata` header concept ([Asset.cs:43-117](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs#L43-L117)) but simplify it — settings and dependencies are no longer embedded: + +```csharp +/// +/// Header for .imported files in Library/Imports/. +/// Simplified from the original AssetMetadata — no settings/dependencies +/// (those live in .gmeta and SQLite). +/// +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +internal struct ImportedHeader +{ + public const int CURRENT_FORMAT_VERSION = 1; + public const int SIZE = 64; // enough room for future fields + + public int FormatVersion { get; set; } + public Guid AssetGuid { get; set; } + public Guid HandlerTypeId { get; set; } + public int HandlerVersion { get; set; } + public long ContentSize { get; set; } + + // Reserved padding (fills to SIZE bytes) +} +``` + +After the header, the handler writes whatever binary content it needs. For textures, this might be: + +``` +Bytes 0-63: ImportedHeader +Bytes 64+: ImageContentHeader (width, height, depth, channels) + + raw pixel data (or DDS reference path) +``` + +### 10.2 Why keep the binary header at all? + +Validation on load: +- `FormatVersion` → detect old/corrupt files, trigger re-import +- `HandlerTypeId` → resolve the correct `IAssetHandler` without touching the catalog +- `HandlerVersion` → detect when handler has been upgraded, trigger re-import + +If the catalog is out of sync (e.g., user copied Library/ from another machine), the header acts as a sanity check. + +--- + +## 11. AssetRegistry Class Redesign + +### 11.1 New structure (partial class split) + +``` +Services/ +├── AssetRegistry.cs ← public API surface (IAssetRegistry impl) +├── AssetRegistry.Startup.cs ← initialization, catalog sync, first-time setup +├── AssetRegistry.Watcher.cs ← FileSystemWatcher event handling +└── AssetRegistry.Backend.cs ← AssetCatalog class (SQLite wrapper) +``` + +### 11.2 Top-level class + +```csharp +namespace Ghost.Editor.Core.Services; + +/// +/// Central asset registry for the GhostEngine editor. +/// +/// Owns the lifecycle of: +/// - AssetCatalog (SQLite GUID↔path mapping + dependency graph) +/// - AssetHandlerRegistry (O(1) handler lookup by extension/typeId) +/// - ImportCoordinator (background import workers + Channel-based queue) +/// - FileSystemWatcher (Assets/ directory monitoring) +/// +/// Thread safety: see each partial file for details. +/// The public API is safe to call from any thread — the registry +/// marshals events to the UI thread via DispatcherQueue. +/// +[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, + ImplementationType = typeof(AssetRegistry))] +internal sealed partial class AssetRegistry : IAssetRegistry +{ + private readonly string _assetsRoot; + private readonly AssetCatalog _catalog; + private readonly AssetHandlerRegistry _handlerRegistry; + private readonly ImportCoordinator _importCoordinator; + private readonly FileSystemWatcher _watcher; + + // WeakReference cache — kept from current design, it works well + private readonly ConcurrentDictionary> _loadedAssets; + private readonly SemaphoreSlim _loadLock; + + public event EventHandler? OnAssetChanged; + + // ─── IAssetRegistry implementation ───────────────────── + + public string? GetAssetPath(Guid id) + => _catalog.GetSourcePath(id); + + public Guid GetAssetGuid(string path) + => _catalog.GetGuid(path); + + public async ValueTask> ImportAssetAsync( + string sourceFilePath, string targetAssetPath, CancellationToken token = default) + { + // 1. Copy source file to Assets/ if not already there + // 2. Generate .gmeta with new GUID + // 3. Upsert to catalog + // 4. Enqueue import job + // 5. Return the new GUID immediately (import happens in background) + } + + public ValueTask ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default) + { + // Enqueue a ManualReimport job + } + + public async ValueTask> LoadAssetAsync(Guid id, CancellationToken token = default) + { + // Same double-check WeakReference pattern as current code + // But reads from Library/Imports/.imported + } + + public async ValueTask SaveAssetAsync(Asset asset, CancellationToken token = default) + { + // For engine-native assets: write to .gasset + update .gmeta + } + + public void Dispose() + { + _watcher.Dispose(); + _importCoordinator.Dispose(); + _catalog.Dispose(); + _loadLock.Dispose(); + } +} +``` + +### 11.3 Startup sequence (`AssetRegistry.Startup.cs`) + +```csharp +internal sealed partial class AssetRegistry +{ + public AssetRegistry(string assetsRoot) + { + _assetsRoot = assetsRoot; + + // 1. Open or create catalog + var dbPath = Path.Combine(EditorApplication.LibraryFolderPath, "AssetDB.sqlite"); + _catalog = new AssetCatalog(dbPath); + + // 2. Build handler registry (one-time scan) + _handlerRegistry = new AssetHandlerRegistry(); + + // 3. Sync catalog with filesystem + SyncCatalogWithDisk(); + + // 4. Start import coordinator + _importCoordinator = new ImportCoordinator(_catalog, _handlerRegistry, _assetsRoot); + _importCoordinator.OnAssetChanged += (_, args) => OnAssetChanged?.Invoke(this, args); + + // 5. Queue pending imports + _importCoordinator.EnqueueDirtyAssetsAsync().AsTask().Wait(); + + // 6. Start watching for changes + _loadedAssets = new ConcurrentDictionary>(); + _loadLock = new SemaphoreSlim(1, 1); + SetupWatcher(); + } + + /// + /// Walk Assets/ directory, diff against SQLite catalog, and reconcile. + /// + /// For each .gmeta file found on disk: + /// - If not in catalog → insert (new asset) + /// - If in catalog with different path → update (file was moved) + /// + /// For each entry in catalog: + /// - If .gmeta no longer on disk → delete (asset was removed externally) + /// + /// This is O(N) where N = number of assets. Runs once at startup. + /// + private void SyncCatalogWithDisk() + { + var onDisk = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 1. Scan all .gmeta files + foreach (var metaFile in Directory.EnumerateFiles(_assetsRoot, "*.gmeta", SearchOption.AllDirectories)) + { + var metaRelative = Path.GetRelativePath(_assetsRoot, metaFile); + var sourceRelative = AssetMetaIO.GetSourcePath(metaRelative); + onDisk.Add(sourceRelative); + + var meta = AssetMetaIO.ReadAsync(metaFile).AsTask().Result; + if (meta is null) + continue; + + var existingPath = _catalog.GetSourcePath(meta.Guid); + if (existingPath is null) + { + // New asset — insert + _catalog.Upsert(meta, sourceRelative); + } + else if (!string.Equals(existingPath, sourceRelative, StringComparison.OrdinalIgnoreCase)) + { + // Asset was moved/renamed — update path + _catalog.Upsert(meta, sourceRelative); + } + } + + // 2. Remove catalog entries whose .gmeta no longer exists + foreach (var (guid, catalogPath) in _catalog.EnumerateAll()) + { + if (!onDisk.Contains(catalogPath)) + { + _catalog.Remove(guid); + } + } + } +} +``` + +> [!IMPORTANT] +> **Startup is now O(N) filesystem enumeration + O(N) SQLite upserts**, which is much faster than the current approach of opening every `.gasset` file and reading 20 bytes from each ([AssetRegistry.cs:99-134](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L99-L134)). For a project with 10,000 assets, this goes from 10,000 file opens → ~10,000 tiny `.gmeta` reads (or even better — just stat + compare timestamps). + +--- + +## 12. Handler API Redesign + +### 12.1 Updated interfaces + +```csharp +namespace Ghost.Editor.Core.AssetHandler; + +public interface IAssetHandler +{ + /// + /// Load a cooked asset from a Library/Imports/ stream. + /// The stream is positioned at the start of handler-specific content + /// (past the ImportedHeader). + /// + ValueTask> LoadAsync(Stream importedStream, IAssetRegistry registry, CancellationToken token = default); + + /// + /// Save an engine-native asset (materials, prefabs, etc). + /// For imported assets (textures, meshes), this is typically not called. + /// + ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry registry, CancellationToken token = default); + + /// + /// Create default import settings for a new asset of this type. + /// Called when auto-generating .gmeta for a newly dropped file. + /// Return null if this handler has no configurable settings. + /// + IAssetSettings? CreateDefaultSettings() => null; +} + +public interface IImportableAssetHandler : IAssetHandler +{ + /// + /// Import a source file into a cooked binary format. + /// + /// sourceStream: the raw source file (PNG, FBX, etc.) + /// targetStream: where to write the cooked binary (Library/Imports/.imported) + /// id: the asset GUID + /// settings: import settings from .gmeta (previously deserialized from JSON) + /// + ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, + IAssetSettings? settings, CancellationToken token = default); + + ValueTask ExportAsync(Stream importedStream, Stream targetStream, + IAssetExportOptions? options, CancellationToken token = default); +} +``` + +Note the key change: `ImportAsync` now receives `IAssetSettings? settings` as a parameter. The handler no longer reads/writes settings from the binary stream — settings live in `.gmeta`. + +### 12.2 TextureAssetHandler changes + +```csharp +[CustomAssetHandler(ID = TextureAsset._TYPE_ID, + SupportedExtensions = [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"])] +internal class TextureAssetHandler : IImportableAssetHandler +{ + private const int _CURRENT_VERSION = 1; + + public IAssetSettings? CreateDefaultSettings() => new TextureAssetSettings(); + + public async ValueTask ImportAsync( + Stream sourceStream, Stream targetStream, Guid id, + IAssetSettings? settings, CancellationToken token = default) + { + var texSettings = settings as TextureAssetSettings ?? new TextureAssetSettings(); + + using var image = new MagickImage(sourceStream); + var bytes = image.ToByteArray(); + + // Kick off NVTT compression in background (this part stays the same) + await TextureProcessor.CompressToCacheAsync( + EditorApplication.CachesFolderPath, id, bytes, + image.Width, image.Height, image.Depth, texSettings, token); + + // Write ImportedHeader + var header = new ImportedHeader + { + FormatVersion = ImportedHeader.CURRENT_FORMAT_VERSION, + AssetGuid = id, + HandlerTypeId = TextureAsset.s_typeGuid, + HandlerVersion = _CURRENT_VERSION, + }; + + // Write content (ImageContentHeader + raw pixels) + var contentHeader = new ImageContentHeader { ... }; + + // ... (similar to current code but without settings in the binary) + } + + public async ValueTask> LoadAsync( + Stream importedStream, IAssetRegistry registry, CancellationToken token = default) + { + // Read ImportedHeader (validation) + var header = ImportedHeader.ReadFromStream(importedStream); + + // Read image content + var contentHeader = /* read ImageContentHeader */; + + // Create GPU texture handle + // ... + + return new TextureAsset(header.AssetGuid, [], null, texture); + } +} +``` + +### 12.3 TextureAssetSettings no longer needs struct layout + +Since settings are now serialized as JSON (not raw bytes), we can make `TextureAssetSettings` a normal class with proper properties: + +```csharp +// Before (fragile — binary struct layout) +public struct BasicSettings() +{ + public TextureType TextureType { get; set; } = TextureType.Default; + // ... adding a field here breaks all existing .gasset files +} + +// After (resilient — JSON serialization) +public sealed class BasicSettings +{ + public TextureType TextureType { get; set; } = TextureType.Default; + // ... adding a field is safe — old .gmeta files without it get the default +} +``` + +The `TextureProcessor` still needs `Unsafe.SizeOf` for hashing — but that's only for the cache key hash, not for persistence. We can replace that hash computation with JSON-based hashing (or keep the struct hash as an internal optimization detail). + +--- + +## 13. Migration Path from Current Code + +If you have existing `.gasset` files, we need a migration tool. This runs once: + +```csharp +internal static class AssetMigration +{ + /// + /// Read old .gasset files and produce .gmeta + Library/Imports/ equivalents. + /// + public static async Task MigrateFromGassetAsync(string assetsRoot, AssetCatalog catalog) + { + foreach (var gassetPath in Directory.EnumerateFiles(assetsRoot, "*.gasset", SearchOption.AllDirectories)) + { + await using var fs = new FileStream(gassetPath, FileMode.Open, FileAccess.Read); + var oldMeta = AssetMetadata.ReadFromStream(fs); + + // Create .gmeta + var meta = new AssetMeta + { + Guid = oldMeta.ID, + HandlerTypeId = oldMeta.TypeID, + HandlerVersion = oldMeta.HandlerVersion, + Dependencies = ReadDependencies(fs, oldMeta), + Settings = ReadSettings(fs, oldMeta), // deserialize from binary + }; + + var metaPath = gassetPath + ".gmeta"; + await AssetMetaIO.WriteAsync(metaPath, meta); + + // Copy content blob to Library/Imports/ + var importedPath = Path.Combine( + EditorApplication.LibraryFolderPath, "Imports", $"{oldMeta.ID:N}.imported"); + // ... extract content section from .gasset and write to .imported + + // Register in catalog + var relativePath = Path.GetRelativePath(assetsRoot, gassetPath); + catalog.Upsert(meta, relativePath); + } + } +} +``` + +--- + +## 14. Thread Safety Model + +| Component | Thread Model | Mechanism | +|---|---|---| +| `AssetCatalog` (SQLite) | Single writer, multiple readers | `Lock` on writes; SQLite WAL handles concurrent reads | +| `AssetHandlerRegistry` | Read-only after construction | Immutable dictionaries, no sync needed | +| `ImportCoordinator` | Multiple worker tasks read from `Channel` | Channel provides thread-safe MPSC semantics | +| `_loadedAssets` cache | Concurrent reads, rare writes | `ConcurrentDictionary` + `SemaphoreSlim` for double-check (same as current) | +| `FileSystemWatcher` callbacks | Runs on thread pool threads | Non-blocking — just writes to channel | +| `OnAssetChanged` events | Marshalled to UI thread | `DispatcherQueue.TryEnqueue` | +| `.gmeta` file I/O | One writer at a time per file | Atomic write via temp-file + rename | + +--- + +## 15. Error Handling Strategy + +Following GhostEngine conventions ([AGENTS.md](file:///f:/csharp/GhostEngine/AGENTS.md)): + +| Situation | Approach | +|---|---| +| Source file not found | `Result.Failure(Error.NotFound)` | +| No handler for extension | `Result.Failure("No handler for .xyz")` | +| Import fails (NVTT crash, corrupt file) | `Logger.Error(...)` + `catalog.MarkFailed(guid, msg)` — asset shows red in editor browser | +| `.gmeta` parse error | `Logger.Warning(...)` + regenerate with defaults | +| SQLite error | Throw — programming error, should not happen | +| `.imported` file corrupt/missing | Mark dirty in catalog → re-import on next startup | + +--- + +## 16. Open Decisions & Trade-offs + +### 16.1 What to do with engine-native assets (materials, scenes)? + +Engine-native assets like materials don't have a "source file" separate from the engine format. Options: + +- **Option A**: The `.gasset` file IS the source. `.gmeta` sits beside it. No `.imported` file needed. +- **Option B**: Store as JSON (like `.gmeta` but with content), drop binary entirely for these types. + +I lean toward **Option A** — keep `.gasset` for engine-native assets only, use `.gmeta` + `.imported` for imported assets. + +### 16.2 Should `ImportAssetAsync` return immediately or wait for import? + +Current design returns after import completes. New design enqueues and returns GUID immediately. Which do you prefer? + +We could offer both: +```csharp +// Fire-and-forget (for drag-and-drop, batch imports) +ValueTask> ImportAssetAsync(...); + +// Wait for completion (for scripted imports, tests) +ValueTask> ImportAssetAndWaitAsync(...); +``` + +### 16.3 Hot-reload after re-import + +When an asset is re-imported, should live `Asset` objects auto-refresh? Your current code already has `Asset.RefreshAsync` ([Asset.cs:36-39](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs#L36-L39)) and the `WeakReference` lookup in `ReimportAssetAsync` ([AssetRegistry.cs:406-409](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L406-L409)). We should keep this behavior — after import completes, check `_loadedAssets` for a live reference and call `RefreshAsync`. + +### 16.4 Library/ on first clone + +When someone clones the repo, `Library/` doesn't exist. Options: +1. **Full re-import on first open** — slowest but simplest +2. **Pre-built Library in git-lfs** — fast startup but large repo +3. **Asset server / CDN** — best UX but complex to build + +I suggest starting with **(1)** — it's correct by construction. Optimize later with (3) if import times become painful. + +--- + +## File Summary: What Gets Created / Modified / Deleted + +### New Files + +| File | Purpose | +|---|---| +| `AssetHandler/AssetMeta.cs` | `AssetMeta` data model + `AssetMetaIO` read/write | +| `AssetHandler/AssetHandlerRegistry.cs` | One-time handler scan, O(1) lookups | +| `Services/AssetCatalog.cs` | SQLite wrapper (replaces `AssetRegistry.Backend.cs` placeholder) | +| `Services/ImportCoordinator.cs` | Channel-based import queue + worker pool | + +### Modified Files + +| File | Changes | +|---|---| +| `Services/AssetRegistry.cs` | Rewritten — delegates to AssetCatalog + ImportCoordinator | +| `Services/AssetRegistry.Backend.cs` | Deleted or merged into `AssetCatalog.cs` | +| `AssetHandler/Asset.cs` | `AssetMetadata` simplified → `ImportedHeader` (no settings/deps in binary) | +| `AssetHandler/AssetHandler.cs` | `IImportableAssetHandler.ImportAsync` gains `IAssetSettings?` param | +| `AssetHandler/TextureAsset.cs` | Handler updated for new API; settings → class instead of struct | +| `Contracts/IAssetRegistry.cs` | Minor — add `IAssetSettings? GetSettings(Guid)` if needed by inspector | +| `EditorApplication.cs` | Add `LIBRARY_FOLDER_NAME` and `LibraryFolderPath` | +| `Attributes.cs` | Unify `AssetImporterAttribute` with `CustomAssetHandlerAttribute` | + +### Deleted Files + +| File | Reason | +|---|---| +| `Utilities/AssetHandlerUtility.cs` | Superseded by handler API changes | diff --git a/docs/notes/meshlet-architecture.md b/docs/specs/meshlet-architecture.md similarity index 100% rename from docs/notes/meshlet-architecture.md rename to docs/specs/meshlet-architecture.md diff --git a/docs/notes/shader_pipeline_architecture.md b/docs/specs/shader_pipeline_architecture.md similarity index 100% rename from docs/notes/shader_pipeline_architecture.md rename to docs/specs/shader_pipeline_architecture.md diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs deleted file mode 100644 index f5d4917..0000000 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Ghost.Editor.Core.Contracts; - -namespace Ghost.Editor.Core.AssetHandler; - -public abstract class Asset -{ - public Guid ID - { - get; - } - - public abstract Guid TypeID - { - get; - } - - protected Asset(Guid id) - { - ID = id; - } - - public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default) - { - return ValueTask.CompletedTask; - } -} - -public readonly struct AssetReference : IEquatable -{ - private readonly int _value; - - /// - /// The index of the asset in the dependency list. - /// - public int Index - { - get => Math.Abs(_value) - 1; - } - - public static AssetReference Null => default; - - public readonly bool IsInternal => _value >= 0; - public readonly bool IsExternal => _value < 0; - - public bool Equals(AssetReference other) - { - return _value == other._value; - } - - public override int GetHashCode() - { - return _value.GetHashCode(); - } - - public override bool Equals(object? obj) - { - return obj is AssetReference reference && Equals(reference); - } - - public static bool operator ==(AssetReference left, AssetReference right) - { - return left.Equals(right); - } - - public static bool operator !=(AssetReference left, AssetReference right) - { - return !(left == right); - } -} diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs index 4344af6..07dc99b 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs @@ -1,5 +1,4 @@ using Ghost.Core; -using Ghost.Editor.Core.Contracts; namespace Ghost.Editor.Core.AssetHandler; @@ -15,40 +14,9 @@ public interface IAssetExportOptions; public interface IAssetHandler { - ValueTask> LoadAsync(Stream sourceStream, Guid id, IAssetRegistry assetRegistry, CancellationToken token = default); - ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default); -} + bool CanExport => false; -public interface IImportableAssetHandler : IAssetHandler -{ IAssetSettings? CreateDefaultSettings(); ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default); -} - -public interface IExportableAssetHandler : IAssetHandler -{ ValueTask ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default); -} - -public static class AssetHandlerExtensions -{ - public static async ValueTask ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, IAssetSettings? settings = null, CancellationToken token = default) - { - await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); - await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None); - return await handler.ImportAsync(sourceStream, targetStream, id, settings, token); - } - - public static async ValueTask ExportAsync(this IExportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default) - { - await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); - await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None); - return await handler.ExportAsync(assetStream, targetStream, options, token); - } - - public static async ValueTask> LoadAsync(this IAssetHandler handler, string assetFilePath, Guid id, IAssetRegistry assetDatabase, CancellationToken token = default) - { - await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); - return await handler.LoadAsync(sourceStream, id, assetDatabase, token); - } -} +} \ No newline at end of file diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetProcesser.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetProcesser.cs index cee776e..5d1dc1c 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetProcesser.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetProcesser.cs @@ -1,4 +1,5 @@ using Ghost.Editor.Core.Contracts; +using Ghost.Engine.AssetLoader; namespace Ghost.Editor.Core.AssetHandler; diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAssetHandler.cs similarity index 52% rename from src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs rename to src/Editor/Ghost.Editor.Core/AssetHandler/TextureAssetHandler.cs index d461289..978d375 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAssetHandler.cs @@ -1,5 +1,5 @@ using Ghost.Core; -using Ghost.Editor.Core.Contracts; +using Ghost.Engine.AssetLoader; using Ghost.Graphics.RHI; using ImageMagick; using System.Runtime.InteropServices; @@ -46,55 +46,6 @@ public enum MipmapFilter : uint MitchellNetravali } -public class TextureAsset : Asset -{ - internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F"; - internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID); - - private readonly byte[] _textureData; - private readonly uint _width; - private readonly uint _height; - private readonly uint _depth; - private readonly uint _colorComponents; - - public override Guid TypeID => s_typeGuid; - - /// - /// Gets the raw texture data in a compressed format. - /// - public ReadOnlyMemory TextureData => _textureData; - - /// - /// Gets the width of the texture in pixels. - /// - public uint Width => _width; - - /// - /// Gets the height of the texture in pixels. - /// - public uint Height => _height; - - /// - /// Gets the bit depth of the texture. - /// - public uint Depth => _depth; - - /// - /// Gets the number of color components in the texture. - /// - public uint ColorComponents => _colorComponents; - - internal TextureAsset(byte[] data, ImageContentHeader header, Guid id) - : base(id) - { - _textureData = data; - _width = header.width; - _height = header.height; - _depth = header.depth; - _colorComponents = header.colorComponents; - } -} - public class TextureAssetSettings : IAssetSettings { public struct BasicSettings() @@ -242,65 +193,14 @@ public class TextureAssetSettings : IAssetSettings } = new SamplerSettings(); } -[StructLayout(LayoutKind.Sequential, Size = 64)] // Leave extra space for future expansion without breaking compatibility -internal struct ImageContentHeader -{ - public uint width; - public uint height; - public uint depth; - public uint colorComponents; -} - -[CustomAssetHandler(TextureAsset._TYPE_ID, [ ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" ], 1)] -internal class TextureAssetHandler : IImportableAssetHandler +[CustomAssetHandler(TextureAsset.TYPE_ID, [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"], 1)] +internal class TextureAssetHandler : IAssetHandler { public IAssetSettings? CreateDefaultSettings() { return new TextureAssetSettings(); } - public async ValueTask> LoadAsync(Stream sourceStream, Guid id, IAssetRegistry assetRegistry, CancellationToken token = default) - { - try - { - // FIX: Should the sourceStream be the stream of the imported file or the raw asset file? - // Or should we change our paramemters to inlcude more information and let each handler decide how to load the asset? - // The problem of a single sourceStream is, for example, for texture assets, we don't even need to read the ".png" file at all, - // but for some other asset types, we may don't even have imported intermediate files at all. - - // var path = assetRegistry.GetAssetPath(id); - // if (string.IsNullOrEmpty(path)) - // { - // return Result.Failure("Asset path not found in registry."); - // } - // - // var metadataPath = AssetMetaIO.GetMetaPath(path); - // var meta = await AssetMetaIO.ReadAsync(metadataPath, token).ConfigureAwait(false); - // Logger.DebugAssert(meta != null, $"Missing or invalid metadata for asset at {path}"); - - - - var header = new ImageContentHeader(); - sourceStream.ReadExactly(MemoryMarshal.AsBytes(new Span(ref header))); - - var imageDataSize = (int)(sourceStream.Length - sourceStream.Position); - var imageData = new byte[imageDataSize]; - await sourceStream.ReadExactlyAsync(imageData, token).ConfigureAwait(false); - - var asset = new TextureAsset(imageData, header, id); - return asset; - } - catch (Exception ex) - { - return Result.Failure($"Failed to load texture asset: {ex.Message}"); - } - } - - public ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default) - { - throw new NotImplementedException(); - } - public async ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default) { try @@ -309,20 +209,22 @@ internal class TextureAssetHandler : IImportableAssetHandler using var image = new MagickImage(sourceStream); var bytes = image.ToByteArray(); - await TextureProcessor.CompressToCacheAsync(EditorApplication.LibraryFolderPath, id, bytes, image.Width, image.Height, image.Depth, textureSettings, token) + var (path, mip) = await TextureProcessor.CompressToCacheAsync(EditorApplication.ImportsFolderPath, id, bytes, image.Width, image.Height, image.Depth, textureSettings, token) .ConfigureAwait(false); targetStream.Seek(0, SeekOrigin.Begin); - var contentHeader = new ImageContentHeader + var contentHeader = new TextureContentHeader { width = image.Width, height = image.Height, depth = image.Depth, - colorComponents = image.ChannelCount + colorComponents = image.ChannelCount, + mipLevels = (uint)mip, + dimension = (int)TextureDimension.Texture2D // TODO: Implement dimension calculation }; - targetStream.Write(MemoryMarshal.AsBytes(new Span(ref contentHeader))); + targetStream.Write(MemoryMarshal.AsBytes(new Span(ref contentHeader))); await targetStream.WriteAsync(bytes, token).ConfigureAwait(false); await targetStream.FlushAsync(token).ConfigureAwait(false); @@ -334,4 +236,9 @@ internal class TextureAssetHandler : IImportableAssetHandler return Result.Failure($"Failed to import texture asset: {ex.Message}"); } } + + public ValueTask ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default) + { + return ValueTask.FromResult(Result.Failure("Exporting texture assets is not supported yet.")); + } } diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs index 8e336dd..8f03454 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs @@ -33,6 +33,8 @@ internal static class TextureProcessor private readonly TextureAssetSettings _settings; private readonly TaskCompletionSource _completionSource; + public int mipmapCount; + public Task Task => _completionSource.Task; public NvttPipelineTask(string outputPath, byte[] image, uint width, uint height, uint depth, TextureAssetSettings settings) @@ -119,7 +121,6 @@ internal static class TextureProcessor var nvttFilter = SelectMipmapFilter(_settings.Advanced.MipmapFilter); - int mipmapCount; if (!_settings.Advanced.GenerateMipmaps) { mipmapCount = 1; @@ -162,23 +163,19 @@ internal static class TextureProcessor } } - private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache"; - - public static async ValueTask CompressToCacheAsync(string cachesFolderPath, Guid assetId, byte[] image, uint width, uint height, uint depth, TextureAssetSettings settings, CancellationToken cancellationToken) + public static async ValueTask<(string cachePath, int mipmapCount)> CompressToCacheAsync(string cachesFolderPath, Guid assetId, byte[] image, uint width, uint height, uint depth, TextureAssetSettings settings, CancellationToken cancellationToken) { - var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER); - Directory.CreateDirectory(cacheDir); - var settingsHash = ComputeSettingsHash(settings); - var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds"; - var cachePath = Path.Combine(cacheDir, cacheFileName); + var cacheFileName = $"texturecache_{assetId:N}_{settingsHash:X16}.dds"; + var cachePath = Path.Combine(cachesFolderPath, cacheFileName); if (File.Exists(cachePath)) { - return cachePath; + // TODO: Implement mipmap count retrieval from existing cache file + return (cachePath, 0); } - foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds")) + foreach (var stale in Directory.EnumerateFiles(cachesFolderPath, $"texturecache_{assetId:N}_*.dds")) { File.Delete(stale); } @@ -187,7 +184,7 @@ internal static class TextureProcessor ThreadPool.UnsafeQueueUserWorkItem(workItem, true); await workItem.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - return cachePath; + return (cachePath, workItem.mipmapCount); } private static NvttFormat SelectFormat(TextureAssetSettings settings) diff --git a/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs index 3f90c2b..5348bf9 100644 --- a/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs +++ b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs @@ -1,6 +1,6 @@ using Ghost.Core; -using Ghost.Editor.Core.AssetHandler; using Ghost.Editor.Core.Services; +using Ghost.Engine.AssetLoader; namespace Ghost.Editor.Core.Contracts; diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs index 55d8902..d1eda5e 100644 --- a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs +++ b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; -using System.Reflection; using Ghost.Core; using Ghost.Editor.Core.AssetHandler; using Ghost.Editor.Core.Contracts; +using Ghost.Engine.AssetLoader; +using System.Collections.Concurrent; namespace Ghost.Editor.Core.Services; @@ -157,7 +157,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable var ext = Path.GetExtension(relativePath); var handler = _handlerRegistry.GetByExtension(ext); - var importable = handler as IImportableAssetHandler; + var importable = handler as IAssetHandler; var metaPath = AssetMetaIO.GetMetaPath(fullPath); if (File.Exists(metaPath)) @@ -169,7 +169,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable var meta = new AssetMeta { Guid = Guid.NewGuid(), - HandlerTypeId = handlerTypeId is string str? Guid.Parse(str) : null, + HandlerTypeId = handlerTypeId is string str ? Guid.Parse(str) : null, HandlerVersion = 1, Settings = importable?.CreateDefaultSettings() }; diff --git a/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs index 8e4e832..00d95a3 100644 --- a/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs +++ b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs @@ -1,8 +1,8 @@ -using System.Threading.Channels; using Ghost.Core; using Ghost.Editor.Core.AssetHandler; using System.Security.Cryptography; using System.Text.Json; +using System.Threading.Channels; namespace Ghost.Editor.Core.Services; @@ -115,7 +115,7 @@ internal sealed class ImportCoordinator : IDisposable } var importResult = Result.Success(); - if (handler is IImportableAssetHandler importable) + if (handler is IAssetHandler importable) { // TODO: This should be handled by EditorApplication. var importsDir = Path.Combine(_libraryRoot, "Imports"); diff --git a/src/Runtime/Ghost.Core/Ghost.Core.csproj b/src/Runtime/Ghost.Core/Ghost.Core.csproj index fc32708..c2ca5a6 100644 --- a/src/Runtime/Ghost.Core/Ghost.Core.csproj +++ b/src/Runtime/Ghost.Core/Ghost.Core.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Runtime/Ghost.Core/Result.cs b/src/Runtime/Ghost.Core/Result.cs index 8dd9b61..1508ed5 100644 --- a/src/Runtime/Ghost.Core/Result.cs +++ b/src/Runtime/Ghost.Core/Result.cs @@ -1,9 +1,25 @@ using Misaki.HighPerformance.LowLevel; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; namespace Ghost.Core; +public enum Error +{ + None, + NotFound, + InvalidArgument, + InvalidState, + InternalError, + PermissionDenied, + NotSupported, + OutOfMemory, + Timeout, + Cancelled, + UnknownError, + + Success = None, +} + public readonly struct Result { private readonly string? _message; @@ -126,25 +142,8 @@ public readonly struct Result public static implicit operator bool(Result result) => result.IsSuccess; } -public enum Error : byte -{ - None, - NotFound, - InvalidArgument, - InvalidState, - InternalError, - PermissionDenied, - NotSupported, - OutOfMemory, - Timeout, - Cancelled, - UnknownError, - - Success = None, -} - public readonly struct Result - where E : struct, Enum + where E : struct { private readonly T _value; private readonly E _error; @@ -203,7 +202,7 @@ public readonly struct Result } public readonly ref struct RefResult - where E : struct, Enum + where E : struct { private readonly ref T _value; private readonly E _error; @@ -261,70 +260,30 @@ public readonly ref struct RefResult public static implicit operator bool(RefResult result) => result.IsSuccess; } -[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder))] -public readonly struct ResultTask -{ - private readonly ValueTask _task; - - public ResultTask(ValueTask task) - { - _task = task; - } - - public ValueTaskAwaiter GetAwaiter() => _task.GetAwaiter(); - - public ValueTask AsValueTask() => _task; - public Task AsTask() => _task.AsTask(); - - public static implicit operator ResultTask(ValueTask task) => new ResultTask(task); - public static implicit operator ValueTask(ResultTask resultTask) => resultTask._task; -} - -[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder))] -public readonly struct ResultTask -{ - private readonly ValueTask> _task; - - public ResultTask(ValueTask> task) - { - _task = task; - } - - public ValueTaskAwaiter> GetAwaiter() => _task.GetAwaiter(); - - public ValueTask> AsValueTask() => _task; - public Task> AsTask() => _task.AsTask(); - - public static implicit operator ResultTask(ValueTask> task) => new ResultTask(task); - public static implicit operator ValueTask>(ResultTask resultTask) => resultTask._task; -} - -[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder))] -public readonly struct ResultTask - where E : struct, Enum -{ - private readonly ValueTask> _task; - - public ResultTask(ValueTask> task) - { - _task = task; - } - - public ValueTaskAwaiter> GetAwaiter() => _task.GetAwaiter(); - - public ValueTask> AsValueTask() => _task; - public Task> AsTask() => _task.AsTask(); - - public static implicit operator ResultTask(ValueTask> task) => new ResultTask(task); - public static implicit operator ValueTask>(ResultTask resultTask) => resultTask._task; -} - public static class ResultExtensions { extension(Error error) { public bool IsSuccess => error == Error.None; public bool IsFailure => error != Error.None; + + public static Error FromHResult(int hr) + { + return hr switch + { + 0 => Error.None, + unchecked((int)0x80070002) => Error.NotFound, + unchecked((int)0x80070057) => Error.InvalidArgument, + unchecked((int)0x8007139F) => Error.InvalidState, + unchecked((int)0x80004005) => Error.InternalError, + unchecked((int)0x80070005) => Error.PermissionDenied, + unchecked((int)0x80004001) => Error.NotSupported, + unchecked((int)0x8007000E) => Error.OutOfMemory, + unchecked((int)0x800705B4) => Error.Timeout, + unchecked((int)0x800704C7) => Error.Cancelled, + _ => Error.UnknownError + }; + } } public static void ThrowIfFailed(this Error result, [CallerArgumentExpression(nameof(result))] string? op = null) @@ -354,7 +313,7 @@ public static class ResultExtensions } public static T GetValueOrThrow(this Result result, [CallerArgumentExpression(nameof(result))] string? op = null) - where E : struct, Enum + where E : struct { if (!result.IsSuccess) { @@ -365,7 +324,7 @@ public static class ResultExtensions } public static ref T GetValueOrThrow(this RefResult result, [CallerArgumentExpression(nameof(result))] string? op = null) - where E : struct, Enum + where E : struct { if (!result.IsSuccess) { @@ -381,13 +340,13 @@ public static class ResultExtensions } public static T? GetValueOrDefault(this Result result, T? defaultValue = default) - where E : struct, Enum + where E : struct { return result.IsSuccess ? result.Value : defaultValue; } public static ref T GetValueOrDefault(this RefResult result) - where E : struct, Enum + where E : struct { return ref result.IsSuccess ? ref result.Value : ref Unsafe.NullRef(); } @@ -438,7 +397,7 @@ public static class ResultExtensions } public static Result OnSuccess(this Result result, Action action) - where E : struct, Enum + where E : struct { if (result.IsSuccess) { @@ -469,7 +428,7 @@ public static class ResultExtensions } public static Result OnFailed(this Result result, Action action) - where E : struct, Enum + where E : struct { if (result.IsFailure) { @@ -500,7 +459,7 @@ public static class ResultExtensions } public static Result Then(this Result result, Func> func) - where E : struct, Enum + where E : struct { if (result.IsFailure) { @@ -535,7 +494,7 @@ public static class ResultExtensions } public static U Match(this Result result, Func onSuccess, Func onFailure) - where E : struct, Enum + where E : struct { if (result.IsSuccess) { diff --git a/src/Runtime/Ghost.Core/Utilities/BufferWriter.cs b/src/Runtime/Ghost.Core/Utilities/BufferWriter.cs index c38ac81..c48ec12 100644 --- a/src/Runtime/Ghost.Core/Utilities/BufferWriter.cs +++ b/src/Runtime/Ghost.Core/Utilities/BufferWriter.cs @@ -98,6 +98,58 @@ public unsafe ref struct SpanWriter } } +public unsafe struct BufferReader +{ + private readonly byte* _buffer; + private readonly nuint _size; + + private byte* _position; + + public readonly byte* Position => _position; + + public nuint Offset + { + readonly get => (nuint)(_buffer + (_position - _buffer)); + set => _position = _buffer + value; + } + + public BufferReader(byte* buffer, nuint size) + { + _buffer = buffer; + _size = size; + _position = _buffer; + } + + public T Read() + where T : unmanaged + { + var value = *(T*)_position; + _position += (nuint)sizeof(T); + return value; + } + + public ReadOnlySpan ReadSpan(int length) + where T : unmanaged + { + length = Math.Min(length, (int)((nuint)(_buffer + _size - _position) / (nuint)sizeof(T))); + + var size = sizeof(T) * length; + var span = new ReadOnlySpan(_position, length); + + _position += (nuint)size; + return span; + } + + public ReadOnlySpan ReadToEnd() + where T : unmanaged + { + var span = new ReadOnlySpan(_position, (int)(_buffer + _size - _position)); + + _position += (nuint)(span.Length * sizeof(T)); + return span; + } +} + public unsafe ref struct SpanReader { private readonly Span _buffer; diff --git a/src/Runtime/Ghost.Core/Utilities/NativeMemoryManager.cs b/src/Runtime/Ghost.Core/Utilities/NativeMemoryManager.cs index d95f2dc..6973483 100644 --- a/src/Runtime/Ghost.Core/Utilities/NativeMemoryManager.cs +++ b/src/Runtime/Ghost.Core/Utilities/NativeMemoryManager.cs @@ -1,5 +1,7 @@ +using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Collections.Contracts; using System.Buffers; +using MemoryHandle = System.Buffers.MemoryHandle; namespace Ghost.Core.Utilities; @@ -26,6 +28,11 @@ public unsafe class NativeMemoryManager : MemoryManager return new NativeMemoryManager((T*)collection.GetUnsafePtr(), collection.Count); } + public static NativeMemoryManager FromMemoryBlock(MemoryBlock memoryBlock, int start, int length) + { + return new NativeMemoryManager((T*)memoryBlock.GetUnsafePtr() + start, length); + } + public override Span GetSpan() { return new Span(_pointer, _length); diff --git a/src/Runtime/Ghost.Engine/AssetLoader/Asset.cs b/src/Runtime/Ghost.Engine/AssetLoader/Asset.cs new file mode 100644 index 0000000..9a2e31e --- /dev/null +++ b/src/Runtime/Ghost.Engine/AssetLoader/Asset.cs @@ -0,0 +1,154 @@ +using Ghost.Core; +using Ghost.Graphics.RHI; +using Misaki.HighPerformance.LowLevel; +using Misaki.HighPerformance.LowLevel.Buffer; +using System.Runtime.InteropServices; + +namespace Ghost.Engine.AssetLoader; + +public abstract class Asset : IResourceReleasable +{ + private bool _disposed; + + public Guid ID + { + get; + } + + public abstract AssetType Type + { + get; + } + + protected Asset(Guid id) + { + ID = id; + } + + protected virtual void Release(IResourceDatabase resourceDatabase) + { + } + + public void ReleaseResource(IResourceDatabase database) + { + if (_disposed) + { + return; + } + + Release(database); + + _disposed = true; + } +} + +public readonly struct AssetReference : IEquatable +{ + private readonly int _value; + + /// + /// The index of the asset in the dependency list. + /// + public int Index + { + get => Math.Abs(_value) - 1; + } + + public static AssetReference Null => default; + + public readonly bool IsInternal => _value >= 0; + public readonly bool IsExternal => _value < 0; + + public bool Equals(AssetReference other) + { + return _value == other._value; + } + + public override int GetHashCode() + { + return _value.GetHashCode(); + } + + public override bool Equals(object? obj) + { + return obj is AssetReference reference && Equals(reference); + } + + public static bool operator ==(AssetReference left, AssetReference right) + { + return left.Equals(right); + } + + public static bool operator !=(AssetReference left, AssetReference right) + { + return !(left == right); + } +} + +[StructLayout(LayoutKind.Sequential, Size = 64)] // Leave extra space for future expansion without breaking compatibility +public struct TextureContentHeader +{ + public uint width; + public uint height; + public uint depth; + public uint mipLevels; + public uint dimension; // 1 for 1D, 2 for 2D, 3 for 3D + public uint colorComponents; +} + +public class TextureAsset : Asset +{ + private MemoryBlock _textureData; + private readonly uint _width; + private readonly uint _height; + private readonly uint _depth; + private readonly uint _colorComponents; + private readonly uint _mipLevels; + private readonly uint _dimension; + + private Handle _textureHandle; + + public override AssetType Type => AssetType.Texture; + + public uint Width => _width; + public uint Height => _height; + public uint Depth => _depth; + public uint MipLevels => _mipLevels; + public uint Dimension => _dimension; + public uint ColorComponents => _colorComponents; + + public Handle TextureHandle => _textureHandle; + + internal TextureAsset([OwnershipTransfer] ref MemoryBlock data, TextureContentHeader header, Guid id) + : base(id) + { + _textureData = data; + _width = header.width; + _height = header.height; + _depth = header.depth; + _mipLevels = header.mipLevels; + _dimension = header.dimension; + _colorComponents = header.colorComponents; + } + + internal void SetTextureHandle(Handle handle, bool disposeCPUData = true) + { + _textureHandle = handle; + if (disposeCPUData) + { + _textureData.Dispose(); + } + } + + public ReadOnlySpan GeData() + where T : unmanaged + { + return _textureData.AsSpan(); + } + + protected override void Release(IResourceDatabase resourceDatabase) + { + _textureData.Dispose(); + resourceDatabase.ReleaseResource(_textureHandle.AsResource()); + } +} \ No newline at end of file diff --git a/src/Runtime/Ghost.Engine/AssetLoader/TextureLoader.cs b/src/Runtime/Ghost.Engine/AssetLoader/TextureLoader.cs new file mode 100644 index 0000000..60475d0 --- /dev/null +++ b/src/Runtime/Ghost.Engine/AssetLoader/TextureLoader.cs @@ -0,0 +1,42 @@ +using Ghost.Core; +using Ghost.Core.Utilities; +using Misaki.HighPerformance.LowLevel.Buffer; +using Misaki.HighPerformance.LowLevel.Utilities; +using System.Runtime.InteropServices; + +namespace Ghost.Engine.AssetLoader; + +internal sealed class TextureLoader : IRuntimeAssetLoader +{ + public static readonly AssetType AssetType = AssetType.Texture; + + public async ValueTask> LoadAsync(Stream cookedData, Guid id, CancellationToken token) + { + var header = new TextureContentHeader(); + cookedData.ReadExactly(MemoryMarshal.AsBytes(new Span(ref header))); + + var alignment = header.depth switch + { + 8 => MemoryUtility.AlignOf(), + 16 => MemoryUtility.AlignOf(), + 32 => MemoryUtility.AlignOf(), + _ => MemoryUtility.AlignOf() + }; + + var data = new MemoryBlock((nuint)(cookedData.Length - cookedData.Position), alignment, AllocationHandle.Persistent); + + // C# built-in collections use int for indexing, so we need to ensure that the buffer size does not exceed int.MaxValue + var maxBufferSize = (int)Math.Min(0x7effffffu, header.width * header.height * header.depth / 8u * header.colorComponents); + var offset = 0u; + + while (offset < data.Size) + { + using var memoryManager = NativeMemoryManager.FromMemoryBlock(data, (int)offset, maxBufferSize); + + await cookedData.ReadExactlyAsync(memoryManager.Memory, token); + offset += (uint)memoryManager.Memory.Length; + } + + return new TextureAsset(ref data, header, id); + } +} \ No newline at end of file diff --git a/src/Runtime/Ghost.Engine/AssetManager.Texture.cs b/src/Runtime/Ghost.Engine/AssetManager.Texture.cs new file mode 100644 index 0000000..124db17 --- /dev/null +++ b/src/Runtime/Ghost.Engine/AssetManager.Texture.cs @@ -0,0 +1,100 @@ +using Ghost.Core; +using Ghost.Core.Utilities; +using Ghost.Engine.AssetLoader; +using Ghost.Graphics.RHI; +using Ghost.Graphics.Utilities; + +namespace Ghost.Engine; + +public partial class AssetManager +{ + private Handle AllocateTextureHandle() + { + // This will create a new slot in the database, but not allocation any GPU resource. + // Everything in the slot will have the same value as the fallback texture, expect the slot will be marked as shared. + return _resourceDatabase.CreateShared(_fallbackTexture.AsResource()).AsTexture(); + } + + private static TextureFormat GetTextureFormat(uint depth, uint colorComponents) + { + return colorComponents switch + { + 1 => depth switch + { + 8 => TextureFormat.R8_UNorm, + 16 => TextureFormat.R16_UNorm, + 32 => TextureFormat.R32_UInt, + _ => TextureFormat.Unknown, + }, + 2 => depth switch + { + 8 => TextureFormat.R8G8_UNorm, + 16 => TextureFormat.R16G16_UNorm, + 32 => TextureFormat.R32G32_Float, + _ => TextureFormat.Unknown, + }, + 3 or 4 => depth switch + { + 8 => TextureFormat.R8G8B8A8_UNorm, + 16 => TextureFormat.R16G16B16A16_Float, + 32 => TextureFormat.R32G32B32A32_Float, + _ => TextureFormat.Unknown, + }, + _ => TextureFormat.Unknown, + }; + } + + private unsafe Result UploadTexture(AssetEntry entry) + { + var pData = (byte*)entry.rawData.GetUnsafePtr(); + var reader = new BufferReader(pData, entry.rawData.Size); + + var header = reader.Read(); + + var textureDesc = new TextureDesc + { + Width = header.width, + Height = header.height, + MipLevels = header.mipLevels, + Slice = 1, + Format = GetTextureFormat(header.depth, header.colorComponents), + Dimension = (TextureDimension)header.dimension, + Usage = TextureUsage.ShaderResource, + }; + + var newHandle = RenderingUtility.CreateTexture( + _resourceManager, + _resourceDatabase, + _resourceAllocator, + _uploadedBatch.CommandBuffer, + reader.Position, + in textureDesc); + + if (newHandle.IsInvalid) + { + return Result.Failure("Failed to create GPU texture."); + } + + // FIX: We can not Swap right now, we must wait on the GPU to finish the upload. + var oldHandle = entry.GetStorage>(); + _resourceDatabase.Swap(oldHandle.AsResource(), newHandle.AsResource()); + // Release the new handle since it now contains the old handle's resource. + // Because the old handle is shared, it will only release the slot in the database, not the actuall GPU resource, which is the fallback texture in this case. + _resourceDatabase.ReleaseResource(newHandle.AsResource()); + + return Result.Success(); + } + + public Handle ResolveTexture(Guid assetID) + { + if (assetID == Guid.Empty) + { + return _fallbackTexture; + } + + var entry = GetOrCreateEntry(assetID); + Logger.DebugAssert(entry.assetType == AssetType.Texture); + + return entry.GetStorage>(); + } +} diff --git a/src/Runtime/Ghost.Engine/AssetManager.cs b/src/Runtime/Ghost.Engine/AssetManager.cs index f3e476e..3bc917b 100644 --- a/src/Runtime/Ghost.Engine/AssetManager.cs +++ b/src/Runtime/Ghost.Engine/AssetManager.cs @@ -1,44 +1,429 @@ using Ghost.Core; -using System.Runtime.InteropServices; +using Ghost.Core.Utilities; +using Ghost.Engine.AssetLoader; +using Ghost.Graphics.Core; +using Ghost.Graphics.RHI; +using Ghost.Graphics.Services; +using Ghost.Graphics.Utilities; +using Misaki.HighPerformance.Buffer; +using Misaki.HighPerformance.Jobs; +using Misaki.HighPerformance.LowLevel.Buffer; +using Misaki.HighPerformance.LowLevel.Utilities; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Ghost.Engine; -internal abstract class RuntimeAsset; - -internal interface IRuntimeAssetLoader +public enum AssetState : byte { - ValueTask> LoadAsync(Stream cookedData, Guid id, CancellationToken token = default); + Unloaded = 0, + Scheduled = 1, + Loading = 2, + Loaded = 3, + Ready = 4, + Failed = 5, } -internal sealed class RuntimeLoaderRegistry +public enum AssetType : byte { - private readonly Dictionary _loaders = new(); - public void Register(Guid cookedTypeId, IRuntimeAssetLoader loader) + Texture = 0, + Mesh = 1, + Material = 2, + Audio = 3, + Scene = 4, + Video = 5, + Json = 6, + + Unknown = 255, +} + +internal interface IContentProvider +{ + bool HasAsset(Guid guid); + + Result OpenRead(Guid guid, CancellationToken token = default); + + Guid[] GetDependencies(Guid guid); + + AssetType GetAssetType(Guid guid); +} + +// TODO: Support DirectStorage. +public partial class AssetManager : IDisposable +{ + private unsafe class AssetEntry : IDisposable { - _loaders[cookedTypeId] = loader; + private static readonly ObjectPool s_pool = new ObjectPool(() => new AssetEntry(), (entry) => entry.Reset()); + + public struct __storage + { + public fixed byte data[64]; + } + + public Guid assetId; + public __storage storage; + public MemoryBlock rawData; + + public JobHandle loadJobHandle; + public AssetType assetType; + public int state; + public int refCount; + + public static AssetEntry Create() + { + return s_pool.Rent(); + } + + private AssetEntry() + { + } + + private void Reset() + { + assetId = Guid.Empty; + assetType = AssetType.Unknown; + storage = default; + rawData = default; + state = (int)AssetState.Unloaded; + refCount = 0; + loadJobHandle = default; + } + + public void SetStorage(T asset) + where T : unmanaged + { + Unsafe.WriteUnaligned(ref storage.data[0], asset); + } + + public T GetStorage() + where T : unmanaged + { + return Unsafe.ReadUnaligned(ref storage.data[0]); + } + + public void Dispose() + { + s_pool.Return(this); + } } - public IRuntimeAssetLoader? GetLoader(Guid cookedTypeId) + + private struct LoadAssetJob : IJob { - _loaders.TryGetValue(cookedTypeId, out var loader); - return loader; + public Guid assetID; + public AssetType assetType; + + public void Execute(ref readonly JobExecutionContext ctx) + { + var assetManager = ctx.State as AssetManager; + + Debug.Assert(assetManager is not null); + Debug.Assert(assetManager._contentProvider.GetAssetType(assetID) == assetType); + + if (!assetManager._entries.TryGetValue(assetID, out var entry)) + { + Logger.Error($"Asset entry not found for {assetID}"); + return; + } + + var result = assetManager.LoadRawData(entry); + if (result.IsFailure) + { + Volatile.Write(ref entry.state, (int)AssetState.Failed); + Logger.Error($"Failed to load asset {assetID}: {result.Message}"); + return; + } + + Volatile.Write(ref entry.state, (int)AssetState.Loaded); + + // Ensure the buffer inside the resource database does not move. + assetManager._resourceDatabase.EnterParallelRead(); + + try + { + switch (assetType) + { + case AssetType.Texture: + result = assetManager.UploadTexture(entry); + break; + case AssetType.Mesh: + break; + case AssetType.Material: + break; + case AssetType.Audio: + break; + case AssetType.Scene: + break; + case AssetType.Video: + break; + case AssetType.Json: + break; + case AssetType.Unknown: + default: + break; + } + + if (result.IsFailure) + { + Logger.Error($"Failed to upload asset {assetID}: {result.Message}"); + return; + } + } + finally + { + assetManager._resourceDatabase.ExitParallelRead(); + } + } + } + + private readonly IContentProvider _contentProvider; + + private readonly ResourceManager _resourceManager; + private readonly IResourceAllocator _resourceAllocator; + private readonly IResourceDatabase _resourceDatabase; + private readonly ResourceUploadBatch _uploadedBatch; // Upload via copy queue. + + private readonly JobScheduler _jobScheduler; + private readonly ConcurrentDictionary _entries; + private readonly ConcurrentQueue _pendingUploads; + + // TODO + private Handle _fallbackTexture; + private Handle _fallbackNormalMap; + private Handle _fallbackMesh; + private Handle _fallbackMaterial; + + internal AssetManager(IContentProvider contentProvider, ResourceManager resourceManager, IResourceAllocator resourceAllocator, IResourceDatabase resourceDatabase, ResourceUploadBatch uploadBatch) + { + _contentProvider = contentProvider; + _resourceManager = resourceManager; + _resourceAllocator = resourceAllocator; + _resourceDatabase = resourceDatabase; + _uploadedBatch = uploadBatch; + + // Ideally we should use a single JobScheduler across the entire engine, and schedule the streaming jobs to that scheduler as low priority background jobs. + // But how can we get the reference to the AssetManager? Is force job types to be unmanaged a wrong decision? Because we don't have burst compiler at all. + var threadCount = Environment.ProcessorCount < 8 ? 1 : 2; + _jobScheduler = new JobScheduler(threadCount, ThreadPriority.BelowNormal, this); + _entries = new ConcurrentDictionary(); + _pendingUploads = new ConcurrentQueue(); + } + + private JobHandle EnsureScheduled(Guid assetID) + { + if (_entries.TryGetValue(assetID, out var existing) && existing.state >= (int)AssetState.Scheduled) + { + return existing.loadJobHandle; + } + + // Resolve dependencies (in-memory manifest/catalog lookup — instant) + var deps = _contentProvider.GetDependencies(assetID); + + // Schedule all dependencies first (recursive, depth-first) + JobHandle dependency = default; + if (deps.Length > 0) + { + var depHandles = deps.Length <= 8 + ? stackalloc JobHandle[deps.Length] + : new JobHandle[deps.Length]; + + for (int i = 0; i < deps.Length; i++) + { + var depEntry = GetOrCreateEntry(deps[i]); + depHandles[i] = depEntry.loadJobHandle; + } + + dependency = _jobScheduler.CombineDependencies(depHandles); + } + + if (_entries.TryGetValue(assetID, out var entry)) + { + var job = new LoadAssetJob + { + assetID = assetID, + assetType = entry.assetType, + }; + + entry.loadJobHandle = _jobScheduler.Schedule(ref job, dependency); + return entry.loadJobHandle; + } + + // This should not happen, because GetOrCreateEntry should have created the entry and scheduled the job. + Debug.Fail($"Entry for {assetID} should have been created by GetOrCreateEntry"); + return JobHandle.Invalid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private AssetEntry GetOrCreateEntry(Guid guid) + { + return _entries.GetOrAdd(guid, static (id, self) => + { + var entry = AssetEntry.Create(); + entry.assetId = id; + entry.assetType = self._contentProvider.GetAssetType(id); + entry.state = (int)AssetState.Scheduled; + + switch (entry.assetType) + { + case AssetType.Texture: + entry.SetStorage(self.AllocateTextureHandle()); + break; + case AssetType.Mesh: + break; + case AssetType.Material: + break; + case AssetType.Audio: + break; + case AssetType.Scene: + break; + case AssetType.Video: + break; + case AssetType.Json: + break; + case AssetType.Unknown: + default: + break; + } + + entry.loadJobHandle = self.EnsureScheduled(entry.assetId); + + return entry; + }, this); + } + + private Result LoadRawData(AssetEntry entry) + { + try + { + using var stream = _contentProvider.OpenRead(entry.assetId).GetValueOrThrow(); + + var data = new MemoryBlock((nuint)stream.Length, MemoryUtility.AlignOf(), AllocationHandle.Persistent); + + // C# built-in collections use int for indexing, so we need to ensure that the buffer size does not exceed int.MaxValue + var maxChunkSize = (int)Math.Min(0x7fffffffu, data.Size); + var offset = 0u; + + while (offset < data.Size) + { + using var mem = NativeMemoryManager.FromMemoryBlock(data, (int)offset, maxChunkSize); + stream.ReadExactly(mem.Memory.Span); + offset += (uint)mem.Memory.Length; + } + + entry.rawData = data; + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + // ── Render thread only — single-threaded by design ── + // TODO: Does this must be called on render thread? Can you just create a dedicated thred or a worker in thread pool for uploading? + // Also, I think we may don't need this RenderContext at all, because the CommandBuffer is from the ResourceUploadBatch (via async upload), and the ResourceManager/Database/Allocator can be passed in the constructor. + public void ProcessUploads(RenderContext ctx, int maxPerFrame = 4) + { + _uploadedBatch.Begin(); + + for (var i = 0; i < maxPerFrame; i++) + { + if (!_pendingUploads.TryDequeue(out var guid)) + { + break; + } + + if (!_entries.TryGetValue(guid, out var entry) || entry.asset is null) + { + continue; + } + + var error = Error.Success; + switch (entry.assetType) + { + case AssetType.Texture: + var textureDesc = new TextureDesc + { + Width = textureAsset.Width, + Height = textureAsset.Height, + MipLevels = textureAsset.MipLevels, + Slice = 1, + Format = GetTextureFormat(textureAsset.Depth, textureAsset.ColorComponents), + Dimension = GetTextureDimension(textureAsset.Dimension), + Usage = TextureUsage.ShaderResource, + }; + + // NOTE: We use Color128 here to avoid that c# span can't hold 16k x 16k x sizeof(float) x 4 textures, because the max span length is int.MaxValue. + // Internal method will cast the data to void* so the type does not matter as long as the format and size are correct. + var handle = RenderingUtility.CreateTexture( + ctx.ResourceManager, + ctx.ResourceDatabase, + ctx.ResourceAllocator, + _uploadedBatch.CommandBuffer, + textureAsset.GeData(), + in textureDesc); + + textureAsset.SetTextureHandle(handle); + break; + + default: + error = Error.NotSupported; + break; + } + + if (error.IsSuccess) + { + Volatile.Write(ref entry.state, (int)AssetState.Ready); + } + else + { + _pendingUploads.Enqueue(guid); // retry next frame + } + } + + _uploadedBatch.End(); + + // TODO: Do we need to wait? + // await _uploadedBatch.WaitAsync(); // WaitIdle(); + } + + /// + /// 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 : Asset + { + if (!assetRef.IsValid) + { + return null; + } + + var entry = _entries.GetOrAdd(assetRef.guid, static (guid, self) => + { + var e = new AssetEntryOld { assetId = guid, state = (int)AssetState.Loading }; + e.loadTask = Task.Run(() => self.ExecuteLoadAsync(e)); + return e; + }, this); + + if (Volatile.Read(ref entry.state) >= (int)AssetState.Loaded) + { + return entry.asset as T; + } + + var loadTask = entry.loadTask; + if (loadTask is not null) + { + await loadTask.WaitAsync(token).ConfigureAwait(false); + } + + return _entries.TryGetValue(assetRef.guid, out var e) ? e.asset as T : null; + } + + public void Dispose() + { + throw new NotImplementedException(); } } - -internal sealed class CookedTextureLoader : IRuntimeAssetLoader -{ - public static readonly Guid TYPE_ID = TextureAsset.s_typeGuid; - public async ValueTask> LoadAsync(Stream cookedData, Guid id, CancellationToken token) - { - // Read the ImageContentHeader you wrote during import - var header = new ImageContentHeader(); - cookedData.ReadExactly(MemoryMarshal.AsBytes(new Span(ref header))); - // Read the rest as raw GPU data (DDS/BC compressed bytes) - var data = new byte[cookedData.Length - cookedData.Position]; - await cookedData.ReadExactlyAsync(data, token); - return new TextureAsset(data, header, id); - } -} - -public class AssetManager -{ -} diff --git a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceAllocator.cs b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceAllocator.cs index e4f39f4..212ef6f 100644 --- a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceAllocator.cs +++ b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceAllocator.cs @@ -480,7 +480,7 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator Dispose(); } - private HRESULT CreateResource(D3D12MA_ALLOCATION_DESC* pAllocationDesc, D3D12_RESOURCE_DESC* pResourceDesc, D3D12_RESOURCE_STATES initialState, CreationOptions options, Guid* riid, void** ppv) + private HRESULT CreateResource(D3D12MA_ALLOCATION_DESC* pAllocationDesc, D3D12_RESOURCE_DESC1* pResourceDesc, D3D12_BARRIER_LAYOUT initialLayout, CreationOptions options, uint numCapatableFormats, DXGI_FORMAT* pCastableFormats, Guid* riid, void** ppv) { HRESULT hr; @@ -493,12 +493,14 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator return E.E_NOTFOUND; } - hr = _d3d12MA.Get()->CreateAliasingResource(result.Value.resource.allocation.Get(), options.Offset, pResourceDesc, initialState, null, riid, ppv); + hr = _d3d12MA.Get()->CreateAliasingResource2(result.Value.resource.allocation.Get(), options.Offset, pResourceDesc, initialLayout, null, numCapatableFormats, pCastableFormats, riid, ppv); } else { + Logger.DebugAssert(*riid == __uuidof()); + var iid_null = IID.IID_NULL; - hr = _d3d12MA.Get()->CreateResource(pAllocationDesc, pResourceDesc, initialState, null, (D3D12MA_Allocation**)ppv, &iid_null, null); + hr = _d3d12MA.Get()->CreateResource3(pAllocationDesc, pResourceDesc, initialLayout, null, numCapatableFormats, pCastableFormats, (D3D12MA_Allocation**)ppv, &iid_null, null); } return hr; @@ -506,21 +508,23 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator public ResourceSizeInfo GetSizeInfo(ResourceDesc desc) { - D3D12_RESOURCE_DESC d3d12Desc; + D3D12_RESOURCE_DESC1 d3d12Desc; if (desc.Type == ResourceType.Texture) { - d3d12Desc = desc.TextureDescriptor.ToD3D12ResourceDesc(); + d3d12Desc = desc.TextureDescriptor.ToD3D12ResourceDesc1(); } else { - d3d12Desc = desc.BufferDescriptor.ToD3D12ResourceDesc(); + d3d12Desc = desc.BufferDescriptor.ToD3D12ResourceDesc1(); } - var info = _device.NativeObject.Get()->GetResourceAllocationInfo(0, 1, &d3d12Desc); + D3D12_RESOURCE_ALLOCATION_INFO1 info1; + var info = _device.NativeObject.Get()->GetResourceAllocationInfo2(0, 1, &d3d12Desc, &info1); return new ResourceSizeInfo { Size = info.SizeInBytes, - Alignment = info.Alignment + Alignment = info.Alignment, + Offset = info1.Offset, }; } @@ -556,13 +560,13 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator return _resourceDatabase.AddAllocation(alloc, barrierData, ResourceViewGroup.Invalid, default, name); } - public Handle CreateTexture(ref readonly TextureDesc desc, string? name = null, CreationOptions options = default) + public Handle CreateTexture(ref readonly TextureDesc desc, string? name = null, CreationOptions options = default, AdditionalTextureDesc additionalDesc = default) { Logger.DebugAssert(!_disposed); CheckTexture2DSize(desc.Width, desc.Height); - var resourceDesc = desc.ToD3D12ResourceDesc(); + var resourceDesc = desc.ToD3D12ResourceDesc1(); var allocationDesc = new D3D12MA_ALLOCATION_DESC { HeapType = D3D12_HEAP_TYPE_DEFAULT, @@ -574,13 +578,19 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator ID3D12Resource* pResource = default; HRESULT hr; + var pCastableFormats = stackalloc DXGI_FORMAT[additionalDesc.CastableFormat.Length]; + for ( var i = 0; i < additionalDesc.CastableFormat.Length; i++) + { + pCastableFormats[i] = additionalDesc.CastableFormat[i].ToDXGIFormat(); + } + if (isSubAllocation) { - hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_RESOURCE_STATE_COMMON, options, __uuidof(pResource), (void**)&pResource); + hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_BARRIER_LAYOUT_COMMON, options, (uint)additionalDesc.CastableFormat.Length, pCastableFormats, __uuidof(pResource), (void**)&pResource); } else { - hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_RESOURCE_STATE_COMMON, options, null, (void**)&pAllocation); + hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_BARRIER_LAYOUT_COMMON, options, (uint)additionalDesc.CastableFormat.Length, pCastableFormats, __uuidof(pAllocation), (void**)&pAllocation); if (hr.SUCCEEDED) { pResource = pAllocation->GetResource(); @@ -638,8 +648,8 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator var barrierData = new ResourceBarrierData { - access = BarrierAccess.NoAccess, layout = BarrierLayout.Common, + access = BarrierAccess.Common, sync = BarrierSync.None }; @@ -656,20 +666,12 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator return resource.AsTexture(); } - public Handle CreateRenderTarget(ref readonly RenderTargetDesc desc, string? name = null, CreationOptions options = default) - { - Logger.DebugAssert(!_disposed); - - var textureDesc = desc.ToTextureDescription(); - return CreateTexture(in textureDesc, name, options); - } - public Handle CreateBuffer(ref readonly BufferDesc desc, string? name = null, CreationOptions options = default) { Logger.DebugAssert(!_disposed); CheckBufferSize(desc.Size); - var resourceDesc = desc.ToD3D12ResourceDesc(); + var resourceDesc = desc.ToD3D12ResourceDesc1(); var isRaw = desc.Usage.HasFlag(BufferUsage.Raw); var allocationDesc = new D3D12MA_ALLOCATION_DESC @@ -683,21 +685,13 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator ID3D12Resource* pResource = default; HRESULT hr; - var initialState = desc.HeapType switch - { - HeapType.Default => D3D12_RESOURCE_STATE_COMMON, - HeapType.Upload => D3D12_RESOURCE_STATE_GENERIC_READ, - HeapType.Readback => D3D12_RESOURCE_STATE_COPY_DEST, - _ => D3D12_RESOURCE_STATE_COMMON - }; - if (isSubAllocation) { - hr = CreateResource(&allocationDesc, &resourceDesc, initialState, options, __uuidof(pResource), (void**)&pResource); + hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_BARRIER_LAYOUT_UNDEFINED, options, 0u, null, __uuidof(pResource), (void**)&pResource); } else { - hr = CreateResource(&allocationDesc, &resourceDesc, initialState, options, null, (void**)&pAllocation); + hr = CreateResource(&allocationDesc, &resourceDesc, D3D12_BARRIER_LAYOUT_UNDEFINED, options, 0u, null, __uuidof(pAllocation), (void**)&pAllocation); if (hr.SUCCEEDED) { pResource = pAllocation->GetResource(); @@ -750,8 +744,8 @@ internal sealed unsafe partial class D3D12ResourceAllocator : IResourceAllocator var barrierData = new ResourceBarrierData { - access = BarrierAccess.NoAccess, layout = BarrierLayout.Undefined, + access = BarrierAccess.Common, sync = BarrierSync.None }; diff --git a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs index 7bb94ab..868e3e9 100644 --- a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs +++ b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs @@ -7,6 +7,7 @@ using Misaki.HighPerformance.LowLevel.Collections; using System.Diagnostics; using System.Runtime.InteropServices; using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; namespace Ghost.Graphics.D3D12; @@ -39,7 +40,8 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase public ResourceBarrierData barrierData; - public readonly bool isExternal; + public bool isExternal; + public bool isShared; public readonly bool Allocated => isExternal ? resource.resource.Get() != null : resource.allocation.Get() != null; public readonly SharedPtr ResourcePtr => isExternal ? resource.resource.Get() : resource.allocation.Get()->GetResource(); @@ -48,6 +50,7 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase { this.resource = new __resource_union(allocation); this.isExternal = false; + this.isShared = false; this.viewGroup = viewGroup; this.barrierData = barrierData; @@ -58,6 +61,7 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase { this.resource = new __resource_union(resource); this.isExternal = true; + this.isShared = false; this.viewGroup = viewGroup; this.barrierData = barrierData; @@ -66,6 +70,11 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase public readonly uint Release(D3D12DescriptorAllocator descriptorAllocator) { + if (isShared) + { + return 0; + } + var refCount = 0u; if (Allocated) { @@ -400,6 +409,39 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase return Error.None; } + public Handle CreateShared(Handle src) + { + if (src.IsInvalid) + { + return Handle.Invalid; + } + + var spinner = new SpinWait(); + while (Interlocked.CompareExchange(ref _writeLock, 1, 0) != 0) + { + spinner.SpinOnce(); + } + + try + { + var (srcRecord, error) = GetResourceRecord(src); + if (error.IsFailure) + { + return Handle.Invalid; + } + + var newRecord = srcRecord.Get(); + newRecord.isShared = true; + + var id = _resources.Add(newRecord, out var generation); + return new Handle(id, generation); + } + finally + { + Interlocked.Exchange(ref _writeLock, 0); + } + } + public void* MapResource(Handle handle, uint subResource, ResourceRange? readRange) { var r = GetResourceRecord(handle); diff --git a/src/Runtime/Ghost.Graphics.D3D12/Utilities/D3D12Utility.cs b/src/Runtime/Ghost.Graphics.D3D12/Utilities/D3D12Utility.cs index 551c633..1b8497c 100644 --- a/src/Runtime/Ghost.Graphics.D3D12/Utilities/D3D12Utility.cs +++ b/src/Runtime/Ghost.Graphics.D3D12/Utilities/D3D12Utility.cs @@ -134,10 +134,31 @@ internal static unsafe class D3D12Utility return format switch { TextureFormat.Unknown => DXGI_FORMAT_UNKNOWN, + + TextureFormat.R8_UNorm => DXGI_FORMAT_R8_UNORM, + TextureFormat.R8_SNorm => DXGI_FORMAT_R8_SNORM, + TextureFormat.R16_UNorm => DXGI_FORMAT_R16_UNORM, + TextureFormat.R16_SNorm => DXGI_FORMAT_R16_SNORM, + TextureFormat.R16_Float => DXGI_FORMAT_R16_FLOAT, + TextureFormat.R32_UInt => DXGI_FORMAT_R32_UINT, + TextureFormat.R32_SInt => DXGI_FORMAT_R32_SINT, + + TextureFormat.R8G8_UNorm => DXGI_FORMAT_R8G8_UNORM, + TextureFormat.R8G8_SNorm => DXGI_FORMAT_R8G8_SNORM, + TextureFormat.R16G16_UNorm => DXGI_FORMAT_R16G16_UNORM, + TextureFormat.R16G16_SNorm => DXGI_FORMAT_R16G16_SNORM, + TextureFormat.R16G16_Float => DXGI_FORMAT_R16G16_FLOAT, + TextureFormat.R32G32_Float => DXGI_FORMAT_R32G32_FLOAT, + TextureFormat.R8G8B8A8_UNorm => DXGI_FORMAT_R8G8B8A8_UNORM, + TextureFormat.R8G8B8A8_SNorm => DXGI_FORMAT_R8G8B8A8_SNORM, TextureFormat.B8G8R8A8_UNorm => DXGI_FORMAT_B8G8R8A8_UNORM, + + TextureFormat.R10G10B10A2_UNorm => DXGI_FORMAT_R10G10B10A2_UNORM, + TextureFormat.R16G16B16A16_Float => DXGI_FORMAT_R16G16B16A16_FLOAT, TextureFormat.R32G32B32A32_Float => DXGI_FORMAT_R32G32B32A32_FLOAT, + TextureFormat.D24_UNorm_S8_UInt => DXGI_FORMAT_D24_UNORM_S8_UINT, TextureFormat.D32_Float => DXGI_FORMAT_D32_FLOAT, TextureFormat.R32_Typeless => DXGI_FORMAT_R32_TYPELESS, @@ -150,10 +171,32 @@ internal static unsafe class D3D12Utility { return format switch { + DXGI_FORMAT_UNKNOWN => TextureFormat.Unknown, + + DXGI_FORMAT_R8_UNORM => TextureFormat.R8_UNorm, + DXGI_FORMAT_R8_SNORM => TextureFormat.R8_SNorm, + DXGI_FORMAT_R16_UNORM => TextureFormat.R16_UNorm, + DXGI_FORMAT_R16_SNORM => TextureFormat.R16_SNorm, + DXGI_FORMAT_R16_FLOAT => TextureFormat.R16_Float, + DXGI_FORMAT_R32_UINT => TextureFormat.R32_UInt, + DXGI_FORMAT_R32_SINT => TextureFormat.R32_SInt, + + DXGI_FORMAT_R8G8_UNORM => TextureFormat.R8G8_UNorm, + DXGI_FORMAT_R8G8_SNORM => TextureFormat.R8G8_SNorm, + DXGI_FORMAT_R16G16_UNORM => TextureFormat.R16G16_UNorm, + DXGI_FORMAT_R16G16_SNORM => TextureFormat.R16G16_SNorm, + DXGI_FORMAT_R16G16_FLOAT => TextureFormat.R16G16_Float, + DXGI_FORMAT_R32G32_FLOAT => TextureFormat.R32G32_Float, + DXGI_FORMAT_R8G8B8A8_UNORM => TextureFormat.R8G8B8A8_UNorm, + DXGI_FORMAT_R8G8B8A8_SNORM => TextureFormat.R8G8B8A8_SNorm, DXGI_FORMAT_B8G8R8A8_UNORM => TextureFormat.B8G8R8A8_UNorm, + + DXGI_FORMAT_R10G10B10A2_UNORM => TextureFormat.R10G10B10A2_UNorm, + DXGI_FORMAT_R16G16B16A16_FLOAT => TextureFormat.R16G16B16A16_Float, DXGI_FORMAT_R32G32B32A32_FLOAT => TextureFormat.R32G32B32A32_Float, + DXGI_FORMAT_D24_UNORM_S8_UINT => TextureFormat.D24_UNorm_S8_UInt, DXGI_FORMAT_D32_FLOAT => TextureFormat.D32_Float, DXGI_FORMAT_R32_TYPELESS => TextureFormat.R32_Typeless, @@ -501,6 +544,67 @@ internal static unsafe class D3D12Utility }; } + public static D3D12_RESOURCE_DESC1 ToD3D12ResourceDesc1(this in TextureDesc desc) + { + var dxgiFormat = desc.Format.ToDXGIFormat(); + + if (desc.Usage.HasFlag(TextureUsage.DepthStencil) && desc.Usage.HasFlag(TextureUsage.ShaderResource)) + { + if (dxgiFormat == DXGI_FORMAT_D32_FLOAT) + { + dxgiFormat = DXGI_FORMAT_R32_TYPELESS; + } + else if (dxgiFormat == DXGI_FORMAT_D24_UNORM_S8_UINT) + { + dxgiFormat = DXGI_FORMAT_R24G8_TYPELESS; + } + } + + var maxDimension = Math.Max(desc.Width, Math.Max(desc.Height, desc.Slice)); + var mipLevels = desc.MipLevels == 0 + ? (ushort)(1 + Math.Floor(Math.Log2(maxDimension))) + : (ushort)desc.MipLevels; + + var resourceFlags = desc.Usage.ToD3D12ResourceFlag(); + return desc.Dimension switch + { + TextureDimension.Texture2D => D3D12_RESOURCE_DESC1.Tex2D( + dxgiFormat, + desc.Width, + desc.Height, + mipLevels: mipLevels, + flags: resourceFlags), + TextureDimension.Texture3D => D3D12_RESOURCE_DESC1.Tex3D( + dxgiFormat, + desc.Width, + desc.Height, + (ushort)desc.Slice, + flags: resourceFlags), + TextureDimension.TextureCube => D3D12_RESOURCE_DESC1.Tex2D( + dxgiFormat, + desc.Width, + desc.Height, + mipLevels: mipLevels, + arraySize: 6, + flags: resourceFlags), + TextureDimension.Texture2DArray => D3D12_RESOURCE_DESC1.Tex2D( + dxgiFormat, + desc.Width, + desc.Height, + mipLevels: mipLevels, + arraySize: (ushort)desc.Slice, + flags: resourceFlags), + TextureDimension.TextureCubeArray => D3D12_RESOURCE_DESC1.Tex2D( + dxgiFormat, + desc.Width, + desc.Height, + mipLevels: mipLevels, + arraySize: (ushort)(desc.Slice * 6), + flags: resourceFlags), + _ => throw new ArgumentException($"Unsupported texture dimension: {desc.Dimension}"), + }; + } + public static D3D12_RESOURCE_FLAGS ToD3D12ResourceFlag(this BufferUsage usage) { var flags = D3D12_RESOURCE_FLAG_NONE; @@ -526,6 +630,19 @@ internal static unsafe class D3D12Utility return D3D12_RESOURCE_DESC.Buffer(alignedSize, resourceFlags); } + public static D3D12_RESOURCE_DESC1 ToD3D12ResourceDesc1(this in BufferDesc desc) + { + var alignedSize = desc.Size; + if (desc.Usage.HasFlag(BufferUsage.Constant)) + { + // D3D12 CBV size must be 256-byte aligned + alignedSize = (uint)(desc.Size + 255) & ~255u; + } + + var resourceFlags = desc.Usage.ToD3D12ResourceFlag(); + return D3D12_RESOURCE_DESC1.Buffer(alignedSize, resourceFlags); + } + public static ResourceDesc GetResourceDesc(ID3D12Resource* pResource, ResourceViewGroup viewGroup) { D3D12_HEAP_PROPERTIES heapProperties; diff --git a/src/Runtime/Ghost.Graphics.RHI/Common.cs b/src/Runtime/Ghost.Graphics.RHI/Common.cs index 1fb64b3..535b635 100644 --- a/src/Runtime/Ghost.Graphics.RHI/Common.cs +++ b/src/Runtime/Ghost.Graphics.RHI/Common.cs @@ -757,150 +757,6 @@ public record struct ResourceDesc } } -/// -/// Render Target description -/// Supports either color OR depth rendering, not both -/// -public struct RenderTargetDesc -{ - /// - /// Width of the render Target - /// - public uint Width - { - get; set; - } - - /// - /// Height of the render Target - /// - public uint Height - { - get; set; - } - - /// - /// Slice of the render Target - /// - public uint Slice - { - get; set; - } - - /// - /// Type of render Target - /// - public RenderTargetType Type - { - get; set; - } - - /// - /// Target texture Format - /// - public TextureFormat Format - { - get; set; - } - - /// - /// Texture dimension - /// - public TextureDimension Dimension - { - get; set; - } - - /// - /// Creation flags for the render Target - /// - public RenderTargetCreationFlags CreationFlags - { - get; set; - } - - /// - /// Number of mip levels. 0 to generate full mip chain - /// - public uint MipLevels - { - get; set; - } - - /// - /// Number of samples for MSAA - /// - public uint SampleCount - { - get; set; - } - - /// - /// Creates a color render Target - /// - public static RenderTargetDesc Color(uint width, uint height, uint slice = 1, - TextureFormat format = TextureFormat.R8G8B8A8_UNorm, TextureDimension dimension = TextureDimension.Texture2D, - RenderTargetCreationFlags creationFlags = RenderTargetCreationFlags.AllowUAV | RenderTargetCreationFlags.DynamicallyResolution | RenderTargetCreationFlags.GenerateMips, - uint mipLevels = 0u, uint sampleCount = 1) - { - return new RenderTargetDesc - { - Width = width, - Height = height, - Slice = slice, - Type = RenderTargetType.Color, - Format = format, - Dimension = dimension, - CreationFlags = creationFlags, - MipLevels = mipLevels, - SampleCount = sampleCount - }; - } - - /// - /// Creates a depth render Target - /// - public static RenderTargetDesc Depth(uint width, uint height, uint slice = 1, - TextureFormat format = TextureFormat.D24_UNorm_S8_UInt, TextureDimension dimension = TextureDimension.Texture2D, - RenderTargetCreationFlags creationFlags = RenderTargetCreationFlags.AllowUAV | RenderTargetCreationFlags.DynamicallyResolution, - uint mipLevels = 0u, uint sampleCount = 1) - { - return new RenderTargetDesc - { - Width = width, - Height = height, - Slice = slice, - Type = RenderTargetType.Depth, - Format = format, - Dimension = dimension, - CreationFlags = creationFlags, - MipLevels = mipLevels, - SampleCount = sampleCount - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public TextureDesc ToTextureDescription() - { - var usage = Type == RenderTargetType.Color ? TextureUsage.RenderTarget : TextureUsage.DepthStencil; - if (CreationFlags.HasFlag(RenderTargetCreationFlags.AllowUAV)) - { - usage |= TextureUsage.UnorderedAccess; - } - - return new TextureDesc - { - Width = Width, - Height = Height, - Slice = Slice, - Format = Format, - Dimension = Dimension, - MipLevels = MipLevels, - Usage = usage, - }; - } -} - /// /// Texture description /// @@ -964,6 +820,14 @@ public record struct TextureDesc } } +public ref struct AdditionalTextureDesc +{ + public ReadOnlySpan CastableFormat + { + get; set; + } +} + public record struct SamplerDesc { public TextureFilterMode FilterMode @@ -1564,10 +1428,31 @@ public enum RenderTargetType public enum TextureFormat { Unknown, + + R8_UNorm, + R8_SNorm, + R16_UNorm, + R16_SNorm, + R16_Float, + R32_UInt, + R32_SInt, + + R8G8_UNorm, + R8G8_SNorm, + R16G16_UNorm, + R16G16_SNorm, + R16G16_Float, + R32G32_Float, + R8G8B8A8_UNorm, + R8G8B8A8_SNorm, B8G8R8A8_UNorm, + + R10G10B10A2_UNorm, + R16G16B16A16_Float, R32G32B32A32_Float, + D24_UNorm_S8_UInt, D32_Float, diff --git a/src/Runtime/Ghost.Graphics.RHI/IResourceAllocator.cs b/src/Runtime/Ghost.Graphics.RHI/IResourceAllocator.cs index 4afa465..ec78f90 100644 --- a/src/Runtime/Ghost.Graphics.RHI/IResourceAllocator.cs +++ b/src/Runtime/Ghost.Graphics.RHI/IResourceAllocator.cs @@ -77,6 +77,11 @@ public readonly struct ResourceSizeInfo { get; init; } + + public ulong Offset + { + get; init; + } } public interface IResourceAllocator : IDisposable @@ -97,17 +102,9 @@ public interface IResourceAllocator : IDisposable /// Texture description /// Debug name of the resource /// Additional options of the resource allocation + /// Additional texture description for some specific texture types /// An point to the resource - Handle CreateTexture(ref readonly TextureDesc desc, string? name = null, CreationOptions options = default); - - /// - /// Creates a render Target for off-screen rendering - /// - /// Render Target description - /// Debug name of the resource - /// Additional options of the resource allocation - /// An point to the resource - Handle CreateRenderTarget(ref readonly RenderTargetDesc desc, string? name = null, CreationOptions options = default); + Handle CreateTexture(ref readonly TextureDesc desc, string? name = null, CreationOptions options = default, AdditionalTextureDesc additionalDesc = default); /// /// Creates a buffer resource diff --git a/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs b/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs index 9128e1d..f0d297b 100644 --- a/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs +++ b/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs @@ -132,6 +132,18 @@ public unsafe interface IResourceDatabase : IDisposable /// An Error indicating the success or failure of the swap operation. Error Swap(Handle handleA, Handle handleB); + /// + /// Creates a new GPU resource that is a share of the specified source resource, including all its properties and data. + /// The new resource will have the same description and content as the source resource, but will be a distinct entity in the resource database with its own handle. + /// + /// + /// The shared resource created by this method will have the same description and content as the source resource, but will be a distinct entity in the resource database with its own handle. + /// However, it is important to note that modifications to the shared resource through one handle will affect all other handles that reference the same underlying resource, as they all point to the same GPU memory. + /// + /// The handle to the source resource. + /// The handle to the newly created shared resource. + Handle CreateShared(Handle src); + /// /// Maps a subresource of a GPU resource for CPU access, specifying read and write ranges. /// diff --git a/src/Runtime/Ghost.Graphics/RenderGraphModule/RenderGraphAliasing.cs b/src/Runtime/Ghost.Graphics/RenderGraphModule/RenderGraphAliasing.cs index 14bec8e..c9429e0 100644 --- a/src/Runtime/Ghost.Graphics/RenderGraphModule/RenderGraphAliasing.cs +++ b/src/Runtime/Ghost.Graphics/RenderGraphModule/RenderGraphAliasing.cs @@ -1,7 +1,6 @@ using Ghost.Core; using Ghost.Core.Utilities; using Ghost.Graphics.RHI; -using System.Diagnostics; using System.Runtime.InteropServices; namespace Ghost.Graphics.RenderGraphModule; diff --git a/src/Runtime/Ghost.Graphics/RenderSystem.cs b/src/Runtime/Ghost.Graphics/RenderSystem.cs index 444f4de..6def7a5 100644 --- a/src/Runtime/Ghost.Graphics/RenderSystem.cs +++ b/src/Runtime/Ghost.Graphics/RenderSystem.cs @@ -60,6 +60,7 @@ public class RenderSystem : IDisposable get; init; } + // TODO: Thread local? public required ICommandAllocator CommandAllocator { get; init; diff --git a/src/Runtime/Ghost.Graphics/Services/ResourceUploadBatch.cs b/src/Runtime/Ghost.Graphics/Services/ResourceUploadBatch.cs index 0c76634..e086de4 100644 --- a/src/Runtime/Ghost.Graphics/Services/ResourceUploadBatch.cs +++ b/src/Runtime/Ghost.Graphics/Services/ResourceUploadBatch.cs @@ -1,6 +1,5 @@ using Ghost.Core; using Ghost.Graphics.RHI; -using Ghost.Graphics.Services; namespace Ghost.Graphics.Services; @@ -11,6 +10,8 @@ public class ResourceUploadBatch private readonly ICommandAllocator _commandAllocator; private readonly ICommandBuffer _commandBuffer; + public ICommandBuffer CommandBuffer => _commandBuffer; + internal ResourceUploadBatch(IGraphicsEngine engine) { _device = engine.Device; @@ -21,6 +22,7 @@ public class ResourceUploadBatch public void Begin() { + _commandAllocator.Reset(); _commandBuffer.Begin(_commandAllocator); } @@ -32,7 +34,7 @@ public class ResourceUploadBatch return r; } - _device.GraphicsQueue.Submit(_commandBuffer); + _device.CopyQueue.Submit(_commandBuffer); return Result.Success(); } diff --git a/src/Runtime/Ghost.Graphics/Utilities/RenderingUtility.cs b/src/Runtime/Ghost.Graphics/Utilities/RenderingUtility.cs index 5d8c284..2b96256 100644 --- a/src/Runtime/Ghost.Graphics/Utilities/RenderingUtility.cs +++ b/src/Runtime/Ghost.Graphics/Utilities/RenderingUtility.cs @@ -1,34 +1,29 @@ using Ghost.Core; using Ghost.Graphics.RHI; -using Misaki.HighPerformance.LowLevel.Utilities; using Ghost.Graphics.Services; +using Misaki.HighPerformance.LowLevel.Utilities; namespace Ghost.Graphics.Utilities; public static unsafe class RenderingUtility { - public static void UploadBuffer(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle buffer, params ReadOnlySpan data) - where T : unmanaged + public static Error UploadBuffer(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle buffer, void* pData, nuint sizeInBytes) { - var r = resourceDatabase.GetResourceDescription(buffer.AsResource()); - if (r.IsFailure) + var (desc, error) = resourceDatabase.GetResourceDescription(buffer.AsResource()); + if (error.IsFailure) { - return; + return error; } - Logger.DebugAssert(r.Value.Type == ResourceType.Buffer); + Logger.DebugAssert(desc.Type == ResourceType.Buffer); - var sizeInBytes = (nuint)(data.Length * sizeof(T)); - var memoryType = r.Value.BufferDescriptor.HeapType; + var memoryType = desc.BufferDescriptor.HeapType; if (memoryType == HeapType.Upload) { - fixed (T* pData = data) - { - var mappedData = resourceDatabase.MapResource(buffer.AsResource(), 0, null); - MemoryUtility.MemCpy(mappedData, pData, sizeInBytes); - resourceDatabase.UnmapResource(buffer.AsResource(), 0, null); - } + var mappedData = resourceDatabase.MapResource(buffer.AsResource(), 0, null); + MemoryUtility.MemCpy(mappedData, pData, sizeInBytes); + resourceDatabase.UnmapResource(buffer.AsResource(), 0, null); } else { @@ -42,24 +37,66 @@ public static unsafe class RenderingUtility var uploadHandle = resourceManager.CreateTransientBuffer(in uploadDesc); if (uploadHandle.IsInvalid) { - throw new OutOfMemoryException("Failed to create upload buffer for buffer data."); + return Error.OutOfMemory; } - fixed (T* pData = data) - { - var mappedData = resourceDatabase.MapResource(uploadHandle.AsResource(), 0, null); - MemoryUtility.MemCpy(mappedData, pData, sizeInBytes); - resourceDatabase.UnmapResource(uploadHandle.AsResource(), 0, null); - } + var mappedData = resourceDatabase.MapResource(uploadHandle.AsResource(), 0, null); + MemoryUtility.MemCpy(mappedData, pData, sizeInBytes); + resourceDatabase.UnmapResource(uploadHandle.AsResource(), 0, null); + cmd.Barrier(BarrierDesc.Buffer(buffer.AsResource(), BarrierSync.Copy, BarrierAccess.CopyDest)); cmd.CopyBuffer(buffer, uploadHandle, 0, 0, sizeInBytes); + cmd.Barrier(BarrierDesc.Buffer(buffer.AsResource(), BarrierSync.None, BarrierAccess.Common)); + } + + return Error.None; + } + + public static Error UploadBuffer(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle buffer, params ReadOnlySpan data) + where T : unmanaged + { + fixed (T* pData = data) + { + return UploadBuffer(resourceManager, resourceDatabase, cmd, buffer, pData, (nuint)(data.Length * sizeof(T))); } } - public static void UploadTexture(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle texture, ReadOnlySpan data) + public static Handle CreateBuffer(ResourceManager resourceManager, IResourceDatabase resourceDatabase, IResourceAllocator resourceAllocator, ICommandBuffer cmd, void* pData, nuint sizeInBytes, ref readonly BufferDesc desc, string? name = null) + { + var error = Error.UnknownError; + var bufferHandle = resourceAllocator.CreateBuffer(in desc, name); + + if (!bufferHandle.IsInvalid) + { + error = UploadBuffer(resourceManager, resourceDatabase, cmd, bufferHandle, pData, sizeInBytes); + } + + if (error.IsSuccess) + { + return bufferHandle; + } + + Logger.DebugAssert(error.IsSuccess); + return Handle.Invalid; + } + + public static Handle CreateBuffer(ResourceManager resourceManager, IResourceAllocator resourceAllocator, IResourceDatabase resourceDatabase, ICommandBuffer cmd, ReadOnlySpan data, ref readonly BufferDesc desc, string? name = null) where T : unmanaged { - var desc = resourceDatabase.GetResourceDescription(texture.AsResource()).GetValueOrThrow(); + fixed (T* pData = data) + { + return CreateBuffer(resourceManager, resourceDatabase, resourceAllocator, cmd, pData, (nuint)(data.Length * sizeof(T)), in desc, name); + } + } + + public static Error UploadTexture(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle texture, void* pData) + { + var (desc, error) = resourceDatabase.GetResourceDescription(texture.AsResource()); + if (error.IsFailure) + { + return error; + } + desc.TextureDescriptor.Format.GetSurfaceInfo(desc.TextureDescriptor.Width, desc.TextureDescriptor.Height, out var rowPitch, out var slicePitch, out _); var requiredSize = resourceDatabase.GetIntermediateResourceSize(texture.AsResource(), 0, 1); @@ -73,21 +110,58 @@ public static unsafe class RenderingUtility var uploadHandle = resourceManager.CreateTransientBuffer(in uploadDesc); if (uploadHandle.IsInvalid) { - throw new OutOfMemoryException("Failed to create upload buffer for texture data."); + return Error.OutOfMemory; } cmd.Barrier(BarrierDesc.Texture(texture.AsResource(), BarrierSync.Copy, BarrierAccess.CopyDest, BarrierLayout.CopyDest)); + var subresourceData = new SubResourceData + { + pData = pData, + rowPitch = rowPitch, + slicePitch = slicePitch + }; + + cmd.UpdateSubResources(texture.AsResource(), uploadHandle.AsResource(), subresourceData); + cmd.Barrier(BarrierDesc.Texture(texture.AsResource(), BarrierSync.None, BarrierAccess.Common, BarrierLayout.Common)); + + return Error.None; + } + + public static Error UploadTexture(ResourceManager resourceManager, IResourceDatabase resourceDatabase, ICommandBuffer cmd, Handle texture, ReadOnlySpan data) + where T : unmanaged + { fixed (T* pData = data) { - var subresourceData = new SubResourceData - { - pData = pData, - rowPitch = rowPitch, - slicePitch = slicePitch - }; + return UploadTexture(resourceManager, resourceDatabase, cmd, texture, pData); + } + } - cmd.UpdateSubResources(texture.AsResource(), uploadHandle.AsResource(), subresourceData); + public static Handle CreateTexture(ResourceManager resourceManager, IResourceDatabase resourceDatabase, IResourceAllocator resourceAllocator, ICommandBuffer cmd, void* pData, ref readonly TextureDesc desc, string? name = null) + { + var error = Error.UnknownError; + + var textureHandle = resourceAllocator.CreateTexture(in desc, name); + if (!textureHandle.IsInvalid) + { + error = UploadTexture(resourceManager, resourceDatabase, cmd, textureHandle, pData); + } + + if (error.IsSuccess) + { + return textureHandle; + } + + Logger.DebugAssert(error.IsSuccess); + return Handle.Invalid; + } + + public static Handle CreateTexture(ResourceManager resourceManager, IResourceDatabase resourceDatabase, IResourceAllocator resourceAllocator, ICommandBuffer cmd, ReadOnlySpan data, ref readonly TextureDesc desc, string? name = null) + where T : unmanaged + { + fixed (T* pData = data) + { + return CreateTexture(resourceManager, resourceDatabase, resourceAllocator, cmd, pData, in desc, name); } } } diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs index b5eb98d..1369ed7 100644 --- a/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs @@ -2,6 +2,7 @@ using Ghost.Core; using Ghost.Core.Attributes; using Ghost.Editor.Core.AssetHandler; using Ghost.Editor.Core.Contracts; +using Ghost.Engine.AssetLoader; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Ghost.UnitTest.AssetSystem;