NiklasEi / bevy_asset_loader

Bevy plugin helping with asset loading and organization
Apache License 2.0
481 stars 52 forks source link

Feature: Multi loading for Atlases #186

Closed lgrossi closed 7 months ago

lgrossi commented 8 months ago

Context

First of all, thanks a lot for this crate, it makes asset loading incredibly less convoluted. I'm using it in different situations for now and it works greatly. The only challenge I faced so far was the lack of possibility of loading a folder of atlases or multiple atlases files, when dealing with a large amount of sprite atlases that are always the same.

I'll give some examples to illustrate:

Basic case: a folder with many atlases that have the same grid

#[derive(AssetCollection, Resource)]
pub struct SpriteAssets {
    #[asset(texture_atlas(tile_size_x = 96., tile_size_y = 99., columns = 8, rows = 1))]
    #[asset(image(sampler = nearest))]
    #[asset(path = "sprite-sheets-96", collection(typed, mapped))]
    pub atlases: HashMap<String, Handle<TextureAtlas>>,
}

Multiple assets grids

#[derive(AssetCollection, Resource)]
pub struct SpriteAssets {
    #[asset(texture_atlas(tile_size_x = 96., tile_size_y = 99., columns = 8, rows = 1))]
    #[asset(image(sampler = nearest))]
    #[asset(path = "sprite-sheets-96", collection(typed, mapped))]
    pub 96_96_atlases: HashMap<String, Handle<TextureAtlas>>,
    #[asset(texture_atlas(tile_size_x = 32., tile_size_y = 32., columns = 12, rows = 12))]
    #[asset(image(sampler = nearest))]
    #[asset(path = "sprite-sheets-32", collection(typed, mapped))]
    pub 32_32_atlases: HashMap<String, Handle<TextureAtlas>>,
}

Dynamic Load

#[derive(AssetCollection, Resource)]
pub struct SpriteAssets {
    #[asset(texture_atlas(tile_size_x = 96., tile_size_y = 99., columns = 8, rows = 1))]
    #[asset(image(sampler = nearest))]
    #[asset(key = "sprite-sheets-96", collection(typed, mapped))]
    pub atlases: HashMap<String, Handle<TextureAtlas>>,
    #[asset(texture_atlas(tile_size_x = 32., tile_size_y = 32., columns = 12, rows = 12))]
    #[asset(image(sampler = nearest))]
    #[asset(key = "sprite-sheets-32", collection(typed, mapped))]
    pub atlases: HashMap<String, Handle<TextureAtlas>>,
}

fn ... {
  // some logic to determine if a sheet is 96
  dynamic_assets.register_asset(
            "sprite-sheets-96",
            Box::new(StandardDynamicAsset::File {
                path: "sprite-sheets-96/{sheet_name}".to_owned(),
            }),
        );

  // some logic to determine if a sheet is 32
  dynamic_assets.register_asset(
            "sprite-sheets-32",
            Box::new(StandardDynamicAsset::File {
                path: "sprite-sheets-32/{sheet_name}".to_owned(),
            }),
        );
}

Workarounds

Currently, what I do to achieve the same is basically to load the folder with all sheets and build the grids based on the configs I have for each grid group. It's already way nicer to do so than manually loading everything, but is still a bit convoluted.

#[derive(AssetCollection, Resource)]
pub struct SpriteAssets {
    #[cfg(feature = "pre_loaded_sprites")]
    #[asset(path = "sprite-sheets", collection(typed, mapped))]
    pub sprite_sheets: HashMap<String, Handle<Image>>,
    #[asset(path = "mascot.png")]
    pub mascot: Handle<Image>,
}

...

