Asset Management Architecture
This document outlines the architecture for a robust asset management system in KeenEyes, handling loading, caching, and lifecycle management of game assets.
Table of Contents
- Executive Summary
- Design Goals
- Architecture Overview
- Asset Types
- Loading Pipeline
- Reference Counting
- Async Loading
- Implementation Plan
Executive Summary
KeenEyes Assets provides a unified system for managing all game resources:
- Textures, audio, fonts, shaders - Binary assets
- Prefabs, scenes, data files - Serialized data
- Reference counting - Automatic unloading when unused
- Async loading - Non-blocking asset loads
Key Decision: Assets are referenced by path/ID, loaded on-demand, and automatically unloaded when no longer referenced.
Design Goals
- Unified API - Single interface for all asset types
- Async by Default - Non-blocking loads for smooth gameplay
- Reference Counted - Automatic memory management
- Hot Reload - Development-time asset reloading
- AOT Compatible - No reflection-based loading
- Extensible - Custom asset types via loaders
Architecture Overview
Project Structure
KeenEyes.Assets/
├── KeenEyes.Assets.csproj
├── AssetPlugin.cs # IWorldPlugin entry point
│
├── Core/
│ ├── IAssetManager.cs # Main API interface
│ ├── AssetHandle.cs # Typed reference to loaded asset
│ ├── AssetId.cs # Unique asset identifier
│ ├── AssetState.cs # Loading state enum
│ └── AssetMetadata.cs # Asset info (size, type, deps)
│
├── Loading/
│ ├── IAssetLoader.cs # Asset type loader interface
│ ├── AssetLoaderRegistry.cs # Loader registration
│ ├── LoadRequest.cs # Async load request
│ └── LoadPriority.cs # Loading priority levels
│
├── Loaders/
│ ├── TextureLoader.cs # Image loading
│ ├── AudioLoader.cs # Audio file loading
│ ├── ShaderLoader.cs # Shader loading
│ ├── FontLoader.cs # Font loading
│ ├── JsonLoader.cs # JSON data loading
│ └── PrefabLoader.cs # Entity prefab loading
│
├── Caching/
│ ├── AssetCache.cs # In-memory cache
│ ├── RefCounter.cs # Reference counting
│ └── CachePolicy.cs # Eviction policies
│
├── Bundles/
│ ├── AssetBundle.cs # Grouped assets
│ ├── BundleManifest.cs # Bundle contents
│ └── BundleLoader.cs # Bundle loading
│
└── Utilities/
├── AssetPath.cs # Path normalization
├── AssetWatcher.cs # Hot reload watcher
└── AssetDatabase.cs # Asset registry
Asset Types
IAsset Interface
public interface IAsset : IDisposable
{
AssetId Id { get; }
string Path { get; }
AssetState State { get; }
long SizeBytes { get; }
Type AssetType { get; }
}
public enum AssetState
{
Unloaded,
Loading,
Loaded,
Failed
}
public readonly record struct AssetId(ulong Value)
{
public static AssetId FromPath(string path)
=> new(XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(path)));
}
Common Asset Types
// Texture asset
public sealed class TextureAsset : IAsset
{
public AssetId Id { get; }
public string Path { get; }
public AssetState State { get; internal set; }
public long SizeBytes => Width * Height * BytesPerPixel;
public Type AssetType => typeof(TextureAsset);
public ITexture Texture { get; internal set; }
public int Width { get; internal set; }
public int Height { get; internal set; }
public TextureFormat Format { get; internal set; }
}
// Audio asset
public sealed class AudioAsset : IAsset
{
public IAudioClip Clip { get; internal set; }
public float Duration { get; internal set; }
public int SampleRate { get; internal set; }
// ... IAsset implementation
}
// Shader asset
public sealed class ShaderAsset : IAsset
{
public IShader Shader { get; internal set; }
public string[] Uniforms { get; internal set; }
// ... IAsset implementation
}
// Data asset (JSON/binary)
public sealed class DataAsset<T> : IAsset where T : class
{
public T Data { get; internal set; }
// ... IAsset implementation
}
// Prefab asset
public sealed class PrefabAsset : IAsset
{
public EntityTemplate Template { get; internal set; }
public AssetId[] Dependencies { get; internal set; }
// ... IAsset implementation
}
Loading Pipeline
IAssetLoader Interface
public interface IAssetLoader
{
Type AssetType { get; }
string[] SupportedExtensions { get; }
bool CanLoad(string path);
IAsset Load(string path, IAssetLoadContext context);
Task<IAsset> LoadAsync(string path, IAssetLoadContext context, CancellationToken ct);
void Unload(IAsset asset);
}
public interface IAssetLoadContext
{
IGraphicsBackend? Graphics { get; }
IAudioContext? Audio { get; }
IAssetManager Assets { get; } // For loading dependencies
Stream OpenRead(string path);
}
TextureLoader Example
public class TextureLoader : IAssetLoader
{
public Type AssetType => typeof(TextureAsset);
public string[] SupportedExtensions => [".png", ".jpg", ".jpeg", ".bmp", ".tga"];
public bool CanLoad(string path)
=> SupportedExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
public IAsset Load(string path, IAssetLoadContext context)
{
using var stream = context.OpenRead(path);
var imageData = ImageDecoder.Decode(stream);
var texture = context.Graphics!.LoadTexture(
imageData.Data,
imageData.Format
);
return new TextureAsset
{
Id = AssetId.FromPath(path),
Path = path,
State = AssetState.Loaded,
Texture = texture,
Width = imageData.Width,
Height = imageData.Height,
Format = imageData.Format
};
}
public async Task<IAsset> LoadAsync(string path, IAssetLoadContext context, CancellationToken ct)
{
// Decode on thread pool
using var stream = context.OpenRead(path);
var imageData = await Task.Run(() => ImageDecoder.Decode(stream), ct);
// GPU upload must be on main thread
var texture = context.Graphics!.LoadTexture(imageData.Data, imageData.Format);
return new TextureAsset { /* ... */ };
}
public void Unload(IAsset asset)
{
if (asset is TextureAsset tex)
{
tex.Texture?.Dispose();
}
}
}
Loader Registry
public class AssetLoaderRegistry
{
private readonly Dictionary<Type, IAssetLoader> loadersByType = new();
private readonly Dictionary<string, IAssetLoader> loadersByExtension = new();
public void Register(IAssetLoader loader)
{
loadersByType[loader.AssetType] = loader;
foreach (var ext in loader.SupportedExtensions)
{
loadersByExtension[ext.ToLowerInvariant()] = loader;
}
}
public IAssetLoader? GetLoader(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return loadersByExtension.GetValueOrDefault(ext);
}
public IAssetLoader? GetLoader<T>() where T : IAsset
=> loadersByType.GetValueOrDefault(typeof(T));
}
Reference Counting
AssetHandle
public readonly struct AssetHandle<T> : IDisposable where T : class, IAsset
{
private readonly AssetManager manager;
private readonly AssetId id;
internal AssetHandle(AssetManager manager, AssetId id)
{
this.manager = manager;
this.id = id;
manager.AddRef(id);
}
public T? Asset => manager.GetLoaded<T>(id);
public bool IsLoaded => Asset?.State == AssetState.Loaded;
public bool IsLoading => manager.IsLoading(id);
public void Dispose()
{
manager.Release(id);
}
// Implicit conversion for convenience
public static implicit operator T?(AssetHandle<T> handle) => handle.Asset;
}
Reference Counter
public class RefCounter
{
private readonly Dictionary<AssetId, int> refCounts = new();
private readonly Dictionary<AssetId, IAsset> loadedAssets = new();
public void AddRef(AssetId id)
{
refCounts.TryGetValue(id, out int count);
refCounts[id] = count + 1;
}
public bool Release(AssetId id, out IAsset? asset)
{
asset = null;
if (!refCounts.TryGetValue(id, out int count))
return false;
count--;
if (count <= 0)
{
refCounts.Remove(id);
if (loadedAssets.Remove(id, out asset))
{
return true; // Should unload
}
}
else
{
refCounts[id] = count;
}
return false;
}
public int GetRefCount(AssetId id)
=> refCounts.GetValueOrDefault(id, 0);
}
Usage Pattern
// Load and hold reference
using var textureHandle = await assets.LoadAsync<TextureAsset>("sprites/player.png");
// Use the asset
renderer.Draw(textureHandle.Asset.Texture, position);
// Reference released when handle disposed
// Asset unloaded if no other references exist
IAssetManager API
Interface Definition
public interface IAssetManager
{
// Synchronous loading
AssetHandle<T> Load<T>(string path) where T : class, IAsset;
// Asynchronous loading
Task<AssetHandle<T>> LoadAsync<T>(string path, LoadPriority priority = LoadPriority.Normal)
where T : class, IAsset;
// Batch loading
Task LoadAllAsync(IEnumerable<string> paths, IProgress<float>? progress = null);
// Query
bool IsLoaded(string path);
bool IsLoading(string path);
T? GetLoaded<T>(string path) where T : class, IAsset;
// Management
void Unload(string path);
void UnloadUnused();
void UnloadAll();
// Statistics
AssetStatistics GetStatistics();
}
public enum LoadPriority
{
Low, // Background loading
Normal, // Standard priority
High, // Load soon
Immediate // Load now, block if needed
}
public readonly record struct AssetStatistics(
int LoadedCount,
int LoadingCount,
long TotalMemoryBytes,
int CacheHits,
int CacheMisses
);
Implementation
public class AssetManager : IAssetManager, IDisposable
{
private readonly AssetLoaderRegistry loaders;
private readonly RefCounter refCounter;
private readonly AssetCache cache;
private readonly ConcurrentDictionary<AssetId, Task<IAsset>> pendingLoads = new();
public AssetHandle<T> Load<T>(string path) where T : class, IAsset
{
var id = AssetId.FromPath(path);
// Check cache
if (cache.TryGet<T>(id, out var cached))
{
return new AssetHandle<T>(this, id);
}
// Synchronous load
var loader = loaders.GetLoader(path)
?? throw new InvalidOperationException($"No loader for {path}");
var asset = loader.Load(path, CreateContext());
cache.Add(id, asset);
return new AssetHandle<T>(this, id);
}
public async Task<AssetHandle<T>> LoadAsync<T>(string path, LoadPriority priority)
where T : class, IAsset
{
var id = AssetId.FromPath(path);
// Check cache
if (cache.TryGet<T>(id, out _))
{
return new AssetHandle<T>(this, id);
}
// Check pending
if (pendingLoads.TryGetValue(id, out var pending))
{
await pending;
return new AssetHandle<T>(this, id);
}
// Start async load
var loadTask = LoadAssetAsync(path, id, priority);
pendingLoads[id] = loadTask;
try
{
await loadTask;
return new AssetHandle<T>(this, id);
}
finally
{
pendingLoads.TryRemove(id, out _);
}
}
private async Task<IAsset> LoadAssetAsync(string path, AssetId id, LoadPriority priority)
{
var loader = loaders.GetLoader(path)
?? throw new InvalidOperationException($"No loader for {path}");
var asset = await loader.LoadAsync(path, CreateContext(), CancellationToken.None);
cache.Add(id, asset);
return asset;
}
}
Async Loading
Load Queue
public class AssetLoadQueue
{
private readonly PriorityQueue<LoadRequest, int> queue = new();
private readonly SemaphoreSlim concurrencyLimit;
private readonly CancellationTokenSource cts = new();
public AssetLoadQueue(int maxConcurrent = 4)
{
concurrencyLimit = new SemaphoreSlim(maxConcurrent);
StartWorkers();
}
public Task<IAsset> Enqueue(string path, LoadPriority priority)
{
var request = new LoadRequest(path, priority);
queue.Enqueue(request, -(int)priority); // Higher priority = lower value
return request.Completion.Task;
}
private async void StartWorkers()
{
while (!cts.IsCancellationRequested)
{
await concurrencyLimit.WaitAsync(cts.Token);
if (queue.TryDequeue(out var request, out _))
{
_ = ProcessRequest(request);
}
else
{
concurrencyLimit.Release();
await Task.Delay(10, cts.Token);
}
}
}
private async Task ProcessRequest(LoadRequest request)
{
try
{
var asset = await LoadAssetAsync(request.Path);
request.Completion.SetResult(asset);
}
catch (Exception ex)
{
request.Completion.SetException(ex);
}
finally
{
concurrencyLimit.Release();
}
}
}
Progress Reporting
public async Task LoadAllAsync(IEnumerable<string> paths, IProgress<float>? progress)
{
var pathList = paths.ToList();
int completed = 0;
var tasks = pathList.Select(async path =>
{
await LoadAsync<IAsset>(path);
var current = Interlocked.Increment(ref completed);
progress?.Report((float)current / pathList.Count);
});
await Task.WhenAll(tasks);
}
// Usage
await assets.LoadAllAsync(levelAssets, new Progress<float>(p =>
{
loadingBar.Value = p;
loadingText.Text = $"Loading... {p:P0}";
}));
Hot Reload (Development)
public class AssetWatcher : IDisposable
{
private readonly FileSystemWatcher watcher;
private readonly AssetManager manager;
private readonly ConcurrentDictionary<string, DateTime> pendingReloads = new();
public AssetWatcher(string rootPath, AssetManager manager)
{
this.manager = manager;
watcher = new FileSystemWatcher(rootPath)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
};
watcher.Changed += OnFileChanged;
watcher.EnableRaisingEvents = true;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
// Debounce rapid changes
pendingReloads[e.FullPath] = DateTime.UtcNow;
Task.Delay(100).ContinueWith(_ =>
{
if (pendingReloads.TryRemove(e.FullPath, out var time)
&& DateTime.UtcNow - time >= TimeSpan.FromMilliseconds(100))
{
ReloadAsset(e.FullPath);
}
});
}
private void ReloadAsset(string fullPath)
{
var relativePath = Path.GetRelativePath(watcher.Path, fullPath);
if (manager.IsLoaded(relativePath))
{
Log.Info($"Hot reloading: {relativePath}");
manager.Reload(relativePath);
}
}
}
Implementation Plan
Phase 1: Core Infrastructure
- Create
KeenEyes.Assetsproject - Implement AssetId and AssetHandle
- Implement RefCounter
- Basic AssetManager with sync loading
Milestone: Load textures with reference counting
Phase 2: Loaders
- TextureLoader
- AudioLoader
- ShaderLoader
- JsonLoader
Milestone: Load common asset types
Phase 3: Async Loading
- Implement load queue
- Add priority system
- Progress reporting
- Batch loading
Milestone: Non-blocking asset loads
Phase 4: Advanced Features
- Asset bundles
- Dependency tracking
- Hot reload (dev only)
- Cache policies
Milestone: Production-ready asset system
Phase 5: Integration
- Prefab loader integration
- Scene asset loading
- Streaming support
- Memory budgets
Open Questions
- Virtual File System - Support for archives (ZIP, custom)?
- Compression - Automatic decompression on load?
- Streaming - Large asset streaming (terrain, open world)?
- Addressables - Unity-style addressable assets?
- Build Pipeline - Asset processing/optimization on build?
- Localization - Localized asset variants?
Related Issues
- Milestone #19: Asset Management
- Issue #428: Create KeenEyes.Assets with core loading infrastructure
- Issue #429: Implement asset loaders and reference counting