Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 3 additions & 40 deletions crates/bevy_asset/src/processor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use crate::{
AssetProcessor, GetProcessorError, LoadTransformAndSave, LogEntry, Process, ProcessContext,
ProcessError, ProcessorState, ProcessorTransactionLog, ProcessorTransactionLogFactory,
},
saver::AssetSaver,
saver::{tests::CoolTextSaver, AssetSaver},
tests::{
read_asset_as_string, read_meta_as_string, run_app_until, CoolText, CoolTextLoader,
CoolTextRon, SubText,
Expand Down Expand Up @@ -426,43 +426,6 @@ fn run_app_until_finished_processing(app: &mut App, guard: RwLockWriteGuard<'_,
});
}

#[derive(TypePath)]
struct CoolTextSaver;

impl AssetSaver for CoolTextSaver {
type Asset = CoolText;
type Settings = ();
type OutputLoader = CoolTextLoader;
type Error = std::io::Error;

async fn save(
&self,
writer: &mut crate::io::Writer,
asset: crate::saver::SavedAsset<'_, '_, Self::Asset>,
_: &Self::Settings,
) -> Result<(), Self::Error> {
let ron = CoolTextRon {
text: asset.text.clone(),
sub_texts: asset
.iter_labels()
.map(|label| asset.get_labeled::<SubText>(label).unwrap().text.clone())
.collect(),
dependencies: asset
.dependencies
.iter()
.map(|handle| handle.path().unwrap().path())
.map(|path| path.to_str().unwrap().to_string())
.collect(),
// NOTE: We can't handle embedded dependencies in any way, since we need to write to
// another file to do so.
embedded_dependencies: vec![],
};
let ron = ron::ser::to_string_pretty(&ron, PrettyConfig::new().new_line("\n")).unwrap();
writer.write_all(ron.as_bytes()).await?;
Ok(())
}
}

