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.
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.
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.
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.
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).