Nikl.me

Dynamic assets

01 March, 2022

One of my favorite Bevy projects at the moment is the plugin bevy_asset_loader (repository). Its goal is to minimize boilerplate for asset handling while improving code readability and maintainability for games with a lot of assets. I wrote about the motivation and basic idea of the plugin in a previous blog post. Most of the future functionality mentioned in the post is now implemented. The biggest feature added to bevy_asset_loader since then, was not discussed though. That's what this post is all about.

The idea

In the beginning of bevy_asset_loader, all the information needed to load a certain asset was given at compile time. Things like the file path, type information (load it as a standard material plz), or tile sizes for a sprite sheet are passed in derive macro attributes.

#[derive(AssetCollection)]
pub struct TextureAssets {
    #[asset(path = "textures/player.png")]
    pub player: Handle<Image>,
    #[asset(texture_atlas(tile_size_x = 64., tile_size_y = 32., columns = 6, rows = 1))]
    #[asset(path = "textures/dog_sprite_sheet.png")]
    pub dog: Handle<TextureAtlas>,
}

Giving all asset configuration at compile time.

At compile time the derive macro will "hardcode" the file paths and other configuration in the generated code to load the assets. The information directly on the asset collection structs is convenient and reads very well. But there is an issue with this.

If different people (or even different teams) take care of assets and coding, the information seems to be at the wrong place. Tile sizes for a sprite sheet might change with updates on the sprite sheet. Changing this configuration feels like it should happen outside the code. The configuration for an asset should be an asset itself.

Dynamic assets

In some way, configuration and asset handle still need to be connected. I went for the simplest approach and use string keys to connect them. These keys need to be given in our asset collections as derive macro attributes. From now on I will call assets "dynamic" if they have a string key and no other hardcoded asset configuration like a file path.

#[derive(AssetCollection)]
pub struct TextureAssets {
    #[asset(key = "player")]
    pub player: Handle<Image>,
    #[asset(key = "dog")]
    pub dog: Handle<TextureAtlas>,
}

An asset collection with "dynamic assets".

At compile time, the file paths and texture atlas configuration are not known. The code generated by the AssetCollection derive macro uses the string key to grab all asset configuration at run time. bevy_asset_loader stores the key <-> asset configuration mapping in a resource. Users can either manage it manually and configure all used keys before the loading state, or load everything from ron files.

fn setup_system(
    mut asset_keys: ResMut<AssetKeys>,
) {
    asset_keys.register_asset(
        "player",
        DynamicAsset::File {
            path: "textures/player.png".to_owned(),
        },
    );
    asset_keys.register_asset(
        "dog",
        DynamicAsset::TextureAtlas {
            path: "textures/dog_sprite_sheet.png".to_owned(),
            tile_size_x: 64.,
            tile_size_y: 32.,
            columns: 6,
            rows: 1
        },
    );
}

Manually registering the required asset keys to load the TextureAssets asset collection.

Manual registration does not solve the original problem. The asset configuration is still a part of our code. This is why I think the main use case of dynamic assets is the possibility to load key <-> asset configuration mappings from ron files. If you enable the feature dynamic_assets, the following file can be loaded by bevy_asset_loader. All asset keys will be automatically registered before dynamic assets are resolved.

({
    "player": File (
        path: "textures/player.png",
    ),
    "dog": TextureAtlas (
        path: "textures/dog_sprite_sheet.png",
        tile_size_x: 64.,
        tile_size_y: 32.,
        columns: 6,
        rows: 1
    ),
})

File in ron notation containing asset keys and their configurations. By default, the file ending should be .assets.

With such a ron file, all configurations regarding your assets are assets themselves. A little setup is required to tell bevy_asset_loader which ron files should be loaded before which loading state.

fn main() {
    let mut app = App::new();
    app.add_plugins(DefaultPlugins);

    AssetLoader::new(MyStates::AssetLoading)
        .continue_to_state(MyStates::Next)
        .with_asset_collection_file("dynamic_asset_ron.assets")
        .with_collection::<TextureAssets>()
        .build(&mut app);
    app.run();
}

Setup to load asset configurations from a ron file.

That's it. We now moved all asset configuration out of our code.

Supported extras

I showed examples for loading a texture atlas and "simple" files as dynamic assets. You can also load an image directly as a StandardMaterial or load a complete directory as a vector of untyped handles. With dynamic assets it is also possible to have optional fields on asset collections. If the key cannot be resolved at run time, the field will be None.

In some cases, it is required to give more information at compile time than just the key. For example, when loading a directory, the field needs to be additionally annotated with #[asset(folder)]. In the same way, optional fields need #[asset(optional)]. This is a limitation I have not been able to lift, because I need to handle return types that are different from Handle<T> separately at compile time.

Some open issues

There are a few things I am not yet happy with. Admittedly, the API to load .assets files feels a bit clunky. One of the original ideas for this new functionality was that it should be easy to internationalize assets. If you have assets that need to be localized, you could configure them in different ron files and load them according to the user language. Currently, this would require the usage of internal APIs.

I think the biggest missing feature at this point is an ergonomic way to define dependencies between asset collections. It should be possible to load .assets files as part of collections and tell bevy_asset_loader to load and apply certain collections before others.

There is also more design work to be done when thinking about different loading states in a "real" game. There are multiple szenarios in which a game would want to change the loaded assets. For example, localized assets need to be reloaded when the user changes the game language at run time. Even more common: the player might be switching between different levels requiring different asset collections to get loaded and unloaded.

At the moment, all .assets files only get loaded and applied the first time a loading state is entered. If the same key gets overwritten in a later loading state, it's value would be wrong when re-entering the first loading state again.


Thank you for reading! If you have any feedback, questions, or comments, you can find me at @nikl_me@mastodon.online or on the Bevy Discord server (@nikl).

Nikl.me © 2024