Designing asset management in Amethyst
Apr 18, 2017
6 minute read

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 States, 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 Textures, Meshes 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 AssetSets:

#[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.