About two months ago, I decided to get started with Amethyst, a data-driven game engine written in Rust. However, I didn’t quite like the asset management, especially because it blocks the main thread. In this blog post, I describe how I designed the asset management for Amethyst.
First experiments
At first, I thought let’s try out these very promising futures. However, it turned out they weren’t as ergonomic as they seemed to be.
For one, you have to either wait()
for them or poll()
them. The first one obviously doesn’t make sense. The second
one is not really nice, because you have to check:
“Do I have my monkey, the grass texture, my M4A1 gun model and my floor
texture loaded up already?” And doing this doesn’t really
work out in a nice way.
Another option I considered was just doing a callback which basically notifies the caller that the asset was loaded. However, this has similar problems and you cannot switch the state, which would be a very typical action (if you are seeing the loading screen at the moment and all the assets are finished).
Another thing I realized was that you almost never want to just know that one asset is finished now, but a whole set of them. This was one of the first design decision.
The AssetSet
An AssetSet
groups multiple assets which logically
belong together (for example because they are all needed for the
same level).
It’s just a trait which returns a builder like this:
trait AssetSet {
type Builder: AssetSetBuilder<Self>;
fn builder() -> Builder;
}
trait AssetSetBuilder<T: Sized> {
/// Submit all the assets to the asset loader
fn submit(&self, loader: &mut AssetLoader);
/// Returns the next asset which is required
/// for building this asset set.
fn next_asset(&self) -> Option<(TypeId, &str)>;
/// Sets this asset.
fn set(&mut self, id: (TypeId, &str), asset: Box<Any>);
fn name(&self) -> &str;
fn build() -> T;
}
As you can guess, you don’t want to implement
this yourself for every group of assets.
So we basically want some way to implement
this trait, which also allows us to pass
a format or an AssetStore
.
What about #[derive]?
I think derive matches the above requirements very good.
Declaring an asset set is then as easy as:
#[derive(AssetSet)]
struct MyGroupOfAssets {
#[format(Obj)]
monkey: Mesh, // will load a mesh from resources/meshes/monkey.obj
#[from(path = "subfolder/floor")]
floor: Material, // loads a material from resources/materials/floor.*
// (figures out which format)
#[from(store = "network_store")]
map: Mesh, // will load the map from some network stream
// ignored because it does not implement
// `Asset`.
network_store: NetworkStore,
}
This derive will then generate an implementation of AssetSet
,
a builder struct and an AssetSetBuilder
implementation.
Assets
Now let’s look at what an Asset
actually is:
trait Asset {
type Data;
type Error;
fn from_data(data: Self::Data, ...);
}
It only provides a function to convert plain data
into an actual asset. This allows us to have
as many formats as we like, which just have to
generate some Asset::Data
. Additionally,
this way we can later reuse this Data
to convert one format to another.
The AssetLoader
Okay, but I still didn’t answer how the user should handle this.
Basically, the user just tells the AssetLoader
to load
such a set by calling
loader.load_set::<MyGroupOfAssets>();
As soon as the asset manager finished one set, it will notify the user with an event. This might look something like this:
fn handle_event(engine: Engine, event: Event) -> Trans {
match event {
Event::AssetSetLoaded(set) => {
if let Ok(my_group) = set.downcast::<MyGroupOfAssets>() {
engine.planner.mut_world().add_resource(my_group)
Trans::Switch(Box::new(Level1State))
}
}
_ => {}
}
}
How it works internally
The AssetLoader
loads everything1
asynchronously using a a shared thread pool (which is also
used for the ECS and for rendering).
As soon as an Asset
is loaded, it will check if one of
the asset sets are completed now. If that’s the case, an
Event
will be emitted.
Even more flexibility
If you have a very special case and you only want
to load a single asset, you can also do that.
Or if you want to load multiple assets, but
you want some very custom handling, you just
provide a callback function which can react on
a finished function (that’s actually how the
default case is implemented: a callback will emit
an Event
if an AssetSet
is finished).
And all this comes with very much flexibility, because
you can do whatever you want when the event comes in.
Additionally, you can just implement the AssetSetBuilder
yourself or just submit a single asset. But after creating
all these assets, how are they freed again?
Asset Freeing
This is actually quite tricky. One proposal was to associate
asset lifetime with a State
meaning as long as the state
is in the stack, the assets stay alive, a pop will destroy
them all. What I liked most about this was that it nicely
integrates with these State
s, making them more meaningful.
However, if you come to Rust, I guess you want maximum performance and so do I. Making such a restriction like tying states and assets togegether and forcing a deallocation on a pop will most likely be worked around by some users, for example by doing asset management themselves.
Let’s look at what our assets actually are (meaning Texture
s,
Mesh
es and so on).
By looking at the Texture
component in Amethyst and walking
to the definition of the contained type, I could get to
RawShaderResourceView
:
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct RawShaderResourceView<R: Resources>(Arc<R::ShaderResourceView>, ViewSource<R>);
This essentially means that our assets are internally already managed,
so we can essentially just do a .clone()
.
And that’s actually fine and I think we can do it that way.
The real problems are that you
1) Do have your assets in an AssetSet
2) Don’t want to load you assets twice
So we have to store them in the AssetLoader
.
To do that, we can simply use a FnvHashMap
.
But now, because we have another clone of our asset around, the buffer is kept in the gpu memory, no matter what.
To explain my proposal here, consider the following
two AssetSet
s:
#[derive(AssetSet)]
struct Level1Assets {
floor: Material,
monkey: Mesh,
}
#[derive(AssetSet)]
struct Level2Assets {
floor: Material,
building: Mesh,
}
The key point here is that they both need
the floor
material, even though they don’t
logically belong together.
This means, when we have a state switch like this
fn handle_event(...) -> Trans {
match event {
...
Event::User("DOOR_ENTERED") => Trans::Switch(Box::new(Level2))
}
}
we don’t want the floor
freed, but only the monkey
.
To do that, we just submit our set of assets as usual,
and after we did that, we want to ask the asset loader
to free all unused assets, meaning the ones which have
only one reference (in the AssetLoader
)2.
Asset config files
A possible extension would be to allow defining these asset sets in config files, but that can still be done later and doesn’t have anything to do with the core part of the asset loading.
Conclusion
I really like the new asset management and I think it’s highly flexible and will allow us to do all kinds of convenient asset loading.
Feedback is highly appreciated! Please contact me on Gitter.
Next week I’ll actually implement this and eventually work out how you would handle very big worlds (where you need to load and unload assets on the fly).
1: Everything except OpenGL stuff, because OpenGL is single-threaded, so buffer creation happens through a channel.
2: Actually, getting the number of references
is a problem currently, because gfx
does not
have such a feature yet.