```rust
#[allow(dead_code)]
fn sprites_preparer(
    sprites: Res<Sprites>,
    sprite_assets: Res<SpriteAssets>,
    mut state: ResMut<NextState<AppStates>>,
    mut atlas_handlers: ResMut<TextureAtlasHandlers>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    info!("Preparing sprites");

    #[cfg(feature = "pre_loaded_sprites")]
    {
        let Some(sheets) = &sprites.sheets else {
            panic!("Sprite sheets configs were not setup.");
        };

        for (file, handle) in &sprite_assets.sprite_sheets {
            let file = match file.strip_prefix("sprite-sheets/") {
                Some(file) => file,
                None => file,
            };

            if atlas_handlers.get(file).is_some() {
                warn!("Skipping file {}: it's already loaded", file);
                continue;
            }

            let Some(sprite_sheet) = &sheets.get_for_file(file) else {
                warn!("Skipping file {}: it's not in sprite sheets", file);
                continue;
            };

            let atlas = TextureAtlas::from_grid(
                handle.clone(),
                sprite_sheet.get_tile_size(&sheets.sheet_config).as_vec2(),
                sprite_sheet.get_columns_count(&sheets.sheet_config),
                sprite_sheet.get_rows_count(&sheets.sheet_config),
                None,
                None,
            );

            let atlas_handle = texture_atlases.add(atlas.clone());
            atlas_handlers.insert(&sprite_sheet.file, atlas_handle);
        }
    }

    state.set(AppStates::Ready);

    info!("Finished preparing sprites");
}

The downside of this approach is higher memory usage than using just the atlases, when getting rid of the Image handles right after. Also, I still kept the old flow (lazy loaded file by file) since it gives an alternative for faster loading and reduced memory usage, but lazy loading is limited, not every scenario deals well with it.

Feature Request

Would be super nice if this crate in the future allowed more custom ways to deal with a big amount of atlases like the one mentioned above. I'm not sure if the suggestions above are the best way to go, but I would love to spark discussions around this subject and possibilities.

NiklasEi commented 7 months ago

This might have been fixed by the changes to texture atlases in Bevy 0.13. The layout and the sprites are now separated:

#[derive(AssetCollection, Resource)]
pub struct SpriteAssets {
    #[asset(texture_atlas(tile_size_x = 96., tile_size_y = 96., columns = 8, rows = 1))]
    pub atlas_layout_96_96: Handle<TextureAtlasLayout>,
    #[asset(image(sampler = nearest))]
    #[asset(path = "sprite-sheets-96", collection(typed, mapped))]
    pub sprite_sheets_96_96: HashMap<String, Handle<Image>>,

    #[asset(texture_atlas(tile_size_x = 32., tile_size_y = 32., columns = 12, rows = 12))]
    pub atlas_layout_32_32: Handle<TextureAtlasLayout>,
    #[asset(image(sampler = nearest))]
    #[asset(path = "sprite-sheets-32", collection(typed, mapped))]
    pub sprite_sheets_32_32: HashMap<String, Handle<Image>>,
}

The layout of an atlas is no longer connected to an asset file and can be reused for as many image files as you like. See the changed Bevy example on how to use the layout with the image handles.

lgrossi commented 7 months ago

Yes, the refactor that Bevy did on the way atlas are handled in 0.13 is great, it is now unified with the way single sprites are handled and it solves a lot of drawbacks of the previous design, super happy with it. I think we can close this one for now, I'll re-open it if I find any limitations in the new approach.

lgrossi commented 7 months ago

@NiklasEi One thing that showed up while taking this approach is that I now need a key for each layout. I wonder if it's possible to dynamically load all layouts in an array of layouts from ron instead.

Any thoughts on that?

E.g. having:

({
    "layout.one_by_one": TextureAtlasLayout (
        tile_size_x: 32.,
        tile_size_y: 32.,
        columns: 12,
        rows: 12,
    ),
    "layout.one_by_two": TextureAtlasLayout (
        tile_size_x: 32.,
        tile_size_y: 64.,
        columns: 12,
        rows: 6,
    ),
    "layout.two_by_one": TextureAtlasLayout (
        tile_size_x: 64.,
        tile_size_y: 32.,
        columns: 6,
        rows: 12,
    ),
    "layout.two_by_two": TextureAtlasLayout (
        tile_size_x: 64.,
        tile_size_y: 64.,
        columns: 6,
        rows: 6,
    ),
})

I need to individually declare each layout like:

    #[asset(key = "layout.one_by_one")]
    atlas_layout: Handle<TextureAtlasLayout>,

Is there a way to have it multiple, like we do with file paths and folders?

    #[asset(key = "layouts", collection(typed, mapped))]
    files_typed_mapped: HashMap<String, Handle<TextureAtlasLayout>>,
NiklasEi commented 7 months ago

78 would allow to load them as a vector, but support for a map hasn't been on my mind so far.

Personally, I would prefer to load them one by one, because it's nicer to use a struct field directly than having to index it. Unless you want to be able to iterate over them?

lgrossi commented 7 months ago

Well, the whole setup is kinda of dynamic, so needing to access them one by one is a bit convoluted also it aims to be extendable (aka you can define your own layouts) so having each of them as a different struct or a different field in the struct would not fly.

What I'm doing right now is just doing custom asset where my custom asset is just CustomAsset(Vec), then I can do something like (ignore the naming, I still need to think better about it):

({
    "layout.main": MultiStandardDynamicAsset([
        TextureAtlasLayout (
            tile_size_x: 32.,
            tile_size_y: 32.,
            columns: 12,
            rows: 12,
        ),
        TextureAtlasLayout (
            tile_size_x: 32.,
            tile_size_y: 64.,
            columns: 12,
            rows: 6,
        ),
        TextureAtlasLayout (
            tile_size_x: 64.,
            tile_size_y: 32.,
            columns: 6,
            rows: 12,
        ),
        TextureAtlasLayout (
            tile_size_x: 64.,
            tile_size_y: 64.,
            columns: 6,
            rows: 6,
        ),
    ]),
})

Didn't stop to think about the mapping yet, tho.