Asset management is one of the most important components of a game framework. Here's how I do it in my own.
Context
For the past two or so years I've been working on my custom C# game framework in spare time. I got a little fed up with numerous pain points of using Godot with C#, so I decided it was time to try and make something that would better fit my requirements and workflow.
It primarily does 2D graphics. For now, I'm using Raylib instead of creating my own renderer, so I can better understand how I want the final API to look and what I'll primarily do with the renderer. Once it's finalized, I'll implement my own using WebGPU.
Goals
Before deciding how I wanted this asset management system to look like, I outlined a few primary goals:
- Hot reloading. During development, I want the ability to change resources and reload them on the fly to reduce iteration time.
- Avoid using strings as much as possible. Referencing assets during runtime by string IDs or paths is error-prone.
- File system abstraction. I want my framework to be portable, but most importantly, I need a way to compress my resources into archives so they take up less space. Introducing some sort of file system abstraction is key to achieve both.
- Extensibility. I want the entire asset system to be extensible. Different resources can have varying priorities depending on the game, and I want the freedom to handle them differently based on the asset. Making this system extensible ensures that I can easily introduce support for multiple formats for sounds, textures, or even completely different asset types.
Basics: The Resource
Resource
is the base class that represents a given asset/resource. For example, I have several resource types that inherit from it: Texture2d
, Font
, Sound
, etc.
Each of these contains its own way to represent in-engine data. Texture2d
includes typical properties of an image: width, height, pixel format, and an actual buffer with data. Sound
contains raw samples per channel.
ResourceManager
ResourceManager
is a static class that manages loading, unloading and retrieving of resources as well as querying different ResourceLoaders. This is essentially the core of the entire system.
Here's how resource loading API looks like:
protected override void LoadResources()
{
ResourceManager.AddResourceLoaderAssociation(new ParticleEmitterSettingsResourceLoader());
if (!ResourceManager.TryLoad("fonts/Inter-Regular.ttf", out _font))
{
}
ResourceManager.TryLoad("icon.png", out _icon);
if (!ResourceManager.TryLoad("fire_effect.toml", out _fireEffect))
{
throw new Exception("Failed to load emitter settings!");
}
}
AddResourceLoaderAssociation
adds a custom resource loader. This is a part of extensibility of the resource manager itself. More on that in a separate chapter.
Loading resources is a failable process: it could be missing or locked by the OS. In that case, we should probably do something about it, like show an error dialog to the user, replace it with a placeholder resource, or just ignore it if its not critical for main game functionality (for instance, we're loading resources contained in a separate mod package). Different resources can have a different priority depending on the game, and I want the freedom to be able to do something about it, depending on the asset that I load.
Resource Loaders
When trying to load the resource, ResourceManager
queries resource loaders that support loading the resource of a given type. When a resource loader is found, it will call its Load
method.
ResourceLoader reads the resource at a specified path, adds it to the list of loaded resources in the manager, and returns a GUID that maps to the newly loaded resource. If the resource is already loaded, it will simply replace it.
All of that is done in the base ResourceLoader class, with custom loaders for different types only needing to implement the reading part. As an example, here's how Texture2dLoader
is implemented.
/// <summary>
/// Loads a 2D texture from PNG or JPG files using StbImageSharp.
/// </summary>
public class Texture2dLoader : ResourceLoader<Texture2d>
{
public override IEnumerable<string> SupportedExtensions => new string[]
{
".png",
".jpg",
".jpeg"
};
protected override Texture2d LoadResource(string path)
{
ImageResult image;
using (var stream = VirtualFileSystem.Read(path))
{
image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
}
Texture2d result = new(path, image.Data)
{
Width = image.Width,
Height = image.Height
};
return result;
}
}
Notice the call to VirtualFileSystem
instead of using the typical .NET APIs. This is the file system abstraction I mentioned earlier.
After all of that is done, a resource gets added to the dictionary/hashmap of loaded ones or gets replaced. Internally, these resources are getting accessed using their 128-bit GUIDs.
However, there is a catch. Mainly, having the user use these GUIDs directly provides several inconveniences. One of the biggest ones is a chance of type mismatches.
As an example, we will get back to the load method in the game:
protected override void LoadResources()
{
ResourceManager.TryLoad("icon.png", out _icon);
}
The TryLoad
passes a GUID of the newly loaded resource to the out
parameter. However, how can we know if _icon
is of correct type? What if by some chance, the resource at the given GUID gets replaced with a different type? This will cause unexpected behavior, makes the code less readable, and introduces ambiguity.
To combat this (and a different problem, more on that in a second), the TryLoad
method actually returns a reference to the resource of a given type. Here's how that reference looks like:
/// <summary>
/// Wraps a reference to an asset of a given type.
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed class ResourceRef<T> where T : Resource
{
/// <summary>
/// Resource GUID this ResourceRef maps to.
/// </summary>
public readonly Guid Guid = Guid.Empty;
public bool HasValue => Guid != Guid.Empty;
/// <summary>
/// Retrieve a reference.
/// </summary>
public T Value => ResourceManager.GetResource<T>(Guid);
public ResourceRef(Guid guid)
{
Guid = guid;
}
}
So in essence, _icon
is just a ResourceRef<Texture2d>
. This way, we know that this is a reference to a Texture2d
, not some other random type. It's also easy to implement validation, and we can easily make that reference point to some placeholder resource if its still being loaded, which makes it convenient to implement asynchronous loading in the future.
It also simplifies the implementation of hot reloading, since this is essentially a handle to a resource, not the actual resource itself, so we don't have to manually "reload" them.
Particle system is a great example of hot reload in action. Particle system settings are stored in a separate resource, and get read from a TOML file.
# Fire effect example
[ParticleEmitterSettings]
Local = true
MaxParticles = 128
EmitRadius = 8
Explosiveness = 0
LifeTime = 1.0
Direction = [0.0, -1.0]
LinearVelocity = 200
LinearVelocityDamping = 0.0
AngularVelocity = 230
AngularVelocityDamping = 0.0
AngularVelocityRandom = 1.0
Gravity = [0.0, 0.0]
LinearVelocityRandom = 0.5
ScaleBegin = 0.1
ScaleEnd = 5.0
ColorBegin = [255, 162, 0]
ColorEnd = [0, 0, 0, 0]
The ParticleEmitter
accepts a ResourceRef<ParticleEmitterSettings>
instead of ParticleEmitterSettings
directly in its constructor. The system itself listens to ResourceManager reloading its resources, and does reloading accordingly. This is only needed because we need to react to changes in the MaxParticles
property, which requires reallocating an array of a different size for the particles.
I haven't implemented hot reload for resources stored on the GPU, but it will probably involve separate systems that will react to resource reloads and then reupload textures to the GPU. Once I'll figure this and a few other improvements out I'll write a separate post, but the groundwork seems solid enough.
Virtual File System (VFS)
I want my framework to be cross-platform. Some targets, like web or Android, need different ways to load files or resources. Not only that, it could be useful to introduce some sort of archive format with optimized representation of resources to avoid the "reading" part entirely. I can't use desktop file loading APIs provided by .NET, so I need to create an abstraction.
VirtualFileSystem
(VFS) is an abstraction, or to be precise, an implementation over several file system types. We shouldn't care where these resources get loaded from: a file system, archive, network, or in-memory buffer: we just need the data itself to be able to read and use.
The VFS supports multiple mount points. To put in other words, you can specify that your resources are located in the Resources/
folder in the file system relative to the executable, or if they're contained in some Resources.pak
archive. Or both at the same time.
A good example of this is having different archives of assets based on language. Normally, we don't want to ship all resources in all languages that the game supports, but rather let the user choose one in a service like Steam, which will download a package containing their preferred language to be later used instead.
By having an abstraction like that, we solve problems like these. But how to actually implement one?
IVirtualMountPoint
/// <summary>
/// A virtual mounting point.
/// </summary>
public interface IVirtualMountPoint
{
/// <summary>
/// Order of mounting for this mount point. Lower values indicate higher priority for lookup.
/// </summary>
int Order { get; }
/// <summary>
/// Mounts this <see cref="IVirtualMountPoint"/>.
/// </summary>
void Mount();
/// <summary>
/// Gets a file.
/// </summary>
/// <param name="path">Relative path to the file.</param>
/// <returns>An instance of <see cref="VirtualFile"/> if the file exists; otherwise, an exception is thrown.</returns>
VirtualFile GetFile(string path);
/// <summary>
/// Gets all files available at this <see cref="IVirtualMountPoint"/>.
/// </summary>
/// <returns>A dictionary mapping a relative path to an instance of a <see cref="VirtualFile"/>.</returns>
IDictionary<string, VirtualFile> GetFiles();
/// <summary>
/// Determines whether a file exists at the given relative path within this mount point.
/// </summary>
/// <param name="path">The relative path of the file to check.</param>
/// <returns><c>true</c> if the file exists; otherwise, <c>false</c>.</returns>
bool HasFile(string path);
}
Every source of files that contains resources implements this interface. Alongside IVirtualMountPoint
, you also need to extend VirtualFile
, which should return a stream used for reading:
public abstract class VirtualFile
{
public abstract Stream GetStream();
}
For now, I only have an implementation for .NET file system API, done with FileSystemMountPoint
and FileSystemFile
.
Before we can do anything with VirtualFileSystem
, a mounting point has to be mounted to it. It will load all files available at this mounting point, and order the list of them based on the order.
An order essentially specifies the priority of a given mount point. Game's main assets will get loaded first, with packages for different languages after that, and finally, the packages for mods. If a given file already exists at a given path, it will get overriden by a mounting point with the lowest order.
Conclusion
As it stands, this is how I manage resource/asset loading in my framework. It's already super useful to just effortlessly edit data files and seeing changes live, without the need of restarting the game. Obviously, there's still plenty of room for improvement, including an asynchronous loading API, and general performance optimizations.
I don't want to unnecessary increase the scope of the framework, so one of the priorities is to work towards a small proof of concept game that will heavily utilize this resource loading system to better identify issues with the approach.
Up next, I want to focus more on establishing an efficient workflow for creating UI using the same "edit -> reload" approach, as well as making a less annoying API for actually interacting with it, but I'll leave these for another post!