Skip to content

Commit 46b8e90

Browse files
Added Method to Allow Pipelined Asset Loading (#10565)
# Objective - Fixes #10518 ## Solution I've added a method to `LoadContext`, `load_direct_with_reader`, which mirrors the behaviour of `load_direct` with a single key difference: it is provided with the `Reader` by the caller, rather than getting it from the contained `AssetServer`. This allows for an `AssetLoader` to process its `Reader` stream, and then directly hand the results off to the `LoadContext` to handle further loading. The outer `AssetLoader` can control how the `Reader` is interpreted by providing a relevant `AssetPath`. For example, a Gzip decompression loader could process the asset `images/my_image.png.gz` by decompressing the bytes, then handing the decompressed result to the `LoadContext` with the new path `images/my_image.png.gz/my_image.png`. This intuitively reflects the nature of contained assets, whilst avoiding unintended behaviour, since the generated path cannot be a real file path (a file and folder of the same name cannot coexist in most file-systems). ```rust #[derive(Asset, TypePath)] pub struct GzAsset { pub uncompressed: ErasedLoadedAsset, } #[derive(Default)] pub struct GzAssetLoader; impl AssetLoader for GzAssetLoader { type Asset = GzAsset; type Settings = (); type Error = GzAssetLoaderError; fn load<'a>( &'a self, reader: &'a mut Reader, _settings: &'a (), load_context: &'a mut LoadContext, ) -> BoxedFuture<'a, Result<Self::Asset, Self::Error>> { Box::pin(async move { let compressed_path = load_context.path(); let file_name = compressed_path .file_name() .ok_or(GzAssetLoaderError::IndeterminateFilePath)? .to_string_lossy(); let uncompressed_file_name = file_name .strip_suffix(".gz") .ok_or(GzAssetLoaderError::IndeterminateFilePath)?; let contained_path = compressed_path.join(uncompressed_file_name); let mut bytes_compressed = Vec::new(); reader.read_to_end(&mut bytes_compressed).await?; let mut decoder = GzDecoder::new(bytes_compressed.as_slice()); let mut bytes_uncompressed = Vec::new(); decoder.read_to_end(&mut bytes_uncompressed)?; // Now that we have decompressed the asset, let's pass it back to the // context to continue loading let mut reader = VecReader::new(bytes_uncompressed); let uncompressed = load_context .load_direct_with_reader(&mut reader, contained_path) .await?; Ok(GzAsset { uncompressed }) }) } fn extensions(&self) -> &[&str] { &["gz"] } } ``` Because this example is so prudent, I've included an `asset_decompression` example which implements this exact behaviour: ```rust fn main() { App::new() .add_plugins(DefaultPlugins) .init_asset::<GzAsset>() .init_asset_loader::<GzAssetLoader>() .add_systems(Startup, setup) .add_systems(Update, decompress::<Image>) .run(); } fn setup(mut commands: Commands, asset_server: Res<AssetServer>) { commands.spawn(Camera2dBundle::default()); commands.spawn(( Compressed::<Image> { compressed: asset_server.load("data/compressed_image.png.gz"), ..default() }, Sprite::default(), TransformBundle::default(), VisibilityBundle::default(), )); } fn decompress<A: Asset>( mut commands: Commands, asset_server: Res<AssetServer>, mut compressed_assets: ResMut<Assets<GzAsset>>, query: Query<(Entity, &Compressed<A>)>, ) { for (entity, Compressed { compressed, .. }) in query.iter() { let Some(GzAsset { uncompressed }) = compressed_assets.remove(compressed) else { continue; }; let uncompressed = uncompressed.take::<A>().unwrap(); commands .entity(entity) .remove::<Compressed<A>>() .insert(asset_server.add(uncompressed)); } } ``` A key limitation to this design is how to type the internally loaded asset, since the example `GzAssetLoader` is unaware of the internal asset type `A`. As such, in this example I store the contained asset as an `ErasedLoadedAsset`, and leave it up to the consumer of the `GzAsset` to handle typing the final result, which is the purpose of the `decompress` system. This limitation can be worked around by providing type information to the `GzAssetLoader`, such as `GzAssetLoader<Image, ImageAssetLoader>`, but this would require registering the asset loader for every possible decompression target. Aside from this limitation, nested asset containerisation works as an end user would expect; if the user registers a `TarAssetLoader`, and a `GzAssetLoader`, then they can load assets with compound containerisation, such as `images.tar.gz`. --- ## Changelog - Added `LoadContext::load_direct_with_reader` - Added `asset_decompression` example ## Notes - While I believe my implementation of a Gzip asset loader is reasonable, I haven't included it as a public feature of `bevy_asset` to keep the scope of this PR as focussed as possible. - I have included `flate2` as a `dev-dependency` for the example; it is not included in the main dependency graph.
1 parent 17e5097 commit 46b8e90

File tree

5 files changed

+207
-0
lines changed

5 files changed

+207
-0
lines changed

Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ bevy_internal = { path = "crates/bevy_internal", version = "0.12.0", default-fea
282282
[dev-dependencies]
283283
rand = "0.8.0"
284284
ron = "0.8.0"
285+
flate2 = "1.0"
285286
serde = { version = "1", features = ["derive"] }
286287
bytemuck = "1.7"
287288
# Needed to poll Task examples
@@ -1077,6 +1078,17 @@ description = "Demonstrates various methods to load assets"
10771078
category = "Assets"
10781079
wasm = false
10791080

1081+
[[example]]
1082+
name = "asset_decompression"
1083+
path = "examples/asset/asset_decompression.rs"
1084+
doc-scrape-examples = true
1085+
1086+
[package.metadata.example.asset_decompression]
1087+
name = "Asset Decompression"
1088+
description = "Demonstrates loading a compressed asset"
1089+
category = "Assets"
1090+
wasm = false
1091+
10801092
[[example]]
10811093
name = "custom_asset"
10821094
path = "examples/asset/custom_asset.rs"

assets/data/compressed_image.png.gz

15 KB
Binary file not shown.

crates/bevy_asset/src/loader.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,62 @@ impl<'a> LoadContext<'a> {
562562
self.loader_dependencies.insert(path, hash);
563563
Ok(loaded_asset)
564564
}
565+
566+
/// Loads the asset at the given `path` directly from the provided `reader`. This is an async function that will wait until the asset is fully loaded before
567+
/// returning. Use this if you need the _value_ of another asset in order to load the current asset, and that value comes from your [`Reader`].
568+
/// For example, if you are deriving a new asset from the referenced asset, or you are building a collection of assets. This will add the `path` as a
569+
/// "load dependency".
570+
///
571+
/// If the current loader is used in a [`Process`] "asset preprocessor", such as a [`LoadAndSave`] preprocessor,
572+
/// changing a "load dependency" will result in re-processing of the asset.
573+
///
574+
/// [`Process`]: crate::processor::Process
575+
/// [`LoadAndSave`]: crate::processor::LoadAndSave
576+
pub async fn load_direct_with_reader<'b>(
577+
&mut self,
578+
reader: &mut Reader<'_>,
579+
path: impl Into<AssetPath<'b>>,
580+
) -> Result<ErasedLoadedAsset, LoadDirectError> {
581+
let path = path.into().into_owned();
582+
583+
let loader = self
584+
.asset_server
585+
.get_path_asset_loader(&path)
586+
.await
587+
.map_err(|error| LoadDirectError {
588+
dependency: path.clone(),
589+
error: error.into(),
590+
})?;
591+
592+
let meta = loader.default_meta();
593+
594+
let loaded_asset = self
595+
.asset_server
596+
.load_with_meta_loader_and_reader(
597+
&path,
598+
meta,
599+
&*loader,
600+
reader,
601+
false,
602+
self.populate_hashes,
603+
)
604+
.await
605+
.map_err(|error| LoadDirectError {
606+
dependency: path.clone(),
607+
error,
608+
})?;
609+
610+
let info = loaded_asset
611+
.meta
612+
.as_ref()
613+
.and_then(|m| m.processed_info().as_ref());
614+
615+
let hash = info.map(|i| i.full_hash).unwrap_or_default();
616+
617+
self.loader_dependencies.insert(path, hash);
618+
619+
Ok(loaded_asset)
620+
}
565621
}
566622