// Note: while we allow any Fn, since closures are unnameable types, creating a processor with a
// closure cannot be used (since we need to include the name of the transformer in the meta
// file).
Expand Down Expand Up @@ -637,7 +600,7 @@ fn asset_processor_transforms_asset_with_meta() {
source_dir.insert_meta_text(path, r#"(
meta_format_version: "1.0",
asset: Process(
processor: "bevy_asset::processor::process::LoadTransformAndSave<bevy_asset::tests::CoolTextLoader, bevy_asset::processor::tests::RootAssetTransformer<bevy_asset::processor::tests::AddText, bevy_asset::tests::CoolText>, bevy_asset::processor::tests::CoolTextSaver>",
processor: "bevy_asset::processor::process::LoadTransformAndSave<bevy_asset::tests::CoolTextLoader, bevy_asset::processor::tests::RootAssetTransformer<bevy_asset::processor::tests::AddText, bevy_asset::tests::CoolText>, bevy_asset::saver::tests::CoolTextSaver>",
settings: (
loader_settings: (),
transformer_settings: (),
Expand Down Expand Up @@ -1759,7 +1722,7 @@ fn writes_default_meta_for_processor() {
r#"(
meta_format_version: "1.0",
asset: Process(
processor: "bevy_asset::processor::process::LoadTransformAndSave<bevy_asset::tests::CoolTextLoader, bevy_asset::processor::tests::RootAssetTransformer<bevy_asset::processor::tests::AddText, bevy_asset::tests::CoolText>, bevy_asset::processor::tests::CoolTextSaver>",
processor: "bevy_asset::processor::process::LoadTransformAndSave<bevy_asset::tests::CoolTextLoader, bevy_asset::processor::tests::RootAssetTransformer<bevy_asset::processor::tests::AddText, bevy_asset::tests::CoolText>, bevy_asset::saver::tests::CoolTextSaver>",
settings: (
loader_settings: (),
transformer_settings: (),
Expand Down
290 changes: 287 additions & 3 deletions crates/bevy_asset/src/saver.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
use crate::{
io::Writer, meta::Settings, transformer::TransformedAsset, Asset, AssetContainer, AssetLoader,
AssetPath, AssetServer, ErasedLoadedAsset, Handle, LabeledAsset, UntypedHandle,
io::{AssetWriterError, MissingAssetSourceError, MissingAssetWriterError, Writer},
meta::{AssetAction, AssetMeta, AssetMetaDyn, Settings},
transformer::TransformedAsset,
Asset, AssetContainer, AssetLoader, AssetPath, AssetServer, ErasedLoadedAsset, Handle,
LabeledAsset, UntypedHandle,
};
use alloc::{boxed::Box, string::ToString};
use alloc::{boxed::Box, string::ToString, sync::Arc};
use atomicow::CowArc;
use bevy_ecs::error::BevyError;
use bevy_platform::collections::HashMap;
use bevy_reflect::TypePath;
use bevy_tasks::{BoxedFuture, ConditionalSendFuture};
use core::{any::TypeId, borrow::Borrow, ops::Deref};
use futures_lite::AsyncWriteExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Saves an [`Asset`] of a given [`AssetSaver::Asset`] type. [`AssetSaver::OutputLoader`] will then be used to load the saved asset
/// in the final deployed application. The saver should produce asset bytes in a format that [`AssetSaver::OutputLoader`] can read.
Expand Down Expand Up @@ -255,6 +261,8 @@ impl<'a> LabeledSavedAsset<'a> {
}

/// A builder for creating [`SavedAsset`] instances (for use with asset saving).
///
/// This is commonly used in tandem with [`save_using_saver`].
pub struct SavedAssetBuilder<'a> {
/// The labeled assets for this saved asset.
labeled_assets: HashMap<&'a str, LabeledSavedAsset<'a>>,
Expand Down Expand Up @@ -398,3 +406,279 @@ impl<T> Deref for Moo<'_, T> {
}
}
}

/// Saves `asset` to `path` using the provided `saver` and `settings`.
pub async fn save_using_saver<S: AssetSaver>(
asset_server: AssetServer,
saver: &S,
path: &AssetPath<'_>,
asset: SavedAsset<'_, '_, S::Asset>,
settings: &S::Settings,
) -> Result<(), SaveAssetError> {
let source = asset_server.get_source(path.source())?;
let writer = source.writer()?;

let mut file_writer = writer.write(path.path()).await?;

let loader_settings = saver
.save(&mut file_writer, asset, settings)
.await
.map_err(|err| SaveAssetError::SaverError(Arc::new(err.into().into())))?;

file_writer.flush().await.map_err(AssetWriterError::Io)?;

let meta = AssetMeta::<S::OutputLoader, ()>::new(AssetAction::Load {
loader: S::OutputLoader::type_path().into(),
settings: loader_settings,
});

let meta = AssetMetaDyn::serialize(&meta);
writer.write_meta_bytes(path.path(), &meta).await?;

Ok(())
}

/// An error occurring when saving an asset.
#[derive(Error, Debug)]
pub enum SaveAssetError {
#[error(transparent)]
MissingSource(#[from] MissingAssetSourceError),
#[error(transparent)]
MissingWriter(#[from] MissingAssetWriterError),
#[error(transparent)]
WriterError(#[from] AssetWriterError),
#[error("Failed to save asset due to error from saver: {0}")]
SaverError(Arc<BevyError>),
}

#[cfg(test)]
pub(crate) mod tests {
use alloc::{string::ToString, vec, vec::Vec};
use bevy_reflect::TypePath;
use bevy_tasks::block_on;
use futures_lite::AsyncWriteExt;
use ron::ser::PrettyConfig;

use crate::{
saver::{save_using_saver, AssetSaver, SavedAsset, SavedAssetBuilder},
tests::{create_app, run_app_until, CoolText, CoolTextLoader, CoolTextRon, SubText},
AssetApp, AssetServer, Assets,
};

fn new_subtext(text: &str) -> SubText {
SubText {
text: text.to_string(),
}
}

#[derive(TypePath)]
pub struct CoolTextSaver;

impl AssetSaver for CoolTextSaver {
type Asset = CoolText;
type Settings = ();
type OutputLoader = CoolTextLoader;
type Error = std::io::Error;

async fn save(
&self,
writer: &mut crate::io::Writer,
asset: SavedAsset<'_, '_, Self::Asset>,
_: &Self::Settings,
) -> Result<(), Self::Error> {
let ron = CoolTextRon {
text: asset.text.clone(),
sub_texts: asset
.iter_labels()
.map(|label| asset.get_labeled::<SubText>(label).unwrap().text.clone())
.collect(),
dependencies: asset
.dependencies
.iter()
.map(|handle| handle.path().unwrap().path())
.map(|path| path.to_str().unwrap().to_string())
.collect(),
// NOTE: We can't handle embedded dependencies in any way, since we need to write to
// another file to do so.
embedded_dependencies: vec![],
};
Comment on lines +522 to +525
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// NOTE: We can't handle embedded dependencies in any way, since we need to write to
// another file to do so.
embedded_dependencies: vec![],
};
embedded_dependencies: vec![],
};
// NOTE: We can't handle embedded dependencies in any way, since we need to write to
// another file to do so.
assert!(asset.embedded_dependencies.is_empty());

Assert seems safer if this is only used in tests.

let ron = ron::ser::to_string_pretty(&ron, PrettyConfig::new().new_line("\n")).unwrap();
writer.write_all(ron.as_bytes()).await?;
Ok(())
}
}

#[test]
fn builds_saved_asset_for_new_asset() {
let mut app = create_app().0;

app.init_asset::<CoolText>()
.init_asset::<SubText>()
.register_asset_loader(CoolTextLoader);

// Update a few times before saving to show that assets can be entirely created from
// scratch.
app.update();
app.update();
app.update();

let hiya_subasset = new_subtext("hiya");
let goodbye_subasset = new_subtext("goodbye");
let idk_subasset = new_subtext("idk");

let asset_server = app.world().resource::<AssetServer>().clone();
let mut saved_asset_builder =
SavedAssetBuilder::new(asset_server.clone(), "some/target/path.cool.ron".into());
let hiya_handle = saved_asset_builder
.add_labeled_asset_with_new_handle("hiya", SavedAsset::from_asset(&hiya_subasset));
let goodbye_handle = saved_asset_builder.add_labeled_asset_with_new_handle(
"goodbye",
SavedAsset::from_asset(&goodbye_subasset),
);
let idk_handle = saved_asset_builder
.add_labeled_asset_with_new_handle("idk", SavedAsset::from_asset(&idk_subasset));

let main_asset = CoolText {
text: "wassup".into(),
sub_texts: vec![hiya_handle, goodbye_handle, idk_handle],
..Default::default()
};

let saved_asset = saved_asset_builder.build(&main_asset);
let mut asset_labels = saved_asset
.labeled_assets
.keys()
.copied()
.collect::<Vec<_>>();
asset_labels.sort();
assert_eq!(asset_labels, &["goodbye", "hiya", "idk"]);

{
let asset_server = asset_server.clone();
block_on(async move {
save_using_saver(
asset_server,
&CoolTextSaver,
&"some/target/path.cool.ron".into(),
saved_asset,
&(),
)
.await
})
.unwrap();
}

let readback = asset_server.load("some/target/path.cool.ron");
run_app_until(&mut app, |_| {
asset_server.is_loaded(&readback).then_some(())
});

let cool_text = app
.world()
.resource::<Assets<CoolText>>()
.get(&readback)
.unwrap();

let subtexts = app.world().resource::<Assets<SubText>>();
let mut asset_labels = cool_text
.sub_texts
.iter()
.map(|handle| subtexts.get(handle).unwrap().text.clone())
.collect::<Vec<_>>();
asset_labels.sort();
assert_eq!(asset_labels, &["goodbye", "hiya", "idk"]);
}

#[test]
fn builds_saved_asset_for_existing_asset() {
let (mut app, _) = create_app();

app.init_asset::<CoolText>()
.init_asset::<SubText>()
.register_asset_loader(CoolTextLoader);

let mut subtexts = app.world_mut().resource_mut::<Assets<SubText>>();
let hiya_handle = subtexts.add(new_subtext("hiya"));
let goodbye_handle = subtexts.add(new_subtext("goodbye"));
let idk_handle = subtexts.add(new_subtext("idk"));

let mut cool_texts = app.world_mut().resource_mut::<Assets<CoolText>>();
let cool_text_handle = cool_texts.add(CoolText {
text: "wassup".into(),
sub_texts: vec![
hiya_handle.clone(),
goodbye_handle.clone(),
idk_handle.clone(),
],
..Default::default()
});

let subtexts = app.world().resource::<Assets<SubText>>();
let cool_texts = app.world().resource::<Assets<CoolText>>();
let asset_server = app.world().resource::<AssetServer>().clone();
let mut saved_asset_builder =
SavedAssetBuilder::new(asset_server.clone(), "some/target/path.cool.ron".into());
saved_asset_builder.add_labeled_asset_with_existing_handle(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what happens if we're wrong about whether or not we already have a handle?

EG, say we don't realize this asset is already saved, and we save over it with a new labeled asset sans a handle.

What breaks, and does it break predictably?

Copy link
Contributor Author

@andriyDev andriyDev Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's clear what "existing" means is not quite clear. "Existing" in this context means that your root asset already contains a handle, whose asset you want to include in the saved asset as a subasset.

So if you save multiple times, you can keep calling add_labeled_asset_with_new_handle. It's just "more work" to do so with add_labeled_asset_with_new_handle since you need to store that handle in the root asset.

So like, the wrong thing to do would be to do something like:

  1. Clone an asset that is stored in Assets.
  2. For each of its subassets, you call add_labeled_asset_with_new_handle - and then do nothing with the handle.
  3. Call save.

This is wrong because now the asset that you cloned out of Assets will contain handles that don't have a corresponding subasset in the SavedAsset - the handles won't match. So from the perspective of the AssetSaver it will see handles that don't match what they get when they call get_handle(subasset_label). In a future PR, we should also have a get_by_handle - which will just return you None or something. The correct thing to do in this example is in step 2 to either use add_labeled_asset_with_existing_handle, or you could call add_labeled_asset_with_new_handle and store that handle in the root asset.

Btw, a missing handle is not necessarily a mistake - for example, if you have a StandardMaterial, the handles it stores are most likely just references to other files. In other words, if you accidentally use add_labeled_asset_with_new_handle and then don't store that handle, the AssetSaver may interpret that handle as a nested-loaded asset, and just serialize its path.

Edit: Added more documentation to explain when to use either function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually even that is a little wrong, since it's possible for an Asset to not hold handles to its subassets - the AssetSaver can still save these by iterating through the labeled assets in the SavedAsset.

But in the common case, the root asset stores handles, so we should optimize for that.

Copy link
Contributor

@IronGremlin IronGremlin Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that's part of my concern, and this seems like a good way to ensure that data passed through the save operation to a given subpath is the data that later gets referenced during load for a given subpath.

I am also concerned with data and handles that might have existed BEFORE that save operation though.

Consider the following:

  1. Asset exists with subassets in their respective asset stores. Someone calls asset_server.load("a_path.asset#subassetpath"). They store that handle somewhere, on an entity.
  2. Someone later saves an asset and associated subassets using add_labeled_asset_with_new_handle.
  3. Also, someone now, a second time, after the asset has been saved, calls asset_server.load("a_path.asset#subassetpath")

This presents two questions:

  1. As of step two, does the reference pointed to by the handle in step 1 now contain new data?
  2. Are the handles returned in step 1 and step 3 clones of the same handle?

I -think- that the answer to these questions is "yes," but we should assert this behavior under test

"hiya",
SavedAsset::from_asset(subtexts.get(&hiya_handle).unwrap()),
hiya_handle,
);
saved_asset_builder.add_labeled_asset_with_existing_handle(
"goodbye",
SavedAsset::from_asset(subtexts.get(&goodbye_handle).unwrap()),
goodbye_handle,
);
saved_asset_builder.add_labeled_asset_with_existing_handle(
"idk",
SavedAsset::from_asset(subtexts.get(&idk_handle).unwrap()),
idk_handle,
);

let saved_asset = saved_asset_builder.build(cool_texts.get(&cool_text_handle).unwrap());
let mut asset_labels = saved_asset
.labeled_assets
.keys()
.copied()
.collect::<Vec<_>>();
asset_labels.sort();
assert_eq!(asset_labels, &["goodbye", "hiya", "idk"]);

// While this example is supported, it is **not** recommended. This currently blocks the
// entire world from updating. A slow write could cause visible stutters. However we do this
// here to show it's possible to use assets directly out of the Assets resources.
{
let asset_server = asset_server.clone();
block_on(async move {
save_using_saver(
asset_server,
&CoolTextSaver,
&"some/target/path.cool.ron".into(),
saved_asset,
&(),
)
.await
})
.unwrap();
}

let readback = asset_server.load("some/target/path.cool.ron");
run_app_until(&mut app, |_| {
asset_server.is_loaded(&readback).then_some(())
});

let cool_text = app
.world()
.resource::<Assets<CoolText>>()
.get(&readback)
.unwrap();

let subtexts = app.world().resource::<Assets<SubText>>();
let mut asset_labels = cool_text
.sub_texts
.iter()
.map(|handle| subtexts.get(handle).unwrap().text.clone())
.collect::<Vec<_>>();
asset_labels.sort();
assert_eq!(asset_labels, &["goodbye", "hiya", "idk"]);
}
}
1 change: 1 addition & 0 deletions examples/asset/saved_assets/my_scene.boxes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
(boxes:[(position:(179.00002,153.99998),color:Srgba((red:-0.12673959,green:0.6866862,blue:0.40668184,alpha:1.0))),(position:(-223.0,165.0),color:Srgba((red:-0.0041340888,green:0.53728354,blue:1.0200645,alpha:1.0)))])
7 changes: 7 additions & 0 deletions examples/asset/saved_assets/my_scene.boxes.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
(
meta_format_version: "1.0",
asset: Load(
loader: "asset_saving::ManyBoxesLoader",
settings: (),
),
)