567623
/// An error produced when calling [`LoadContext::read_asset_bytes`]

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Example | Description
181181

182182
Example | Description
183183
--- | ---
184+
[Asset Decompression](../examples/asset/asset_decompression.rs) | Demonstrates loading a compressed asset
184185
[Asset Loading](../examples/asset/asset_loading.rs) | Demonstrates various methods to load assets
185186
[Asset Processing](../examples/asset/processing/asset_processing.rs) | Demonstrates how to process and load custom assets
186187
[Custom Asset](../examples/asset/custom_asset.rs) | Implements a custom asset loader

examples/asset/asset_decompression.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Implements loader for a Gzip compressed asset.
2+
3+
use bevy::utils::thiserror;
4+
use bevy::{
5+
asset::{
6+
io::{Reader, VecReader},
7+
AssetLoader, AsyncReadExt, ErasedLoadedAsset, LoadContext, LoadDirectError,
8+
},
9+
prelude::*,
10+
reflect::TypePath,
11+
utils::BoxedFuture,
12+
};
13+
use flate2::read::GzDecoder;
14+
use std::io::prelude::*;
15+
use std::marker::PhantomData;
16+
use thiserror::Error;
17+
18+
#[derive(Asset, TypePath)]
19+
pub struct GzAsset {
20+
pub uncompressed: ErasedLoadedAsset,
21+
}
22+
23+
#[derive(Default)]
24+
pub struct GzAssetLoader;
25+
26+
/// Possible errors that can be produced by [`GzAssetLoader`]
27+
#[non_exhaustive]
28+
#[derive(Debug, Error)]
29+
pub enum GzAssetLoaderError {
30+
/// An [IO](std::io) Error
31+
#[error("Could not load asset: {0}")]
32+
Io(#[from] std::io::Error),
33+
/// An error caused when the asset path cannot be used ot determine the uncompressed asset type.
34+
#[error("Could not determine file path of uncompressed asset")]
35+
IndeterminateFilePath,
36+
/// An error caused by the internal asset loader.
37+
#[error("Could not load contained asset: {0}")]
38+
LoadDirectError(#[from] LoadDirectError),
39+
}
40+
41+
impl AssetLoader for GzAssetLoader {
42+
type Asset = GzAsset;
43+
type Settings = ();
44+
type Error = GzAssetLoaderError;
45+
fn load<'a>(
46+
&'a self,
47+
reader: &'a mut Reader,
48+
_settings: &'a (),
49+
load_context: &'a mut LoadContext,
50+
) -> BoxedFuture<'a, Result<Self::Asset, Self::Error>> {
51+
Box::pin(async move {
52+
let compressed_path = load_context.path();
53+
let file_name = compressed_path
54+
.file_name()
55+
.ok_or(GzAssetLoaderError::IndeterminateFilePath)?
56+
.to_string_lossy();
57+
let uncompressed_file_name = file_name
58+
.strip_suffix(".gz")
59+
.ok_or(GzAssetLoaderError::IndeterminateFilePath)?;
60+
let contained_path = compressed_path.join(uncompressed_file_name);
61+
62+
let mut bytes_compressed = Vec::new();
63+
64+
reader.read_to_end(&mut bytes_compressed).await?;
65+
66+
let mut decoder = GzDecoder::new(bytes_compressed.as_slice());
67+
68+
let mut bytes_uncompressed = Vec::new();
69+
70+
decoder.read_to_end(&mut bytes_uncompressed)?;
71+
72+
// Now that we have decompressed the asset, let's pass it back to the
73+
// context to continue loading
74+
75+
let mut reader = VecReader::new(bytes_uncompressed);
76+
77+
let uncompressed = load_context
78+
.load_direct_with_reader(&mut reader, contained_path)
79+
.await?;
80+
81+
Ok(GzAsset { uncompressed })
82+
})
83+
}
84+
85+
fn extensions(&self) -> &[&str] {
86+
&["gz"]
87+
}
88+
}
89+
90+
#[derive(Component, Default)]
91+
struct Compressed<T> {
92+
compressed: Handle<GzAsset>,
93+
_phantom: PhantomData<T>,
94+
}
95+
96+
fn main() {
97+
App::new()
98+
.add_plugins(DefaultPlugins)
99+
.init_asset::<GzAsset>()
100+
.init_asset_loader::<GzAssetLoader>()
101+
.add_systems(Startup, setup)
102+
.add_systems(Update, decompress::<Image>)
103+
.run();
104+
}
105+
106+
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
107+
commands.spawn(Camera2dBundle::default());
108+
109+
commands.spawn((
110+
Compressed::<Image> {
111+
compressed: asset_server.load("data/compressed_image.png.gz"),
112+
..default()
113+
},
114+
Sprite::default(),
115+
TransformBundle::default(),
116+
VisibilityBundle::default(),
117+
));
118+
}
119+
120+
fn decompress<A: Asset>(
121+
mut commands: Commands,
122+
asset_server: Res<AssetServer>,
123+
mut compressed_assets: ResMut<Assets<GzAsset>>,
124+
query: Query<(Entity, &Compressed<A>)>,
125+
) {
126+
for (entity, Compressed { compressed, .. }) in query.iter() {
127+
let Some(GzAsset { uncompressed }) = compressed_assets.remove(compressed) else {
128+
continue;
129+
};
130+
131+
let uncompressed = uncompressed.take::<A>().unwrap();
132+
133+
commands
134+
.entity(entity)
135+
.remove::<Compressed<A>>()
136+
.insert(asset_server.add(uncompressed));
137+
}
138+
}

0 commit comments

Comments
 (0)