From eddf3a374851bb1e4f05f3488da7ce0413fd0ddc Mon Sep 17 00:00:00 2001 From: andriyDev Date: Fri, 7 Nov 2025 17:38:16 -0800 Subject: [PATCH 01/15] Store `AssetSource` as Arc in `AssetSources`. --- crates/bevy_asset/src/io/source.rs | 78 +++++++++++++----------- crates/bevy_asset/src/lib.rs | 4 +- crates/bevy_asset/src/loader_builders.rs | 11 +++- crates/bevy_asset/src/processor/mod.rs | 11 ++-- crates/bevy_asset/src/server/mod.rs | 17 +++++- 5 files changed, 75 insertions(+), 46 deletions(-) diff --git a/crates/bevy_asset/src/io/source.rs b/crates/bevy_asset/src/io/source.rs index 4b04198d15aeb..682a2432666fb 100644 --- a/crates/bevy_asset/src/io/source.rs +++ b/crates/bevy_asset/src/io/source.rs @@ -165,13 +165,15 @@ impl AssetSourceBuilder { } } - /// Builds a new [`AssetSource`] with the given `id`. If `watch` is true, the unprocessed source will watch for changes. - /// If `watch_processed` is true, the processed source will watch for changes. - pub fn build( + /// Builds a new [`AssetSource`] with the given `id`. If `watch` is true, the unprocessed source + /// will watch for changes. If `watch_processed` is true, the processed source will watch for + /// changes. If `processing_state` is [`Some`], the processed reader will be gated on the state. + pub(crate) fn build( &mut self, id: AssetSourceId<'static>, watch: bool, watch_processed: bool, + processing_state: Option>, ) -> AssetSource { let reader = self.reader.as_mut()(); let writer = self.writer.as_mut().and_then(|w| w(false)); @@ -222,6 +224,13 @@ impl AssetSourceBuilder { } } } + + if source.should_process() + && let Some(processing_state) = processing_state + { + source.gate_on_processor(processing_state); + } + source } @@ -355,17 +364,25 @@ impl AssetSourceBuilders { } } - /// Builds a new [`AssetSources`] collection. If `watch` is true, the unprocessed sources will watch for changes. - /// If `watch_processed` is true, the processed sources will watch for changes. - pub fn build_sources(&mut self, watch: bool, watch_processed: bool) -> AssetSources { + /// Builds a new [`AssetSources`] collection. If `watch` is true, the unprocessed sources will + /// watch for changes. If `watch_processed` is true, the processed sources will watch for + /// changes. If `processing_state` is [`Some`], the processed readers will be gated on the + /// processing state. + pub(crate) fn build_sources( + &mut self, + watch: bool, + watch_processed: bool, + processing_state: Option>, + ) -> AssetSources { let mut sources = >::default(); for (id, source) in &mut self.sources { let source = source.build( AssetSourceId::Name(id.clone_owned()), watch, watch_processed, + processing_state.clone(), ); - sources.insert(id.clone_owned(), source); + sources.insert(id.clone_owned(), Arc::new(source)); } AssetSources { @@ -373,7 +390,15 @@ impl AssetSourceBuilders { default: self .default .as_mut() - .map(|p| p.build(AssetSourceId::Default, watch, watch_processed)) + .map(|p| { + p.build( + AssetSourceId::Default, + watch, + watch_processed, + processing_state.clone(), + ) + }) + .map(Arc::new) .expect(MISSING_DEFAULT_SOURCE), } } @@ -591,45 +616,36 @@ impl AssetSource { /// A collection of [`AssetSource`]s. pub struct AssetSources { - sources: HashMap, AssetSource>, - default: AssetSource, + sources: HashMap, Arc>, + default: Arc, } impl AssetSources { /// Gets the [`AssetSource`] with the given `id`, if it exists. - pub fn get<'a, 'b>( - &'a self, - id: impl Into>, - ) -> Result<&'a AssetSource, MissingAssetSourceError> { + pub fn get<'a>( + &self, + id: impl Into>, + ) -> Result, MissingAssetSourceError> { match id.into().into_owned() { - AssetSourceId::Default => Ok(&self.default), + AssetSourceId::Default => Ok(self.default.clone()), AssetSourceId::Name(name) => self .sources .get(&name) + .cloned() .ok_or(MissingAssetSourceError(AssetSourceId::Name(name))), } } /// Iterates all asset sources in the collection (including the default source). - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator> { self.sources.values().chain(Some(&self.default)) } - /// Mutably iterates all asset sources in the collection (including the default source). - pub fn iter_mut(&mut self) -> impl Iterator { - self.sources.values_mut().chain(Some(&mut self.default)) - } - /// Iterates all processed asset sources in the collection (including the default source). - pub fn iter_processed(&self) -> impl Iterator { + pub fn iter_processed(&self) -> impl Iterator> { self.iter().filter(|p| p.should_process()) } - /// Mutably iterates all processed asset sources in the collection (including the default source). - pub fn iter_processed_mut(&mut self) -> impl Iterator { - self.iter_mut().filter(|p| p.should_process()) - } - /// Iterates over the [`AssetSourceId`] of every [`AssetSource`] in the collection (including the default source). pub fn ids(&self) -> impl Iterator> + '_ { self.sources @@ -637,14 +653,6 @@ impl AssetSources { .map(|k| AssetSourceId::Name(k.clone_owned())) .chain(Some(AssetSourceId::Default)) } - - /// This will cause processed [`AssetReader`](crate::io::AssetReader) futures (such as [`AssetReader::read`](crate::io::AssetReader::read)) to wait until - /// the [`AssetProcessor`](crate::AssetProcessor) has finished processing the requested asset. - pub(crate) fn gate_on_processor(&mut self, processing_state: Arc) { - for source in self.iter_processed_mut() { - source.gate_on_processor(processing_state.clone()); - } - } } /// An error returned when an [`AssetSource`] does not exist for a given id. diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index df47416193387..ef14c469ebae5 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -372,7 +372,7 @@ impl Plugin for AssetPlugin { match self.mode { AssetMode::Unprocessed => { let mut builders = app.world_mut().resource_mut::(); - let sources = builders.build_sources(watch, false); + let sources = builders.build_sources(watch, false, None); app.insert_resource(AssetServer::new_with_meta_check( Arc::new(sources), @@ -402,7 +402,7 @@ impl Plugin for AssetPlugin { .add_systems(bevy_app::Startup, AssetProcessor::start); } else { let mut builders = app.world_mut().resource_mut::(); - let sources = builders.build_sources(false, watch); + let sources = builders.build_sources(false, watch, None); app.insert_resource(AssetServer::new_with_meta_check( Arc::new(sources), AssetServerMode::Processed, diff --git a/crates/bevy_asset/src/loader_builders.rs b/crates/bevy_asset/src/loader_builders.rs index 994eb33590bee..b0cd5820f1c53 100644 --- a/crates/bevy_asset/src/loader_builders.rs +++ b/crates/bevy_asset/src/loader_builders.rs @@ -407,6 +407,7 @@ impl<'builder, 'reader, T> NestedLoader<'_, '_, T, Immediate<'builder, 'reader>> .write_infos() .stats .started_load_tasks += 1; + let source; let (mut meta, loader, mut reader) = if let Some(reader) = self.mode.reader { let loader = if let Some(asset_type_id) = asset_type_id { self.load_context @@ -430,10 +431,18 @@ impl<'builder, 'reader, T> NestedLoader<'_, '_, T, Immediate<'builder, 'reader>> let meta = loader.default_meta(); (meta, loader, ReaderRef::Borrowed(reader)) } else { + source = self + .load_context + .asset_server + .get_source(path.source()) + .map_err(|err| LoadDirectError::LoadError { + dependency: path.clone(), + error: err.into(), + })?; let (meta, loader, reader) = self .load_context .asset_server - .get_meta_loader_and_reader(path, asset_type_id) + .get_meta_loader_and_reader(path, asset_type_id, &source) .await .map_err(|error| LoadDirectError::LoadError { dependency: path.clone(), diff --git a/crates/bevy_asset/src/processor/mod.rs b/crates/bevy_asset/src/processor/mod.rs index 516f83448be1f..9e46f494963c2 100644 --- a/crates/bevy_asset/src/processor/mod.rs +++ b/crates/bevy_asset/src/processor/mod.rs @@ -139,8 +139,7 @@ impl AssetProcessor { watch_processed: bool, ) -> (Self, Arc) { let state = Arc::new(ProcessingState::new()); - let mut sources = sources.build_sources(true, watch_processed); - sources.gate_on_processor(state.clone()); + let sources = sources.build_sources(true, watch_processed, Some(state.clone())); let sources = Arc::new(sources); let data = Arc::new(AssetProcessorData::new(sources.clone(), state)); @@ -176,7 +175,7 @@ impl AssetProcessor { pub fn get_source<'a>( &self, id: impl Into>, - ) -> Result<&AssetSource, MissingAssetSourceError> { + ) -> Result, MissingAssetSourceError> { self.data.sources.get(id.into()) } @@ -298,7 +297,7 @@ impl AssetProcessor { return; }; processor - .handle_asset_source_event(source, event, &sender) + .handle_asset_source_event(&source, event, &sender) .await; } }) @@ -374,7 +373,9 @@ impl AssetProcessor { let Ok(source) = processor.get_source(source_id) else { return; }; - processor.process_asset(source, path, new_task_sender).await; + processor + .process_asset(&source, path, new_task_sender) + .await; // If the channel gets closed, that's ok. Just ignore it. let _ = task_finished_sender.send(()).await; }) diff --git a/crates/bevy_asset/src/server/mod.rs b/crates/bevy_asset/src/server/mod.rs index 9f7d594e5d3bb..d2258a172fb2b 100644 --- a/crates/bevy_asset/src/server/mod.rs +++ b/crates/bevy_asset/src/server/mod.rs @@ -182,7 +182,7 @@ impl AssetServer { pub fn get_source<'a>( &self, source: impl Into>, - ) -> Result<&AssetSource, MissingAssetSourceError> { + ) -> Result, MissingAssetSourceError> { self.data.sources.get(source.into()) } @@ -711,8 +711,19 @@ impl AssetServer { let path = path.into_owned(); let path_clone = path.clone(); + let source = self.get_source(path.source()).inspect_err(|e| { + // If there was an input handle, a "load" operation has already started, so we must + // produce a "failure" event, if we cannot find the source. + if let Some(handle) = &input_handle { + self.send_asset_event(InternalAssetEvent::Failed { + index: handle.try_into().unwrap(), + path: path.clone_owned(), + error: e.clone().into(), + }); + } + })?; let (mut meta, loader, mut reader) = self - .get_meta_loader_and_reader(&path_clone, input_handle_type_id) + .get_meta_loader_and_reader(&path_clone, input_handle_type_id, &source) .await .inspect_err(|e| { // if there was an input handle, a "load" operation has already started, so we must produce a "failure" event, if @@ -1418,6 +1429,7 @@ impl AssetServer { &'a self, asset_path: &'a AssetPath<'_>, asset_type_id: Option, + source: &'a AssetSource, ) -> Result< ( Box, @@ -1426,7 +1438,6 @@ impl AssetServer { ), AssetLoadError, > { - let source = self.get_source(asset_path.source())?; // NOTE: We grab the asset byte reader first to ensure this is transactional for AssetReaders like ProcessorGatedReader // The asset byte reader will "lock" the processed asset, preventing writes for the duration of the lock. // Then the meta reader, if meta exists, will correspond to the meta for the current "version" of the asset. From fdd6a3b0d9940a637789724b1ce66542dd8469b2 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Fri, 7 Nov 2025 17:09:48 -0800 Subject: [PATCH 02/15] Wrap the `AssetSources` in a `RwLock`. --- crates/bevy_asset/src/lib.rs | 5 +-- crates/bevy_asset/src/processor/mod.rs | 50 +++++++++++++++++--------- crates/bevy_asset/src/server/mod.rs | 22 ++++++++---- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index ef14c469ebae5..2fffeea403e5a 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -225,6 +225,7 @@ use bevy_ecs::{ use bevy_platform::collections::HashSet; use bevy_reflect::{FromReflect, GetTypeRegistration, Reflect, TypePath}; use core::any::TypeId; +use std::sync::RwLock; use tracing::error; /// Provides "asset" loading and processing functionality. An [`Asset`] is a "runtime value" that is loaded from an [`AssetSource`], @@ -375,7 +376,7 @@ impl Plugin for AssetPlugin { let sources = builders.build_sources(watch, false, None); app.insert_resource(AssetServer::new_with_meta_check( - Arc::new(sources), + Arc::new(RwLock::new(sources)), AssetServerMode::Unprocessed, self.meta_check.clone(), watch, @@ -404,7 +405,7 @@ impl Plugin for AssetPlugin { let mut builders = app.world_mut().resource_mut::(); let sources = builders.build_sources(false, watch, None); app.insert_resource(AssetServer::new_with_meta_check( - Arc::new(sources), + Arc::new(RwLock::new(sources)), AssetServerMode::Processed, AssetMetaCheck::Always, watch, diff --git a/crates/bevy_asset/src/processor/mod.rs b/crates/bevy_asset/src/processor/mod.rs index 9e46f494963c2..ec69af78ca061 100644 --- a/crates/bevy_asset/src/processor/mod.rs +++ b/crates/bevy_asset/src/processor/mod.rs @@ -115,7 +115,8 @@ pub struct AssetProcessorData { processors: RwLock>>, /// Default processors for file extensions default_processors: RwLock, &'static str>>, - sources: Arc, + /// The asset sources for which this processor will process assets. + sources: Arc>, } /// The current state of processing, including the overall state and the state of all assets. @@ -137,10 +138,10 @@ impl AssetProcessor { pub fn new( sources: &mut AssetSourceBuilders, watch_processed: bool, - ) -> (Self, Arc) { + ) -> (Self, Arc>) { let state = Arc::new(ProcessingState::new()); let sources = sources.build_sources(true, watch_processed, Some(state.clone())); - let sources = Arc::new(sources); + let sources = Arc::new(RwLock::new(sources)); let data = Arc::new(AssetProcessorData::new(sources.clone(), state)); // The asset processor uses its own asset server with its own id space @@ -176,11 +177,15 @@ impl AssetProcessor { &self, id: impl Into>, ) -> Result, MissingAssetSourceError> { - self.data.sources.get(id.into()) + self.data + .sources + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(id.into()) } #[inline] - pub fn sources(&self) -> &AssetSources { + pub fn sources(&self) -> &RwLock { &self.data.sources } @@ -233,11 +238,19 @@ impl AssetProcessor { let start_time = std::time::Instant::now(); debug!("Processing Assets"); - processor.initialize().await.unwrap(); + let sources = processor + .sources() + .read() + .unwrap_or_else(PoisonError::into_inner) + .iter_processed() + .cloned() + .collect::>(); + + processor.initialize(&sources).await.unwrap(); let (new_task_sender, new_task_receiver) = async_channel::unbounded(); processor - .queue_initial_processing_tasks(&new_task_sender) + .queue_initial_processing_tasks(&sources, &new_task_sender) .await; // Once all the tasks are queued for the initial processing, start actually @@ -260,7 +273,7 @@ impl AssetProcessor { debug!("Processing finished in {:?}", end_time - start_time); debug!("Listening for changes to source assets"); - processor.spawn_source_change_event_listeners(&new_task_sender); + processor.spawn_source_change_event_listeners(&sources, &new_task_sender); }) .detach(); } @@ -268,9 +281,10 @@ impl AssetProcessor { /// Sends start task events for all assets in all processed sources into `sender`. async fn queue_initial_processing_tasks( &self, + sources: &[Arc], sender: &async_channel::Sender<(AssetSourceId<'static>, PathBuf)>, ) { - for source in self.sources().iter_processed() { + for source in sources { self.queue_processing_tasks_for_folder(source, PathBuf::from(""), sender) .await .unwrap(); @@ -281,9 +295,10 @@ impl AssetProcessor { /// response. fn spawn_source_change_event_listeners( &self, + sources: &[Arc], sender: &async_channel::Sender<(AssetSourceId<'static>, PathBuf)>, ) { - for source in self.data.sources.iter_processed() { + for source in sources { let Some(receiver) = source.event_receiver().cloned() else { continue; }; @@ -753,8 +768,8 @@ impl AssetProcessor { /// This info will later be used to determine whether or not to re-process an asset /// /// This will validate transactions and recover failed transactions when necessary. - async fn initialize(&self) -> Result<(), InitializeError> { - self.validate_transaction_log_and_recover().await; + async fn initialize(&self, sources: &[Arc]) -> Result<(), InitializeError> { + self.validate_transaction_log_and_recover(sources).await; let mut asset_infos = self.data.processing_state.asset_infos.write().await; /// Retrieves asset paths recursively. If `clean_empty_folders_writer` is Some, it will be used to clean up empty @@ -793,7 +808,7 @@ impl AssetProcessor { } } - for source in self.sources().iter_processed() { + for source in sources { let Some(processed_reader) = source.ungated_processed_reader() else { continue; }; @@ -1129,7 +1144,7 @@ impl AssetProcessor { Ok(ProcessResult::Processed(new_processed_info)) } - async fn validate_transaction_log_and_recover(&self) { + async fn validate_transaction_log_and_recover(&self, sources: &[Arc]) { let log_factory = self .data .log_factory @@ -1203,7 +1218,7 @@ impl AssetProcessor { if !state_is_valid { error!("Processed asset transaction log state was invalid and unrecoverable for some reason (see previous logs). Removing processed assets and starting fresh."); - for source in self.sources().iter_processed() { + for source in sources { let Ok(processed_writer) = source.processed_writer() else { continue; }; @@ -1226,7 +1241,10 @@ impl AssetProcessor { impl AssetProcessorData { /// Initializes a new [`AssetProcessorData`] using the given [`AssetSources`]. - pub(crate) fn new(sources: Arc, processing_state: Arc) -> Self { + pub(crate) fn new( + sources: Arc>, + processing_state: Arc, + ) -> Self { AssetProcessorData { processing_state, sources, diff --git a/crates/bevy_asset/src/server/mod.rs b/crates/bevy_asset/src/server/mod.rs index d2258a172fb2b..3bfd3c5fa60d6 100644 --- a/crates/bevy_asset/src/server/mod.rs +++ b/crates/bevy_asset/src/server/mod.rs @@ -69,7 +69,7 @@ pub(crate) struct AssetServerData { pub(crate) loaders: Arc>, asset_event_sender: Sender, asset_event_receiver: Receiver, - sources: Arc, + sources: Arc>, mode: AssetServerMode, meta_check: AssetMetaCheck, unapproved_path_mode: UnapprovedPathMode, @@ -91,7 +91,7 @@ impl AssetServer { /// Create a new instance of [`AssetServer`]. If `watch_for_changes` is true, the [`AssetReader`](crate::io::AssetReader) storage will watch for changes to /// asset sources and hot-reload them. pub fn new( - sources: Arc, + sources: Arc>, mode: AssetServerMode, watching_for_changes: bool, unapproved_path_mode: UnapprovedPathMode, @@ -109,7 +109,7 @@ impl AssetServer { /// Create a new instance of [`AssetServer`]. If `watch_for_changes` is true, the [`AssetReader`](crate::io::AssetReader) storage will watch for changes to /// asset sources and hot-reload them. pub fn new_with_meta_check( - sources: Arc, + sources: Arc>, mode: AssetServerMode, meta_check: AssetMetaCheck, watching_for_changes: bool, @@ -126,7 +126,7 @@ impl AssetServer { } pub(crate) fn new_with_loaders( - sources: Arc, + sources: Arc>, loaders: Arc>, mode: AssetServerMode, meta_check: AssetMetaCheck, @@ -183,7 +183,11 @@ impl AssetServer { &self, source: impl Into>, ) -> Result, MissingAssetSourceError> { - self.data.sources.get(source.into()) + self.data + .sources + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(source.into()) } /// Returns true if the [`AssetServer`] watches for changes. @@ -1849,7 +1853,13 @@ pub fn handle_internal_asset_events(world: &mut World) { } }; - for source in server.data.sources.iter() { + for source in server + .data + .sources + .read() + .unwrap_or_else(PoisonError::into_inner) + .iter() + { match server.data.mode { AssetServerMode::Unprocessed => { if let Some(receiver) = source.event_receiver() { From 6a2f33d4ef6b6e6692f4a9803b31c2cf39b5a465 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Fri, 7 Nov 2025 17:09:48 -0800 Subject: [PATCH 03/15] Allow adding and removing assets, and allow registering sources after adding the AssetPlugin. --- crates/bevy_asset/src/io/embedded/mod.rs | 22 ++- crates/bevy_asset/src/io/file/mod.rs | 2 +- crates/bevy_asset/src/io/source.rs | 170 +++++++++++------------ crates/bevy_asset/src/lib.rs | 134 +++++++++++------- crates/bevy_asset/src/processor/mod.rs | 7 +- crates/bevy_asset/src/server/mod.rs | 35 ++++- 6 files changed, 217 insertions(+), 153 deletions(-) diff --git a/crates/bevy_asset/src/io/embedded/mod.rs b/crates/bevy_asset/src/io/embedded/mod.rs index 9eea1dbb95b75..a587202a55514 100644 --- a/crates/bevy_asset/src/io/embedded/mod.rs +++ b/crates/bevy_asset/src/io/embedded/mod.rs @@ -3,10 +3,11 @@ mod embedded_watcher; #[cfg(feature = "embedded_watcher")] pub use embedded_watcher::*; +use tracing::error; use crate::io::{ memory::{Dir, MemoryAssetReader, Value}, - AssetSourceBuilder, AssetSourceBuilders, + AssetSourceBuilder, AssetSources, }; use crate::AssetServer; use alloc::boxed::Box; @@ -19,8 +20,7 @@ use std::path::{Path, PathBuf}; #[cfg(feature = "embedded_watcher")] use alloc::borrow::ToOwned; -/// The name of the `embedded` [`AssetSource`](crate::io::AssetSource), -/// as stored in the [`AssetSourceBuilders`] resource. +/// The name of the `embedded` [`AssetSource`](crate::io::AssetSource). pub const EMBEDDED: &str = "embedded"; /// A [`Resource`] that manages "rust source files" in a virtual in memory [`Dir`], which is intended @@ -83,18 +83,12 @@ impl EmbeddedAssetRegistry { self.dir.remove_asset(full_path) } - /// Registers the [`EMBEDDED`] [`AssetSource`](crate::io::AssetSource) with the given [`AssetSourceBuilders`]. - pub fn register_source(&self, sources: &mut AssetSourceBuilders) { + /// Registers the [`EMBEDDED`] [`AssetSource`](crate::io::AssetSource) with the given + /// [`AssetSources`]. + pub fn register_source(&self, sources: &mut AssetSources) { let dir = self.dir.clone(); let processed_dir = self.dir.clone(); - #[cfg_attr( - not(feature = "embedded_watcher"), - expect( - unused_mut, - reason = "Variable is only mutated when `embedded_watcher` feature is enabled." - ) - )] let mut source = AssetSourceBuilder::new(move || Box::new(MemoryAssetReader { root: dir.clone() })) .with_processed_reader(move || { @@ -132,7 +126,9 @@ impl EmbeddedAssetRegistry { ))) }); } - sources.insert(EMBEDDED, source); + if let Err(err) = sources.add(EMBEDDED, &mut source) { + error!("Failed to register asset source: {err}"); + } } } diff --git a/crates/bevy_asset/src/io/file/mod.rs b/crates/bevy_asset/src/io/file/mod.rs index 0ab590cd797fc..6a0b752bf8bd2 100644 --- a/crates/bevy_asset/src/io/file/mod.rs +++ b/crates/bevy_asset/src/io/file/mod.rs @@ -52,7 +52,7 @@ impl FileAssetReader { /// Returns the base path of the assets directory, which is normally the executable's parent /// directory. /// - /// To change this, set [`AssetPlugin::file_path`][crate::AssetPlugin::file_path]. + /// To change this, set [`DefaultAssetSource::FromPaths::file_path`][crate::DefaultAssetSource::FromPaths::file_path]. pub fn get_base_path() -> PathBuf { get_base_path() } diff --git a/crates/bevy_asset/src/io/source.rs b/crates/bevy_asset/src/io/source.rs index 682a2432666fb..b341faa8f725e 100644 --- a/crates/bevy_asset/src/io/source.rs +++ b/crates/bevy_asset/src/io/source.rs @@ -8,9 +8,9 @@ use alloc::{ sync::Arc, }; use atomicow::CowArc; -use bevy_ecs::resource::Resource; -use bevy_platform::collections::HashMap; +use bevy_platform::collections::{hash_map::Entry, HashMap}; use core::{fmt::Display, hash::Hash, time::Duration}; +use std::sync::RwLock; use thiserror::Error; use tracing::warn; @@ -174,7 +174,7 @@ impl AssetSourceBuilder { watch: bool, watch_processed: bool, processing_state: Option>, - ) -> AssetSource { + ) -> Arc { let reader = self.reader.as_mut()(); let writer = self.writer.as_mut().and_then(|w| w(false)); let processed_writer = self.processed_writer.as_mut().and_then(|w| w(true)); @@ -231,7 +231,7 @@ impl AssetSourceBuilder { source.gate_on_processor(processing_state); } - source + Arc::new(source) } /// Will use the given `reader` function to construct unprocessed [`AssetReader`](crate::io::AssetReader) instances. @@ -332,84 +332,6 @@ impl AssetSourceBuilder { } } -/// A [`Resource`] that hold (repeatable) functions capable of producing new [`AssetReader`](crate::io::AssetReader) and [`AssetWriter`](crate::io::AssetWriter) instances -/// for a given asset source. -#[derive(Resource, Default)] -pub struct AssetSourceBuilders { - sources: HashMap, AssetSourceBuilder>, - default: Option, -} - -impl AssetSourceBuilders { - /// Inserts a new builder with the given `id` - pub fn insert(&mut self, id: impl Into>, source: AssetSourceBuilder) { - match id.into() { - AssetSourceId::Default => { - self.default = Some(source); - } - AssetSourceId::Name(name) => { - self.sources.insert(name, source); - } - } - } - - /// Gets a mutable builder with the given `id`, if it exists. - pub fn get_mut<'a, 'b>( - &'a mut self, - id: impl Into>, - ) -> Option<&'a mut AssetSourceBuilder> { - match id.into() { - AssetSourceId::Default => self.default.as_mut(), - AssetSourceId::Name(name) => self.sources.get_mut(&name.into_owned()), - } - } - - /// Builds a new [`AssetSources`] collection. If `watch` is true, the unprocessed sources will - /// watch for changes. If `watch_processed` is true, the processed sources will watch for - /// changes. If `processing_state` is [`Some`], the processed readers will be gated on the - /// processing state. - pub(crate) fn build_sources( - &mut self, - watch: bool, - watch_processed: bool, - processing_state: Option>, - ) -> AssetSources { - let mut sources = >::default(); - for (id, source) in &mut self.sources { - let source = source.build( - AssetSourceId::Name(id.clone_owned()), - watch, - watch_processed, - processing_state.clone(), - ); - sources.insert(id.clone_owned(), Arc::new(source)); - } - - AssetSources { - sources, - default: self - .default - .as_mut() - .map(|p| { - p.build( - AssetSourceId::Default, - watch, - watch_processed, - processing_state.clone(), - ) - }) - .map(Arc::new) - .expect(MISSING_DEFAULT_SOURCE), - } - } - - /// Initializes the default [`AssetSourceBuilder`] if it has not already been set. - pub fn init_default_source(&mut self, path: &str, processed_path: Option<&str>) { - self.default - .get_or_insert_with(|| AssetSourceBuilder::platform_default(path, processed_path)); - } -} - /// A collection of unprocessed and processed [`AssetReader`](crate::io::AssetReader), [`AssetWriter`](crate::io::AssetWriter), and [`AssetWatcher`] instances /// for a specific asset source, identified by an [`AssetSourceId`]. pub struct AssetSource { @@ -616,11 +538,44 @@ impl AssetSource { /// A collection of [`AssetSource`]s. pub struct AssetSources { + /// The named sources. sources: HashMap, Arc>, + /// The default source. default: Arc, + /// Whether sources should watch the unprocessed assets. + watch: bool, + /// Whether sources should watch the processed assets. + watch_processed: bool, + /// The processing state if these sources are being used with + /// [`AssetProcessor`](crate::AssetProcessor). + /// + /// If [`Some`], the processed reader will be gated on this state. + processing_state: Option>, } impl AssetSources { + /// Creates a new instance where the default asset source is created from `default`, and sources + /// are built with the other options. + pub(crate) fn new( + default: &mut AssetSourceBuilder, + watch: bool, + watch_processed: bool, + processing_state: Option>, + ) -> Arc> { + Arc::new(RwLock::new(Self { + default: default.build( + AssetSourceId::Default, + watch, + watch_processed, + processing_state.clone(), + ), + sources: HashMap::new(), + watch, + watch_processed, + processing_state, + })) + } + /// Gets the [`AssetSource`] with the given `id`, if it exists. pub fn get<'a>( &self, @@ -636,6 +591,46 @@ impl AssetSources { } } + /// Adds a new named asset source. + /// + /// Note: Default asset sources cannot be changed at runtime. + pub fn add( + &mut self, + name: impl Into>, + source_builder: &mut AssetSourceBuilder, + ) -> Result<(), AddSourceError> { + let name = name.into(); + let entry = match self.sources.entry(name) { + Entry::Occupied(entry) => return Err(AddSourceError::NameInUse(entry.key().clone())), + Entry::Vacant(entry) => entry, + }; + + let name = entry.key().clone(); + entry.insert(source_builder.build( + AssetSourceId::Name(name), + self.watch, + self.watch_processed, + self.processing_state.clone(), + )); + Ok(()) + } + + /// Removes an existing named asset source. + /// + /// Note: Default asset sources cannot be removed at runtime. + pub fn remove( + &mut self, + name: impl Into>, + ) -> Result<(), MissingAssetSourceError> { + let name = name.into(); + if self.sources.remove(&name).is_none() { + return Err(MissingAssetSourceError(AssetSourceId::Name( + name.clone_owned(), + ))); + } + Ok(()) + } + /// Iterates all asset sources in the collection (including the default source). pub fn iter(&self) -> impl Iterator> { self.sources.values().chain(Some(&self.default)) @@ -655,6 +650,14 @@ impl AssetSources { } } +/// An error when attempting to add a new asset source. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum AddSourceError { + /// An asset source with the given name already exists. + #[error("Asset Source '{0}' already exists")] + NameInUse(CowArc<'static, str>), +} + /// An error returned when an [`AssetSource`] does not exist for a given id. #[derive(Error, Debug, Clone, PartialEq, Eq)] #[error("Asset Source '{0}' does not exist")] @@ -674,6 +677,3 @@ pub struct MissingProcessedAssetReaderError(AssetSourceId<'static>); #[derive(Error, Debug, Clone)] #[error("Asset Source '{0}' does not have a processed AssetWriter.")] pub struct MissingProcessedAssetWriterError(AssetSourceId<'static>); - -const MISSING_DEFAULT_SOURCE: &str = - "A default AssetSource is required. Add one to `AssetSourceBuilders`"; diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 2fffeea403e5a..a0dccbdc72b03 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -187,6 +187,7 @@ mod render_asset; mod server; pub use assets::*; +use atomicow::CowArc; pub use bevy_asset_macros::Asset; use bevy_diagnostic::{Diagnostic, DiagnosticsStore, RegisterDiagnostic}; pub use direct_access_ext::DirectAssetAccessExt; @@ -207,7 +208,7 @@ pub use server::*; pub use uuid; use crate::{ - io::{embedded::EmbeddedAssetRegistry, AssetSourceBuilder, AssetSourceBuilders, AssetSourceId}, + io::{embedded::EmbeddedAssetRegistry, AssetSourceBuilder, AssetSources}, processor::{AssetProcessor, Process}, }; use alloc::{ @@ -225,7 +226,7 @@ use bevy_ecs::{ use bevy_platform::collections::HashSet; use bevy_reflect::{FromReflect, GetTypeRegistration, Reflect, TypePath}; use core::any::TypeId; -use std::sync::RwLock; +use std::sync::{Mutex, PoisonError}; use tracing::error; /// Provides "asset" loading and processing functionality. An [`Asset`] is a "runtime value" that is loaded from an [`AssetSource`], @@ -236,10 +237,10 @@ use tracing::error; /// /// [`AssetSource`]: io::AssetSource pub struct AssetPlugin { - /// The default file path to use (relative to the project root) for unprocessed assets. - pub file_path: String, - /// The default file path to use (relative to the project root) for processed assets. - pub processed_file_path: String, + /// The default source for loading assets from. + /// + /// Note: this asset source cannot be removed later. + pub default_source: DefaultAssetSource, /// If set, will override the default "watch for changes" setting. By default "watch for changes" will be `false` unless /// the `watch` cargo feature is set. `watch` can be enabled manually, or it will be automatically enabled if a specific watcher /// like `file_watcher` is enabled. @@ -257,17 +258,17 @@ pub struct AssetPlugin { pub mode: AssetMode, /// How/If asset meta files should be checked. pub meta_check: AssetMetaCheck, - /// How to handle load requests of files that are outside the approved directories. + /// How to handle load requests of files that are outside the approved paths. /// - /// Approved folders are [`AssetPlugin::file_path`] and the folder of each - /// [`AssetSource`](io::AssetSource). Subfolders within these folders are also valid. + /// Approved paths are those within a source, as opposed to paths that "escape" a source using + /// `../`. Subfolders within sources are also valid. pub unapproved_path_mode: UnapprovedPathMode, } -/// Determines how to react to attempts to load assets not inside the approved folders. +/// Determines how to react to attempts to load assets not inside the approved paths. /// -/// Approved folders are [`AssetPlugin::file_path`] and the folder of each -/// [`AssetSource`](io::AssetSource). Subfolders within these folders are also valid. +/// Approved paths are those within a source, as opposed to paths that "escape" a source using +/// `../`. Subfolders within these sources are also valid. /// /// It is strongly discouraged to use [`Allow`](UnapprovedPathMode::Allow) if your /// app will include scripts or modding support, as it could allow arbitrary file @@ -331,12 +332,32 @@ pub enum AssetMetaCheck { Never, } +/// Type to define how to create the default asset source. +pub enum DefaultAssetSource { + /// Create the default asset source given these file paths. + FromPaths { + /// The path to the unprocessed assets. + file_path: String, + /// The path to the processed assets. + /// + /// If [`None`], the default file path is used when in [`AssetMode::Processed`]. + processed_file_path: Option, + }, + /// Create the default asset source from the provided builder. + /// + /// Note: The Mutex is just an implementation detail for applying the + /// plugin. + FromBuilder(Mutex), +} + impl Default for AssetPlugin { fn default() -> Self { Self { mode: AssetMode::Unprocessed, - file_path: Self::DEFAULT_UNPROCESSED_FILE_PATH.to_string(), - processed_file_path: Self::DEFAULT_PROCESSED_FILE_PATH.to_string(), + default_source: DefaultAssetSource::FromPaths { + file_path: Self::DEFAULT_UNPROCESSED_FILE_PATH.to_string(), + processed_file_path: None, + }, watch_for_changes_override: None, use_asset_processor_override: None, meta_check: AssetMetaCheck::default(), @@ -354,29 +375,42 @@ impl AssetPlugin { impl Plugin for AssetPlugin { fn build(&self, app: &mut App) { - let embedded = EmbeddedAssetRegistry::default(); - { - let mut sources = app - .world_mut() - .get_resource_or_init::(); - sources.init_default_source( - &self.file_path, - (!matches!(self.mode, AssetMode::Unprocessed)) - .then_some(self.processed_file_path.as_str()), - ); - embedded.register_source(&mut sources); - } + let mut lock; + let mut default_source_builder; + let default_source_builder_ref; + match &self.default_source { + DefaultAssetSource::FromPaths { + file_path, + processed_file_path, + } => { + let processed_file_path = (!matches!(self.mode, AssetMode::Unprocessed)).then_some( + processed_file_path + .as_ref() + .map(String::as_ref) + .unwrap_or(Self::DEFAULT_PROCESSED_FILE_PATH), + ); + default_source_builder = + AssetSourceBuilder::platform_default(file_path, processed_file_path); + default_source_builder_ref = &mut default_source_builder; + } + DefaultAssetSource::FromBuilder(builder) => { + lock = builder.lock().unwrap_or_else(PoisonError::into_inner); + default_source_builder_ref = &mut *lock; + } + }; + + let asset_sources; { let watch = self .watch_for_changes_override .unwrap_or(cfg!(feature = "watch")); match self.mode { AssetMode::Unprocessed => { - let mut builders = app.world_mut().resource_mut::(); - let sources = builders.build_sources(watch, false, None); + asset_sources = + AssetSources::new(default_source_builder_ref, watch, false, None); app.insert_resource(AssetServer::new_with_meta_check( - Arc::new(RwLock::new(sources)), + asset_sources.clone(), AssetServerMode::Unprocessed, self.meta_check.clone(), watch, @@ -388,11 +422,12 @@ impl Plugin for AssetPlugin { .use_asset_processor_override .unwrap_or(cfg!(feature = "asset_processor")); if use_asset_processor { - let mut builders = app.world_mut().resource_mut::(); - let (processor, sources) = AssetProcessor::new(&mut builders, watch); + let (processor, sources) = + AssetProcessor::new(default_source_builder_ref, watch); + asset_sources = sources; // the main asset server shares loaders with the processor asset server app.insert_resource(AssetServer::new_with_loaders( - sources, + asset_sources.clone(), processor.server().data.loaders.clone(), AssetServerMode::Processed, AssetMetaCheck::Always, @@ -402,10 +437,11 @@ impl Plugin for AssetPlugin { .insert_resource(processor) .add_systems(bevy_app::Startup, AssetProcessor::start); } else { - let mut builders = app.world_mut().resource_mut::(); - let sources = builders.build_sources(false, watch, None); + asset_sources = + AssetSources::new(default_source_builder_ref, false, watch, None); + app.insert_resource(AssetServer::new_with_meta_check( - Arc::new(RwLock::new(sources)), + asset_sources.clone(), AssetServerMode::Processed, AssetMetaCheck::Always, watch, @@ -415,6 +451,12 @@ impl Plugin for AssetPlugin { } } } + let embedded = EmbeddedAssetRegistry::default(); + embedded.register_source( + &mut asset_sources + .write() + .unwrap_or_else(PoisonError::into_inner), + ); app.insert_resource(embedded) .init_asset::() .init_asset::() @@ -561,7 +603,7 @@ pub trait AssetApp { /// since registered asset sources are built at that point and not after. fn register_asset_source( &mut self, - id: impl Into>, + id: impl Into>, source: AssetSourceBuilder, ) -> &mut Self; /// Sets the default asset processor for the given `extension`. @@ -605,19 +647,17 @@ impl AssetApp for App { fn register_asset_source( &mut self, - id: impl Into>, - source: AssetSourceBuilder, + name: impl Into>, + mut source: AssetSourceBuilder, ) -> &mut Self { - let id = id.into(); - if self.world().get_resource::().is_some() { - error!("{} must be registered before `AssetPlugin` (typically added as part of `DefaultPlugins`)", id); - } + let name = name.into(); + let Some(asset_server) = self.world().get_resource::() else { + error!("{} must be registered after `AssetPlugin` (typically added as part of `DefaultPlugins`)", name); + return self; + }; - { - let mut sources = self - .world_mut() - .get_resource_or_init::(); - sources.insert(id, source); + if let Err(err) = asset_server.add_source(name, &mut source) { + error!("Failed to register asset source: {err}"); } self diff --git a/crates/bevy_asset/src/processor/mod.rs b/crates/bevy_asset/src/processor/mod.rs index ec69af78ca061..3e1b4fae33293 100644 --- a/crates/bevy_asset/src/processor/mod.rs +++ b/crates/bevy_asset/src/processor/mod.rs @@ -46,7 +46,7 @@ pub use process::*; use crate::{ io::{ - AssetReaderError, AssetSource, AssetSourceBuilders, AssetSourceEvent, AssetSourceId, + AssetReaderError, AssetSource, AssetSourceBuilder, AssetSourceEvent, AssetSourceId, AssetSources, AssetWriterError, ErasedAssetReader, MissingAssetSourceError, }, meta::{ @@ -136,12 +136,11 @@ pub(crate) struct ProcessingState { impl AssetProcessor { /// Creates a new [`AssetProcessor`] instance. pub fn new( - sources: &mut AssetSourceBuilders, + default_source: &mut AssetSourceBuilder, watch_processed: bool, ) -> (Self, Arc>) { let state = Arc::new(ProcessingState::new()); - let sources = sources.build_sources(true, watch_processed, Some(state.clone())); - let sources = Arc::new(RwLock::new(sources)); + let sources = AssetSources::new(default_source, true, watch_processed, Some(state.clone())); let data = Arc::new(AssetProcessorData::new(sources.clone(), state)); // The asset processor uses its own asset server with its own id space diff --git a/crates/bevy_asset/src/server/mod.rs b/crates/bevy_asset/src/server/mod.rs index 3bfd3c5fa60d6..c70177b78c8cd 100644 --- a/crates/bevy_asset/src/server/mod.rs +++ b/crates/bevy_asset/src/server/mod.rs @@ -4,9 +4,9 @@ mod loaders; use crate::{ folder::LoadedFolder, io::{ - AssetReaderError, AssetSource, AssetSourceEvent, AssetSourceId, AssetSources, - AssetWriterError, ErasedAssetReader, MissingAssetSourceError, MissingAssetWriterError, - MissingProcessedAssetReaderError, Reader, + AddSourceError, AssetReaderError, AssetSource, AssetSourceBuilder, AssetSourceEvent, + AssetSourceId, AssetSources, AssetWriterError, ErasedAssetReader, MissingAssetSourceError, + MissingAssetWriterError, MissingProcessedAssetReaderError, Reader, }, loader::{AssetLoader, ErasedAssetLoader, LoadContext, LoadedAsset}, meta::{ @@ -190,6 +190,35 @@ impl AssetServer { .get(source.into()) } + /// Adds a new named asset source. + /// + /// Note: Default asset sources cannot be changed at runtime. + pub fn add_source( + &self, + name: impl Into>, + source_builder: &mut AssetSourceBuilder, + ) -> Result<(), AddSourceError> { + self.data + .sources + .write() + .unwrap_or_else(PoisonError::into_inner) + .add(name.into(), source_builder) + } + + /// Removes an existing named asset source. + /// + /// Note: Default asset sources cannot be removed at runtime. + pub fn remove_source( + &self, + name: impl Into>, + ) -> Result<(), MissingAssetSourceError> { + self.data + .sources + .write() + .unwrap_or_else(PoisonError::into_inner) + .remove(name.into()) + } + /// Returns true if the [`AssetServer`] watches for changes. pub fn watching_for_changes(&self) -> bool { self.read_infos().watching_for_changes From 080966906e6fdde66e26aeba47dd666fee10765f Mon Sep 17 00:00:00 2001 From: andriyDev Date: Fri, 7 Nov 2025 20:50:49 -0800 Subject: [PATCH 04/15] Fix up regular asset tests to register sources after the AssetPlugin. --- crates/bevy_asset/src/lib.rs | 148 +++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 60 deletions(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index a0dccbdc72b03..6e4f70b308a8f 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -757,8 +757,8 @@ mod tests { }, loader::{AssetLoader, LoadContext}, Asset, AssetApp, AssetEvent, AssetId, AssetLoadError, AssetLoadFailedEvent, AssetPath, - AssetPlugin, AssetServer, Assets, InvalidGenerationError, LoadState, UnapprovedPathMode, - UntypedHandle, + AssetPlugin, AssetServer, Assets, DefaultAssetSource, InvalidGenerationError, LoadState, + UnapprovedPathMode, UntypedHandle, }; use alloc::{ boxed::Box, @@ -943,13 +943,14 @@ mod tests { fn test_app(dir: Dir) -> (App, GateOpener) { let mut app = App::new(); let (gated_memory_reader, gate_opener) = GatedReader::new(MemoryAssetReader { root: dir }); - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(gated_memory_reader.clone())), - ) - .add_plugins(( + app.add_plugins(( TaskPoolPlugin::default(), - AssetPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(gated_memory_reader.clone())), + )), + ..Default::default() + }, DiagnosticsPlugin, )); (app, gate_opener) @@ -1882,18 +1883,18 @@ mod tests { let unstable_reader = UnstableMemoryAssetReader::new(dir, 2); let mut app = App::new(); - app.register_asset_source( - "unstable", - AssetSourceBuilder::new(move || Box::new(unstable_reader.clone())), - ) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())) - .init_asset::() - .register_asset_loader(CoolTextLoader) - .init_resource::() - .add_systems( - Update, - (asset_event_handler, asset_load_error_event_handler).chain(), - ); + app.add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())) + .register_asset_source( + "unstable", + AssetSourceBuilder::new(move || Box::new(unstable_reader.clone())), + ) + .init_asset::() + .register_asset_loader(CoolTextLoader) + .init_resource::() + .add_systems( + Update, + (asset_event_handler, asset_load_error_event_handler).chain(), + ); let asset_server = app.world().resource::().clone(); let a_path = format!("unstable://{a_path}"); @@ -1952,11 +1953,17 @@ mod tests { ); dir.insert_asset_text(Path::new("empty.txt"), ""); - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(MemoryAssetReader { root: dir.clone() })), - ) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || { + Box::new(MemoryAssetReader { root: dir.clone() }) + }), + )), + ..Default::default() + }, + )); app.init_asset::() .init_asset::() @@ -2075,13 +2082,12 @@ mod tests { let mut app = App::new(); let memory_reader = MemoryAssetReader { root: dir }; - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(memory_reader.clone())), - ) - .add_plugins(( + app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(memory_reader.clone())), + )), unapproved_path_mode: mode, ..Default::default() }, @@ -2213,11 +2219,15 @@ mod tests { let dir = Dir::default(); let mut app = App::new(); let reader = MemoryAssetReader { root: dir.clone() }; - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(reader.clone())), - ) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(reader.clone())), + )), + ..Default::default() + }, + )); let (in_loader_sender, in_loader_receiver) = async_channel::bounded(1); let (gate_sender, gate_receiver) = async_channel::bounded(1); @@ -2269,11 +2279,15 @@ mod tests { let dir = Dir::default(); let mut app = App::new(); let reader = MemoryAssetReader { root: dir.clone() }; - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(reader.clone())), - ) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(reader.clone())), + )), + ..Default::default() + }, + )); let (in_loader_sender, in_loader_receiver) = async_channel::bounded(1); let (gate_sender, gate_receiver) = async_channel::bounded(1); @@ -2335,18 +2349,17 @@ mod tests { struct FakeWatcher; impl AssetWatcher for FakeWatcher {} - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(memory_reader.clone())).with_watcher( - move |sender| { - sender_sender.send(sender).unwrap(); - Some(Box::new(FakeWatcher)) - }, - ), - ) - .add_plugins(( + app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(memory_reader.clone())).with_watcher( + move |sender| { + sender_sender.send(sender).unwrap(); + Some(Box::new(FakeWatcher)) + }, + ), + )), watch_for_changes_override: Some(true), ..Default::default() }, @@ -2532,10 +2545,15 @@ mod tests { let mut app = App::new(); - app.register_asset_source(AssetSourceId::Default, asset_source) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())) - .init_asset::() - .register_asset_loader(U8Loader); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + ..Default::default() + }, + )) + .init_asset::() + .register_asset_loader(U8Loader); let asset_server = app.world().resource::(); @@ -2592,9 +2610,14 @@ mod tests { let asset_source = AssetSourceBuilder::new(move || Box::new(MemoryAssetReader { root: dir.clone() })); - app.register_asset_source(AssetSourceId::Default, asset_source) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())) - .init_asset::(); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + ..Default::default() + }, + )) + .init_asset::(); struct TwoSubassetLoader; @@ -2644,9 +2667,14 @@ mod tests { let asset_source = AssetSourceBuilder::new(move || Box::new(MemoryAssetReader { root: dir.clone() })); - app.register_asset_source(AssetSourceId::Default, asset_source) - .add_plugins((TaskPoolPlugin::default(), AssetPlugin::default())) - .init_asset::(); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + ..Default::default() + }, + )) + .init_asset::(); struct TrivialLoader; From a027728d105dabc94fe0e18255f3179b694029cb Mon Sep 17 00:00:00 2001 From: andriyDev Date: Mon, 3 Nov 2025 17:17:54 -0800 Subject: [PATCH 05/15] Rewrite asset processing tests to allow adding sources piecemeal. --- crates/bevy_asset/src/processor/tests.rs | 205 +++++++++++------------ 1 file changed, 95 insertions(+), 110 deletions(-) diff --git a/crates/bevy_asset/src/processor/tests.rs b/crates/bevy_asset/src/processor/tests.rs index 68941836e7d7c..b50082d546f70 100644 --- a/crates/bevy_asset/src/processor/tests.rs +++ b/crates/bevy_asset/src/processor/tests.rs @@ -7,10 +7,8 @@ use alloc::{ vec::Vec, }; use async_lock::{RwLock, RwLockWriteGuard}; -use bevy_platform::{ - collections::HashMap, - sync::{Mutex, PoisonError}, -}; +use atomicow::CowArc; +use bevy_platform::sync::{Mutex, PoisonError}; use bevy_reflect::TypePath; use core::marker::PhantomData; use futures_lite::AsyncWriteExt; @@ -25,8 +23,8 @@ use bevy_tasks::BoxedFuture; use crate::{ io::{ memory::{Dir, MemoryAssetReader, MemoryAssetWriter}, - AssetReader, AssetReaderError, AssetSourceBuilder, AssetSourceEvent, AssetSourceId, - AssetWatcher, PathStream, Reader, + AssetReader, AssetReaderError, AssetSourceBuilder, AssetSourceEvent, AssetWatcher, + PathStream, Reader, }, processor::{ AssetProcessor, LoadTransformAndSave, LogEntry, ProcessorState, ProcessorTransactionLog, @@ -35,7 +33,8 @@ use crate::{ saver::AssetSaver, tests::{run_app_until, CoolText, CoolTextLoader, CoolTextRon, SubText}, transformer::{AssetTransformer, TransformedAsset}, - Asset, AssetApp, AssetLoader, AssetMode, AssetPath, AssetPlugin, LoadContext, + Asset, AssetApp, AssetLoader, AssetMode, AssetPath, AssetPlugin, DefaultAssetSource, + LoadContext, }; #[derive(Clone)] @@ -49,7 +48,6 @@ struct AppWithProcessor { app: App, source_gate: Arc>, default_source_dirs: ProcessingDirs, - extra_sources_dirs: HashMap, } /// Similar to [`crate::io::gated::GatedReader`], but uses a lock instead of a channel to avoid @@ -107,94 +105,83 @@ fn serialize_as_cool_text(text: &str) -> String { ron::ser::to_string_pretty(&cool_text_ron, PrettyConfig::new().new_line("\n")).unwrap() } -fn create_app_with_asset_processor(extra_sources: &[String]) -> AppWithProcessor { - let mut app = App::new(); - let source_gate = Arc::new(RwLock::new(())); - - struct UnfinishedProcessingDirs { - source: Dir, - processed: Dir, - // The receiver channel for the source event sender for the unprocessed source. - source_event_sender_receiver: - async_channel::Receiver>, - } +/// The processing dirs that need to be "finished" before being used. +struct UnfinishedProcessingDirs { + /// The directory of the source assets. + source: Dir, + /// The directory of the processed assets. + processed: Dir, + /// The receiver channel for the source event sender for the unprocessed source. + source_event_sender_receiver: async_channel::Receiver>, +} - impl UnfinishedProcessingDirs { - fn finish(self) -> ProcessingDirs { - ProcessingDirs { - source: self.source, - processed: self.processed, - // The processor listens for events on the source unconditionally, and we enable - // watching for the processed source, so both of these channels will be filled. - source_event_sender: self.source_event_sender_receiver.recv_blocking().unwrap(), - } +impl UnfinishedProcessingDirs { + /// Attempts to finish the source by fetching the source event sender. + /// + /// This will block if called before adding the source. + fn finish(self) -> ProcessingDirs { + ProcessingDirs { + source: self.source, + processed: self.processed, + // The processor listens for events on the source unconditionally, and we enable + // watching for the processed source, so both of these channels will be filled. + source_event_sender: self.source_event_sender_receiver.recv_blocking().unwrap(), } } +} - fn create_source( - app: &mut App, - source_id: AssetSourceId<'static>, - source_gate: Arc>, - ) -> UnfinishedProcessingDirs { - let source_dir = Dir::default(); - let processed_dir = Dir::default(); +/// Creates a source, including its processed and unprocessed directories, gated on `source_gate`. +fn create_source(source_gate: Arc>) -> (AssetSourceBuilder, UnfinishedProcessingDirs) { + let source_dir = Dir::default(); + let processed_dir = Dir::default(); - let source_memory_reader = LockGatedReader::new( - source_gate, - MemoryAssetReader { - root: source_dir.clone(), - }, - ); - let processed_memory_reader = MemoryAssetReader { - root: processed_dir.clone(), - }; - let processed_memory_writer = MemoryAssetWriter { - root: processed_dir.clone(), - }; + let source_memory_reader = LockGatedReader::new( + source_gate, + MemoryAssetReader { + root: source_dir.clone(), + }, + ); + let processed_memory_reader = MemoryAssetReader { + root: processed_dir.clone(), + }; + let processed_memory_writer = MemoryAssetWriter { + root: processed_dir.clone(), + }; - let (source_event_sender_sender, source_event_sender_receiver) = async_channel::bounded(1); + let (source_event_sender_sender, source_event_sender_receiver) = async_channel::bounded(1); - struct FakeWatcher; + struct FakeWatcher; - impl AssetWatcher for FakeWatcher {} + impl AssetWatcher for FakeWatcher {} - app.register_asset_source( - source_id, - AssetSourceBuilder::new(move || Box::new(source_memory_reader.clone())) - .with_watcher(move |sender: async_channel::Sender| { - source_event_sender_sender.send_blocking(sender).unwrap(); - Some(Box::new(FakeWatcher)) - }) - .with_processed_reader(move || Box::new(processed_memory_reader.clone())) - .with_processed_writer(move |_| Some(Box::new(processed_memory_writer.clone()))), - ); + let source_builder = AssetSourceBuilder::new(move || Box::new(source_memory_reader.clone())) + .with_watcher(move |sender: async_channel::Sender| { + source_event_sender_sender.send_blocking(sender).unwrap(); + Some(Box::new(FakeWatcher)) + }) + .with_processed_reader(move || Box::new(processed_memory_reader.clone())) + .with_processed_writer(move |_| Some(Box::new(processed_memory_writer.clone()))); + ( + source_builder, UnfinishedProcessingDirs { source: source_dir, processed: processed_dir, source_event_sender_receiver, - } - } + }, + ) +} - let default_source_dirs = create_source(&mut app, AssetSourceId::Default, source_gate.clone()); - - let extra_sources_dirs = extra_sources - .iter() - .map(|source_name| { - ( - source_name.clone(), - create_source( - &mut app, - AssetSourceId::Name(source_name.clone().into()), - source_gate.clone(), - ), - ) - }) - .collect::>(); +fn create_app_with_asset_processor() -> AppWithProcessor { + let mut app = App::new(); + let source_gate = Arc::new(RwLock::new(())); + + let (default_source_builder, default_source_dirs) = create_source(source_gate.clone()); app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new(default_source_builder)), mode: AssetMode::Processed, use_asset_processor_override: Some(true), watch_for_changes_override: Some(true), @@ -248,19 +235,25 @@ fn create_app_with_asset_processor(extra_sources: &[String]) -> AppWithProcessor .set_log_factory(Box::new(FakeTransactionLogFactory)) .unwrap(); - // Now that we've built the app, finish all the processing dirs. + // Now that we've built the app, finish the default dir, and return it. AppWithProcessor { app, source_gate, default_source_dirs: default_source_dirs.finish(), - extra_sources_dirs: extra_sources_dirs - .into_iter() - .map(|(name, dirs)| (name, dirs.finish())) - .collect(), } } +fn register_new_source( + app: &mut App, + name: impl Into>, + source_gate: Arc>, +) -> ProcessingDirs { + let (source, processing_dirs) = create_source(source_gate); + app.register_asset_source(name.into(), source); + processing_dirs.finish() +} + fn run_app_until_finished_processing(app: &mut App, guard: RwLockWriteGuard<'_, ()>) { let processor = app.world().resource::().clone(); // We can't just wait for the processor state to be finished since we could have already @@ -373,8 +366,7 @@ fn no_meta_or_default_processor_copies_asset() { processed: processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); let guard = source_gate.write_blocking(); @@ -406,8 +398,7 @@ fn asset_processor_transforms_asset_default_processor() { processed: processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); type CoolTextProcessor = LoadTransformAndSave< CoolTextLoader, @@ -460,8 +451,7 @@ fn asset_processor_transforms_asset_with_meta() { processed: processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); type CoolTextProcessor = LoadTransformAndSave< CoolTextLoader, @@ -657,8 +647,7 @@ fn asset_processor_loading_can_read_processed_assets() { processed: processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); // This processor loads a gltf file, converts it to BSN and then saves out the BSN. type GltfProcessor = LoadTransformAndSave; @@ -731,8 +720,7 @@ fn asset_processor_loading_can_read_source_assets() { processed: processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); #[derive(Serialize, Deserialize)] struct FakeGltfxData { @@ -927,18 +915,18 @@ fn asset_processor_processes_all_sources() { processed: default_processed_dir, source_event_sender: default_source_events, }, - extra_sources_dirs, - } = create_app_with_asset_processor(&["custom_1".into(), "custom_2".into()]); + } = create_app_with_asset_processor(); + let ProcessingDirs { source: custom_1_source_dir, processed: custom_1_processed_dir, source_event_sender: custom_1_source_events, - } = extra_sources_dirs["custom_1"].clone(); + } = register_new_source(&mut app, "custom_1", source_gate.clone()); let ProcessingDirs { source: custom_2_source_dir, processed: custom_2_processed_dir, source_event_sender: custom_2_source_events, - } = extra_sources_dirs["custom_2"].clone(); + } = register_new_source(&mut app, "custom_2", source_gate.clone()); type AddTextProcessor = LoadTransformAndSave< CoolTextLoader, @@ -1043,13 +1031,12 @@ fn nested_loads_of_processed_asset_reprocesses_on_reload() { processed: default_processed_dir, source_event_sender: default_source_events, }, - extra_sources_dirs, - } = create_app_with_asset_processor(&["custom".into()]); + } = create_app_with_asset_processor(); let ProcessingDirs { source: custom_source_dir, processed: custom_processed_dir, source_event_sender: custom_source_events, - } = extra_sources_dirs["custom"].clone(); + } = register_new_source(&mut app, "custom", source_gate.clone()); #[derive(Serialize, Deserialize)] enum NesterSerialized { @@ -1261,8 +1248,7 @@ fn clears_invalid_data_from_processed_dir() { processed: default_processed_dir, .. }, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); type CoolTextProcessor = LoadTransformAndSave< CoolTextLoader, @@ -1367,8 +1353,7 @@ fn only_reprocesses_wrong_hash_on_startup() { mut app, source_gate, default_source_dirs, - .. - } = create_app_with_asset_processor(&[]); + } = create_app_with_asset_processor(); default_source_dir = default_source_dirs.source; default_processed_dir = default_source_dirs.processed; @@ -1450,16 +1435,16 @@ fn only_reprocesses_wrong_hash_on_startup() { root: default_processed_dir.clone(), }; - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(source_memory_reader.clone())) - .with_processed_reader(move || Box::new(processed_memory_reader.clone())) - .with_processed_writer(move |_| Some(Box::new(processed_memory_writer.clone()))), - ); - app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(source_memory_reader.clone())) + .with_processed_reader(move || Box::new(processed_memory_reader.clone())) + .with_processed_writer(move |_| { + Some(Box::new(processed_memory_writer.clone())) + }), + )), mode: AssetMode::Processed, use_asset_processor_override: Some(true), ..Default::default() From 7853d9989e5dc861b6431e5b45c5373dc1c42230 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sat, 8 Nov 2025 11:48:08 -0800 Subject: [PATCH 06/15] Prevent adding or removing processed asset sources after processing has started. --- crates/bevy_asset/src/io/source.rs | 83 +++++++++++++++++++++++--- crates/bevy_asset/src/processor/mod.rs | 13 ++++ crates/bevy_asset/src/server/mod.rs | 4 +- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/bevy_asset/src/io/source.rs b/crates/bevy_asset/src/io/source.rs index b341faa8f725e..75bb81bd69d62 100644 --- a/crates/bevy_asset/src/io/source.rs +++ b/crates/bevy_asset/src/io/source.rs @@ -8,9 +8,12 @@ use alloc::{ sync::Arc, }; use atomicow::CowArc; -use bevy_platform::collections::{hash_map::Entry, HashMap}; +use bevy_platform::collections::{ + hash_map::{Entry, EntryRef}, + HashMap, +}; use core::{fmt::Display, hash::Hash, time::Duration}; -use std::sync::RwLock; +use std::sync::{PoisonError, RwLock}; use thiserror::Error; use tracing::warn; @@ -600,18 +603,41 @@ impl AssetSources { source_builder: &mut AssetSourceBuilder, ) -> Result<(), AddSourceError> { let name = name.into(); + let entry = match self.sources.entry(name) { Entry::Occupied(entry) => return Err(AddSourceError::NameInUse(entry.key().clone())), Entry::Vacant(entry) => entry, }; let name = entry.key().clone(); - entry.insert(source_builder.build( + // Build the asset source. This does mean that we may build the source and then **not** add + // it (due to checks later on), but we need to build the source to know if it's processed. + let source = source_builder.build( AssetSourceId::Name(name), self.watch, self.watch_processed, self.processing_state.clone(), - )); + ); + + // Hold the processor started lock until after we insert the source (so that the processor + // doesn't start between when we check and when we insert) + let started_lock; + // If the source wants to be processed, and the processor has already started, return an + // error. + // TODO: Remove this once the processor can handle newly added sources. + if source.should_process() + && let Some(processing_state) = self.processing_state.as_ref() + { + started_lock = processing_state + .started + .read() + .unwrap_or_else(PoisonError::into_inner); + if *started_lock { + return Err(AddSourceError::SourceIsProcessed); + } + } + + entry.insert(source); Ok(()) } @@ -621,13 +647,36 @@ impl AssetSources { pub fn remove( &mut self, name: impl Into>, - ) -> Result<(), MissingAssetSourceError> { + ) -> Result<(), RemoveSourceError> { let name = name.into(); - if self.sources.remove(&name).is_none() { - return Err(MissingAssetSourceError(AssetSourceId::Name( - name.clone_owned(), - ))); + + // Use entry_ref so we don't need to clone the name just to remove the entry. + let entry = match self.sources.entry_ref(&name) { + EntryRef::Vacant(_) => { + return Err(RemoveSourceError::MissingSource(name.clone_owned())) + } + EntryRef::Occupied(entry) => entry, + }; + + // Hold the processor started lock until after we remove the source (so that the processor + // doesn't start between when we check and when we remove) + let started_lock; + // If the source wants to be processed, and the processor has already started, return an + // error. + // TODO: Remove this once the processor can handle removed sources. + if entry.get().should_process() + && let Some(processing_state) = self.processing_state.as_ref() + { + started_lock = processing_state + .started + .read() + .unwrap_or_else(PoisonError::into_inner); + if *started_lock { + return Err(RemoveSourceError::SourceIsProcessed); + } } + + entry.remove(); Ok(()) } @@ -653,11 +702,27 @@ impl AssetSources { /// An error when attempting to add a new asset source. #[derive(Error, Debug, Clone, PartialEq, Eq)] pub enum AddSourceError { + /// The provided asset source is a "processed" source, and processing has already started, where adding is currently unsupported. + // TODO: Remove this once it's supported. + #[error("The provided asset source is processed, but the asset processor has already started. This is currently unsupported - processed sources can only be added at startup")] + SourceIsProcessed, /// An asset source with the given name already exists. #[error("Asset Source '{0}' already exists")] NameInUse(CowArc<'static, str>), } +/// An error when attempting to remove an existing asset source. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum RemoveSourceError { + /// The requested asset source is a "processed" source, and processing has already started, where removing is currently unsupported. + // TODO: Remove this once it's supported. + #[error("The asset source being removed is processed, but the asset processor has already started. This is currently unsupported - processed sources can only be removed at startup")] + SourceIsProcessed, + /// The requested source is missing, so it cannot be removed. + #[error("Asset Source '{0}' does not exist")] + MissingSource(CowArc<'static, str>), +} + /// An error returned when an [`AssetSource`] does not exist for a given id. #[derive(Error, Debug, Clone, PartialEq, Eq)] #[error("Asset Source '{0}' does not exist")] diff --git a/crates/bevy_asset/src/processor/mod.rs b/crates/bevy_asset/src/processor/mod.rs index 3e1b4fae33293..c7b3b27263506 100644 --- a/crates/bevy_asset/src/processor/mod.rs +++ b/crates/bevy_asset/src/processor/mod.rs @@ -121,6 +121,12 @@ pub struct AssetProcessorData { /// The current state of processing, including the overall state and the state of all assets. pub(crate) struct ProcessingState { + /// A bool indicating whether the processor has started or not. + /// + /// This is different from `state` since `state` is async, and we only assign to it once, so we + /// should ~never block on it. + // TODO: Remove this once the processor can process new asset sources. + pub(crate) started: RwLock, /// The overall state of processing. state: async_lock::RwLock, /// The channel to broadcast when the processor has completed initialization. @@ -237,6 +243,12 @@ impl AssetProcessor { let start_time = std::time::Instant::now(); debug!("Processing Assets"); + *processor + .data + .processing_state + .started + .write() + .unwrap_or_else(PoisonError::into_inner) = true; let sources = processor .sources() .read() @@ -1303,6 +1315,7 @@ impl ProcessingState { finished_sender.set_overflow(true); Self { + started: Default::default(), state: async_lock::RwLock::new(ProcessorState::Initializing), initialized_sender, initialized_receiver, diff --git a/crates/bevy_asset/src/server/mod.rs b/crates/bevy_asset/src/server/mod.rs index c70177b78c8cd..4c98aab68ae66 100644 --- a/crates/bevy_asset/src/server/mod.rs +++ b/crates/bevy_asset/src/server/mod.rs @@ -6,7 +6,7 @@ use crate::{ io::{ AddSourceError, AssetReaderError, AssetSource, AssetSourceBuilder, AssetSourceEvent, AssetSourceId, AssetSources, AssetWriterError, ErasedAssetReader, MissingAssetSourceError, - MissingAssetWriterError, MissingProcessedAssetReaderError, Reader, + MissingAssetWriterError, MissingProcessedAssetReaderError, Reader, RemoveSourceError, }, loader::{AssetLoader, ErasedAssetLoader, LoadContext, LoadedAsset}, meta::{ @@ -211,7 +211,7 @@ impl AssetServer { pub fn remove_source( &self, name: impl Into>, - ) -> Result<(), MissingAssetSourceError> { + ) -> Result<(), RemoveSourceError> { self.data .sources .write() From a4839e27f166752bb858029f1f716888db679a62 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sat, 8 Nov 2025 14:27:53 -0800 Subject: [PATCH 07/15] Create a test to show that an asset can be loaded from a runtime added source. --- crates/bevy_asset/src/lib.rs | 133 +++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 21 deletions(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 6e4f70b308a8f..cf28695be9691 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -2657,6 +2657,28 @@ mod tests { assert_eq!(get_started_load_count(app.world()), 2); } + /// A trivial asset loader that just immediately produces a [`TestAsset`]. + struct TrivialLoader; + + impl AssetLoader for TrivialLoader { + type Asset = TestAsset; + type Settings = (); + type Error = std::io::Error; + + async fn load( + &self, + _reader: &mut dyn Reader, + _settings: &Self::Settings, + _load_context: &mut LoadContext<'_>, + ) -> Result { + Ok(TestAsset) + } + + fn extensions(&self) -> &[&str] { + &["txt"] + } + } + #[test] fn get_strong_handle_prevents_reload_when_asset_still_alive() { let mut app = App::new(); @@ -2676,27 +2698,6 @@ mod tests { )) .init_asset::(); - struct TrivialLoader; - - impl AssetLoader for TrivialLoader { - type Asset = TestAsset; - type Settings = (); - type Error = std::io::Error; - - async fn load( - &self, - _reader: &mut dyn Reader, - _settings: &Self::Settings, - _load_context: &mut LoadContext<'_>, - ) -> Result { - Ok(TestAsset) - } - - fn extensions(&self) -> &[&str] { - &["txt"] - } - } - app.register_asset_loader(TrivialLoader); let asset_server = app.world().resource::().clone(); @@ -2739,4 +2740,94 @@ mod tests { // assert_eq!(get_started_load_count(app.world()), 1); assert_eq!(get_started_load_count(app.world()), 2); } + + // Creates a new asset source that reads from the returned dir. + fn create_dir_source() -> (AssetSourceBuilder, Dir) { + let dir = Dir::default(); + let dir_clone = dir.clone(); + ( + AssetSourceBuilder::new(move || { + Box::new(MemoryAssetReader { + root: dir_clone.clone(), + }) + }), + dir, + ) + } + + #[test] + fn can_load_asset_from_runtime_added_sources() { + let mut app = App::new(); + let (default_source, _) = create_dir_source(); + app.add_plugins(( + TaskPoolPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new(default_source)), + ..Default::default() + }, + DiagnosticsPlugin, + )); + + app.init_asset::() + .register_asset_loader(TrivialLoader); + + let asset_server = app.world().resource::().clone(); + let custom_handle: Handle = asset_server.load("custom://asset.txt"); + + run_app_until(&mut app, |_| { + match asset_server.get_load_state(&custom_handle).unwrap() { + LoadState::Loading => None, + LoadState::Failed(err) => match err.as_ref() { + AssetLoadError::MissingAssetSourceError(_) => Some(()), + err => panic!("Unexpected load error: {err}"), + }, + err => panic!("Unexpected state for asset load: {err:?}"), + } + }); + + // Drop the handle, and let the app update to react to that. + drop(custom_handle); + app.update(); + + let (mut custom_source, custom_dir) = create_dir_source(); + custom_dir.insert_asset_text(Path::new("asset.txt"), ""); + + asset_server + .add_source("custom", &mut custom_source) + .unwrap(); + + // Now that we have added the "custom" asset source, loading the asset should work! + let custom_handle: Handle = asset_server.load("custom://asset.txt"); + run_app_until(&mut app, |_| { + match asset_server.get_load_state(&custom_handle).unwrap() { + LoadState::Loading => None, + LoadState::Loaded => Some(()), + err => panic!("Unexpected state for asset load: {err:?}"), + } + }); + + // Removing the source shouldn't change anything about the asset, so it should still be alive. + asset_server.remove_source("custom").unwrap(); + app.update(); + assert!(app + .world() + .resource::>() + .contains(&custom_handle)); + + drop(custom_handle); + app.update(); + + // After removing the "custom" asset source, trying to load the asset again should fail. + let custom_handle: Handle = asset_server.load("custom://asset.txt"); + run_app_until(&mut app, |_| { + match asset_server.get_load_state(&custom_handle).unwrap() { + LoadState::Loading => None, + LoadState::Failed(err) => match err.as_ref() { + AssetLoadError::MissingAssetSourceError(_) => Some(()), + err => panic!("Unexpected load error: {err}"), + }, + err => panic!("Unexpected state for asset load: {err:?}"), + } + }); + } } From 71bc5bec56ce3b1dc6f27c50b0719f01a99a2c18 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sat, 8 Nov 2025 15:55:03 -0800 Subject: [PATCH 08/15] Fix up all the dependents and examples to add the default source in the AssetPlugin. --- crates/bevy_gltf/src/loader/mod.rs | 52 ++++++++++--------- examples/asset/asset_settings.rs | 6 ++- examples/asset/custom_asset_reader.rs | 41 +++++++-------- examples/asset/extra_source.rs | 8 ++- examples/asset/processing/asset_processing.rs | 11 ++-- examples/tools/scene_viewer/main.rs | 8 ++- 6 files changed, 67 insertions(+), 59 deletions(-) diff --git a/crates/bevy_gltf/src/loader/mod.rs b/crates/bevy_gltf/src/loader/mod.rs index 7c6c0eaa56ca9..280dd77be1f85 100644 --- a/crates/bevy_gltf/src/loader/mod.rs +++ b/crates/bevy_gltf/src/loader/mod.rs @@ -1901,16 +1901,17 @@ struct MorphTargetNames { #[cfg(test)] mod test { - use std::path::Path; + use std::{path::Path, sync::Mutex}; use crate::{Gltf, GltfAssetLabel, GltfNode, GltfSkin}; use bevy_app::{App, TaskPoolPlugin}; use bevy_asset::{ io::{ memory::{Dir, MemoryAssetReader}, - AssetSourceBuilder, AssetSourceId, + AssetSourceBuilder, }, - AssetApp, AssetLoader, AssetPlugin, AssetServer, Assets, Handle, LoadState, + AssetApp, AssetLoader, AssetPlugin, AssetServer, Assets, DefaultAssetSource, Handle, + LoadState, }; use bevy_ecs::{resource::Resource, world::World}; use bevy_image::{Image, ImageLoaderSettings}; @@ -1923,14 +1924,15 @@ mod test { fn test_app(dir: Dir) -> App { let mut app = App::new(); let reader = MemoryAssetReader { root: dir }; - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || Box::new(reader.clone())), - ) - .add_plugins(( + app.add_plugins(( LogPlugin::default(), TaskPoolPlugin::default(), - AssetPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(reader.clone())), + )), + ..Default::default() + }, ScenePlugin, MeshPlugin, crate::GltfPlugin::default(), @@ -2341,27 +2343,27 @@ mod test { let mut app = App::new(); let custom_reader = MemoryAssetReader { root: dir.clone() }; - // Create a default asset source so we definitely don't try to read from disk. - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(move || { - Box::new(MemoryAssetReader { - root: Dir::default(), - }) - }), - ) - .register_asset_source( - "custom", - AssetSourceBuilder::new(move || Box::new(custom_reader.clone())), - ) - .add_plugins(( + app.add_plugins(( LogPlugin::default(), TaskPoolPlugin::default(), - AssetPlugin::default(), + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || { + Box::new(MemoryAssetReader { + root: Dir::default(), + }) + }), + )), + ..Default::default() + }, ScenePlugin, MeshPlugin, crate::GltfPlugin::default(), - )); + )) + .register_asset_source( + "custom", + AssetSourceBuilder::new(move || Box::new(custom_reader.clone())), + ); app.finish(); app.cleanup(); diff --git a/examples/asset/asset_settings.rs b/examples/asset/asset_settings.rs index 9f4c1fe507dfe..eb6aa4d4fce43 100644 --- a/examples/asset/asset_settings.rs +++ b/examples/asset/asset_settings.rs @@ -1,6 +1,7 @@ //! This example demonstrates the usage of '.meta' files and [`AssetServer::load_with_settings`] to override the default settings for loading an asset use bevy::{ + asset::DefaultAssetSource, image::{ImageLoaderSettings, ImageSampler}, prelude::*, }; @@ -10,7 +11,10 @@ fn main() { .add_plugins( // This just tells the asset server to look in the right examples folder DefaultPlugins.set(AssetPlugin { - file_path: "examples/asset/files".to_string(), + default_source: DefaultAssetSource::FromPaths { + file_path: "examples/asset/files".to_string(), + processed_file_path: None, + }, ..Default::default() }), ) diff --git a/examples/asset/custom_asset_reader.rs b/examples/asset/custom_asset_reader.rs index d95abb6a6b650..b6d3371c43137 100644 --- a/examples/asset/custom_asset_reader.rs +++ b/examples/asset/custom_asset_reader.rs @@ -3,13 +3,16 @@ //! It does not know anything about the asset formats, only how to talk to the underlying storage. use bevy::{ - asset::io::{ - AssetReader, AssetReaderError, AssetSource, AssetSourceBuilder, AssetSourceId, - ErasedAssetReader, PathStream, Reader, + asset::{ + io::{ + AssetReader, AssetReaderError, AssetSource, AssetSourceBuilder, ErasedAssetReader, + PathStream, Reader, + }, + DefaultAssetSource, }, prelude::*, }; -use std::path::Path; +use std::{path::Path, sync::Mutex}; /// A custom asset reader implementation that wraps a given asset reader implementation struct CustomAssetReader(Box); @@ -35,26 +38,20 @@ impl AssetReader for CustomAssetReader { } } -/// A plugins that registers our new asset reader -struct CustomAssetReaderPlugin; - -impl Plugin for CustomAssetReaderPlugin { - fn build(&self, app: &mut App) { - app.register_asset_source( - AssetSourceId::Default, - AssetSourceBuilder::new(|| { - Box::new(CustomAssetReader( - // This is the default reader for the current platform - AssetSource::get_default_reader("assets".to_string())(), - )) - }), - ); - } -} - fn main() { App::new() - .add_plugins((CustomAssetReaderPlugin, DefaultPlugins)) + .add_plugins(DefaultPlugins.set(AssetPlugin { + // The default source must be registered in the `AssetPlugin`. + default_source: DefaultAssetSource::FromBuilder(Mutex::new(AssetSourceBuilder::new( + || { + Box::new(CustomAssetReader( + // This is the default reader for the current platform + AssetSource::get_default_reader("assets".to_string())(), + )) + }, + ))), + ..Default::default() + })) .add_systems(Startup, setup) .run(); } diff --git a/examples/asset/extra_source.rs b/examples/asset/extra_source.rs index 298b9d4aa85c7..44ccd31932976 100644 --- a/examples/asset/extra_source.rs +++ b/examples/asset/extra_source.rs @@ -12,17 +12,15 @@ use std::path::Path; fn main() { App::new() + // DefaultPlugins contains AssetPlugin so it must be added to our App + // before inserting our new asset source. + .add_plugins(DefaultPlugins) // Add an extra asset source with the name "example_files" to // AssetSourceBuilders. - // - // This must be done before AssetPlugin finalizes building assets. .register_asset_source( "example_files", AssetSourceBuilder::platform_default("examples/asset/files", None), ) - // DefaultPlugins contains AssetPlugin so it must be added to our App - // after inserting our new asset source. - .add_plugins(DefaultPlugins) .add_systems(Startup, setup) .run(); } diff --git a/examples/asset/processing/asset_processing.rs b/examples/asset/processing/asset_processing.rs index d5da644c27190..7b4e1c1ce2500 100644 --- a/examples/asset/processing/asset_processing.rs +++ b/examples/asset/processing/asset_processing.rs @@ -7,7 +7,7 @@ use bevy::{ processor::LoadTransformAndSave, saver::{AssetSaver, SavedAsset}, transformer::{AssetTransformer, TransformedAsset}, - AssetLoader, AsyncWriteExt, LoadContext, + AssetLoader, AsyncWriteExt, DefaultAssetSource, LoadContext, }, prelude::*, reflect::TypePath, @@ -31,9 +31,12 @@ fn main() { mode: AssetMode::Processed, // This is just overriding the default paths to scope this to the correct example folder // You can generally skip this in your own projects - file_path: "examples/asset/processing/assets".to_string(), - processed_file_path: "examples/asset/processing/imported_assets/Default" - .to_string(), + default_source: DefaultAssetSource::FromPaths { + file_path: "examples/asset/processing/assets".to_string(), + processed_file_path: Some( + "examples/asset/processing/imported_assets/Default".to_string(), + ), + }, ..default() }), TextPlugin, diff --git a/examples/tools/scene_viewer/main.rs b/examples/tools/scene_viewer/main.rs index 7a33843c78626..7436937425a80 100644 --- a/examples/tools/scene_viewer/main.rs +++ b/examples/tools/scene_viewer/main.rs @@ -10,7 +10,7 @@ use argh::FromArgs; use bevy::{ - asset::UnapprovedPathMode, + asset::{DefaultAssetSource, UnapprovedPathMode}, camera::primitives::{Aabb, Sphere}, camera_controller::free_camera::{FreeCamera, FreeCameraPlugin}, core_pipeline::prepass::{DeferredPrepass, DepthPrepass}, @@ -86,7 +86,11 @@ fn main() { ..default() }) .set(AssetPlugin { - file_path: std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()), + default_source: DefaultAssetSource::FromPaths { + file_path: std::env::var("CARGO_MANIFEST_DIR") + .unwrap_or_else(|_| ".".to_string()), + processed_file_path: None, + }, // Allow scenes to be loaded from anywhere on disk unapproved_path_mode: UnapprovedPathMode::Allow, ..default() From 8bd59ab7f34959b740791482fb9ffc2612e21903 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sun, 9 Nov 2025 10:28:04 -0800 Subject: [PATCH 09/15] Write a test to show that we can't add or remove processed sources after the processor starts. --- crates/bevy_asset/src/processor/tests.rs | 60 ++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/bevy_asset/src/processor/tests.rs b/crates/bevy_asset/src/processor/tests.rs index b50082d546f70..bbdd7ac1f6f3e 100644 --- a/crates/bevy_asset/src/processor/tests.rs +++ b/crates/bevy_asset/src/processor/tests.rs @@ -23,8 +23,8 @@ use bevy_tasks::BoxedFuture; use crate::{ io::{ memory::{Dir, MemoryAssetReader, MemoryAssetWriter}, - AssetReader, AssetReaderError, AssetSourceBuilder, AssetSourceEvent, AssetWatcher, - PathStream, Reader, + AddSourceError, AssetReader, AssetReaderError, AssetSourceBuilder, AssetSourceEvent, + AssetWatcher, PathStream, Reader, RemoveSourceError, }, processor::{ AssetProcessor, LoadTransformAndSave, LogEntry, ProcessorState, ProcessorTransactionLog, @@ -33,8 +33,8 @@ use crate::{ saver::AssetSaver, tests::{run_app_until, CoolText, CoolTextLoader, CoolTextRon, SubText}, transformer::{AssetTransformer, TransformedAsset}, - Asset, AssetApp, AssetLoader, AssetMode, AssetPath, AssetPlugin, DefaultAssetSource, - LoadContext, + Asset, AssetApp, AssetLoader, AssetMode, AssetPath, AssetPlugin, AssetServer, + DefaultAssetSource, LoadContext, }; #[derive(Clone)] @@ -1491,3 +1491,55 @@ fn only_reprocesses_wrong_hash_on_startup() { serialize_as_cool_text("dep_changed processed DIFFERENT processed") ); } + +// TODO: Replace this test once the asset processor can handle adding and removing sources. +#[test] +fn fails_to_add_or_remove_source_after_processor_starts() { + let AppWithProcessor { + mut app, + source_gate, + .. + } = create_app_with_asset_processor(); + + let asset_server = app.world().resource::().clone(); + + app.register_asset_source("custom_1", create_source(source_gate.clone()).0); + // Despite the source being processed, we can remove it before the processor starts. + asset_server.remove_source("custom_1").unwrap(); + + // We can still add processed sources before the processor starts. + asset_server + .add_source("custom_2", &mut create_source(source_gate.clone()).0) + .unwrap(); + + let guard = source_gate.write_blocking(); + // The processor starts as soon as we update for the first time. + app.update(); + // Starting the processor task does not guarantee that the start flag is set in multi_threaded, + // so wait for processing to finish to avoid that race condition. + run_app_until_finished_processing(&mut app, guard); + + // We can't remove the source because it is processed. + assert_eq!( + asset_server.remove_source("custom_2"), + Err(RemoveSourceError::SourceIsProcessed) + ); + + // We can't add a processed source, since the processor has started. + assert_eq!( + asset_server.add_source("custom_3", &mut create_source(source_gate).0), + Err(AddSourceError::SourceIsProcessed) + ); + + // However we can add unprocessed sources even after the processor has started! + asset_server + .add_source( + "custom_4", + &mut AssetSourceBuilder::new(move || { + Box::new(MemoryAssetReader { + root: Dir::default(), + }) + }), + ) + .unwrap(); +} From 6c360c8f7cf6a73b6f7dea8df5ba715ca819414b Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sun, 9 Nov 2025 10:51:06 -0800 Subject: [PATCH 10/15] Create a migration guide for registering asset sources. --- .../registering_asset_sources.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 release-content/migration-guides/registering_asset_sources.md diff --git a/release-content/migration-guides/registering_asset_sources.md b/release-content/migration-guides/registering_asset_sources.md new file mode 100644 index 0000000000000..c229c68189743 --- /dev/null +++ b/release-content/migration-guides/registering_asset_sources.md @@ -0,0 +1,95 @@ +--- +title: Registering asset sources +pull_requests: [] +--- + +In previous versions, asset sources had to be registered **before** adding the `AssetPlugin` (which +usually meant before adding the `DefaultPlugins`). Now, asset sources must be registered **after** +`AssetPlugin` (and in effect, after `DefaultPlugins`). So if you had: + +```rust +App::new() + .register_asset_source("my_source", + AssetSource::build() + .with_reader(move || Box::new(todo!())) + ) + .add_plugins(DefaultPlugins) + .run(); +``` + +Now, it will be: + +```rust +App::new() + .add_plugins(DefaultPlugins) + .register_asset_source("my_source", + AssetSourceBuilder::new(move || Box::new(todo!())) + ) + .run(); +``` + +Note: See also "Custom asset sources now require a reader" for changes to builders. + +In addition, default asset sources **can no longer be registered like custom sources**. There are +two cases here: + +## 1. File paths + +The `AssetPlugin` will create the default asset source for a pair of file paths. Previously, this +was written as: + +```rust +App::new() + .add_plugins(DefaultPlugins.set( + AssetPlugin { + file_path: "some/path".to_string(), + processed_file_path: "some/processed_path".to_string(), + ..Default::default() + } + )); +``` + +Now, this is written as: + +```rust +App::new() + .add_plugins(DefaultPlugins.set( + AssetPlugin { + default_source: DefaultAssetSource::FromPaths { + file_path: "some/path".to_string(), + // Note: Setting this to None will just use the default path. + processed_file_path: Some("some/processed_path".to_string()), + }, + ..Default::default() + } + )); +``` + +## 2. Custom default source + +Users can also completely replace the default asset source to provide their own implementation. +Previously, this was written as: + +```rust +App::new() + .register_asset_source( + AssetSourceId::Default, + AssetSource::build() + .with_reader(move || Box::new(todo!())) + ) + .add_plugins(DefaultPlugins); +``` + +Now, this is written as: + +```rust +App::new() + .add_plugins(DefaultPlugins.set( + AssetPlugin { + default_source: DefaultAssetSource::FromBuilder(Mutex::new( + AssetSourceBuilder::new(move || Box::new(todo!())) + )), + ..Default::default() + } + )); +``` From 86a92a06187a96bcbc5963cc397db77ff8a021bf Mon Sep 17 00:00:00 2001 From: andriyDev Date: Sun, 9 Nov 2025 11:12:49 -0800 Subject: [PATCH 11/15] Create release notes for runtime asset sources. --- .../release-notes/runtime_sources.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 release-content/release-notes/runtime_sources.md diff --git a/release-content/release-notes/runtime_sources.md b/release-content/release-notes/runtime_sources.md new file mode 100644 index 0000000000000..b94d897133408 --- /dev/null +++ b/release-content/release-notes/runtime_sources.md @@ -0,0 +1,51 @@ +--- +title: Adding and removing asset sources at runtime +authors: ["@andriyDev"] +pull_requests: [] +--- + +Custom asset sources are a great way to extend the asset system to access data from all sorts of +sources, whether that be a file system, or a webserver, or a compressed package. Unfortunately, in +previous versions, asset sources could **only** be added before the app starts! This prevents users +from choosing their sources at runtime. + +For a concrete example, consider the case of an application which allows you to pick a `zip` file to +open. Internally, a `zip` is its own little filesystem. Representing this as an asset source is +quite natural and allows loading just the parts you need. However, since we couldn't previously add +asset sources at runtime, this wasn't possible! + +Now you can add asset sources quite easily! + +```rust +fn add_source_and_load( + mut commands: Commands, + asset_server: Res, +) -> Result<(), BevyError> { + let user_selected_file_path: String = todo!(); + + asset_server.add_source( + "user_directory", + &mut AssetSourceBuilder::platform_default(&user_selected_file_path, None) + )?; + + let wallpaper = asset_server.load("user_directory://wallpaper.png"); + commands.spawn(Sprite { image: wallpaper, ..Default::default() }); + + Ok(()) +} +``` + +Asset sources can also be removed at runtime, allowing you to load and unload asset sources as +necessary. + +We've also changed the behavior of registering asset sources. Previously, you needed to register +asset sources **before** `DefaultPlugins` (more accurately, the `AssetPlugin`). This was uninuitive, +and resulted in limitations, like effectively preventing crate authors from registering their own +asset sources (since crate plugins often need to come after `DefaultPlugins`). Now, asset sources +need to be registered after `AssetPlugin` (and so, `DefaultPlugins`). + +## Limitations + +A limitation is that asset sources added after `Startup` **cannot be processed**. Attempting to add +such a source will return an error. Similarly, removing a processed source returns an error. In the +future, we hope to lift this limitation and allow runtime asset sources to be processed. From 4ac516a2462632f041cc1656e5f4320c1f8b4be3 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Thu, 20 Nov 2025 20:17:17 -0800 Subject: [PATCH 12/15] Reword release notes. Co-authored-by: Greeble <166992735+greeble-dev@users.noreply.github.com> --- release-content/release-notes/runtime_sources.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-content/release-notes/runtime_sources.md b/release-content/release-notes/runtime_sources.md index b94d897133408..3d0a4e1de3db4 100644 --- a/release-content/release-notes/runtime_sources.md +++ b/release-content/release-notes/runtime_sources.md @@ -46,6 +46,6 @@ need to be registered after `AssetPlugin` (and so, `DefaultPlugins`). ## Limitations -A limitation is that asset sources added after `Startup` **cannot be processed**. Attempting to add +A limitation is that asset sources added after `Startup` cannot be **processed** asset sources. Attempting to add such a source will return an error. Similarly, removing a processed source returns an error. In the future, we hope to lift this limitation and allow runtime asset sources to be processed. From ddd8634558de5b27ed65bd437883b5d245d3b283 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Thu, 20 Nov 2025 20:20:48 -0800 Subject: [PATCH 13/15] Reword error to be a more clear when adding sources at the wrong time. Co-authored-by: Greeble <166992735+greeble-dev@users.noreply.github.com> --- crates/bevy_asset/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index cf28695be9691..67f069f80190a 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -652,7 +652,7 @@ impl AssetApp for App { ) -> &mut Self { let name = name.into(); let Some(asset_server) = self.world().get_resource::() else { - error!("{} must be registered after `AssetPlugin` (typically added as part of `DefaultPlugins`)", name); + error!("`register_asset_source` was called, but `AssetPlugin` has not been added. `AssetPlugin` should be added first (typically as part of `DefaultPlugins`). Source was \"{name}\"."); return self; }; From efa0bcbf0c8f6d453a88b2aef6ac8545c2078fc8 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Thu, 20 Nov 2025 20:23:35 -0800 Subject: [PATCH 14/15] Clarify blocking on the RwLock around the `started` flag. --- crates/bevy_asset/src/processor/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/processor/mod.rs b/crates/bevy_asset/src/processor/mod.rs index c7b3b27263506..f7787a5dbfd10 100644 --- a/crates/bevy_asset/src/processor/mod.rs +++ b/crates/bevy_asset/src/processor/mod.rs @@ -124,7 +124,7 @@ pub(crate) struct ProcessingState { /// A bool indicating whether the processor has started or not. /// /// This is different from `state` since `state` is async, and we only assign to it once, so we - /// should ~never block on it. + /// should almost never block on it (and only for a few cycles). // TODO: Remove this once the processor can process new asset sources. pub(crate) started: RwLock, /// The overall state of processing. From b980d56a75ae6f90e2662a154be2a4dab0933370 Mon Sep 17 00:00:00 2001 From: andriyDev Date: Thu, 20 Nov 2025 20:48:45 -0800 Subject: [PATCH 15/15] Provide a from_builder and from_paths method for creating `DefaultAssetSource`. --- crates/bevy_asset/src/io/file/mod.rs | 2 +- crates/bevy_asset/src/lib.rs | 63 ++++++++++++------- crates/bevy_asset/src/processor/tests.rs | 6 +- crates/bevy_gltf/src/loader/mod.rs | 12 ++-- examples/asset/asset_settings.rs | 8 +-- examples/asset/custom_asset_reader.rs | 16 +++-- examples/asset/processing/asset_processing.rs | 10 ++- examples/tools/scene_viewer/main.rs | 9 ++- .../registering_asset_sources.md | 14 ++--- 9 files changed, 76 insertions(+), 64 deletions(-) diff --git a/crates/bevy_asset/src/io/file/mod.rs b/crates/bevy_asset/src/io/file/mod.rs index 6a0b752bf8bd2..d889197e1f4d8 100644 --- a/crates/bevy_asset/src/io/file/mod.rs +++ b/crates/bevy_asset/src/io/file/mod.rs @@ -52,7 +52,7 @@ impl FileAssetReader { /// Returns the base path of the assets directory, which is normally the executable's parent /// directory. /// - /// To change this, set [`DefaultAssetSource::FromPaths::file_path`][crate::DefaultAssetSource::FromPaths::file_path]. + /// To change this, set [`DefaultAssetSource::Paths::file_path`][crate::DefaultAssetSource::Paths::file_path]. pub fn get_base_path() -> PathBuf { get_base_path() } diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 67f069f80190a..3e7f470aff444 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -335,7 +335,7 @@ pub enum AssetMetaCheck { /// Type to define how to create the default asset source. pub enum DefaultAssetSource { /// Create the default asset source given these file paths. - FromPaths { + Paths { /// The path to the unprocessed assets. file_path: String, /// The path to the processed assets. @@ -347,14 +347,33 @@ pub enum DefaultAssetSource { /// /// Note: The Mutex is just an implementation detail for applying the /// plugin. - FromBuilder(Mutex), + Builder(Mutex), +} + +impl DefaultAssetSource { + /// Creates an instance that will build the default source for the platform given the file + /// paths. + /// + /// If `processed_file_path` is [`None`], the default file path is used when in + /// [`AssetMode::Processed`]. + pub fn from_paths(file_path: String, processed_file_path: Option) -> Self { + Self::Paths { + file_path, + processed_file_path, + } + } + + /// Creates an instance that will build the source from the provided builder. + pub fn from_builder(builder: AssetSourceBuilder) -> Self { + Self::Builder(Mutex::new(builder)) + } } impl Default for AssetPlugin { fn default() -> Self { Self { mode: AssetMode::Unprocessed, - default_source: DefaultAssetSource::FromPaths { + default_source: DefaultAssetSource::Paths { file_path: Self::DEFAULT_UNPROCESSED_FILE_PATH.to_string(), processed_file_path: None, }, @@ -379,7 +398,7 @@ impl Plugin for AssetPlugin { let mut default_source_builder; let default_source_builder_ref; match &self.default_source { - DefaultAssetSource::FromPaths { + DefaultAssetSource::Paths { file_path, processed_file_path, } => { @@ -393,7 +412,7 @@ impl Plugin for AssetPlugin { AssetSourceBuilder::platform_default(file_path, processed_file_path); default_source_builder_ref = &mut default_source_builder; } - DefaultAssetSource::FromBuilder(builder) => { + DefaultAssetSource::Builder(builder) => { lock = builder.lock().unwrap_or_else(PoisonError::into_inner); default_source_builder_ref = &mut *lock; } @@ -946,8 +965,8 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || Box::new(gated_memory_reader.clone())), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(gated_memory_reader.clone()), )), ..Default::default() }, @@ -1956,10 +1975,8 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || { - Box::new(MemoryAssetReader { root: dir.clone() }) - }), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(MemoryAssetReader { root: dir.clone() }), )), ..Default::default() }, @@ -2085,8 +2102,8 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || Box::new(memory_reader.clone())), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(memory_reader.clone()), )), unapproved_path_mode: mode, ..Default::default() @@ -2222,8 +2239,8 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || Box::new(reader.clone())), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(reader.clone()), )), ..Default::default() }, @@ -2282,8 +2299,8 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || Box::new(reader.clone())), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(reader.clone()), )), ..Default::default() }, @@ -2352,14 +2369,14 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( + default_source: DefaultAssetSource::from_builder( AssetSourceBuilder::new(move || Box::new(memory_reader.clone())).with_watcher( move |sender| { sender_sender.send(sender).unwrap(); Some(Box::new(FakeWatcher)) }, ), - )), + ), watch_for_changes_override: Some(true), ..Default::default() }, @@ -2548,7 +2565,7 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + default_source: DefaultAssetSource::from_builder(asset_source), ..Default::default() }, )) @@ -2613,7 +2630,7 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + default_source: DefaultAssetSource::from_builder(asset_source), ..Default::default() }, )) @@ -2692,7 +2709,7 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new(asset_source)), + default_source: DefaultAssetSource::from_builder(asset_source), ..Default::default() }, )) @@ -2762,7 +2779,7 @@ mod tests { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new(default_source)), + default_source: DefaultAssetSource::from_builder(default_source), ..Default::default() }, DiagnosticsPlugin, diff --git a/crates/bevy_asset/src/processor/tests.rs b/crates/bevy_asset/src/processor/tests.rs index bbdd7ac1f6f3e..491f911d8fd1c 100644 --- a/crates/bevy_asset/src/processor/tests.rs +++ b/crates/bevy_asset/src/processor/tests.rs @@ -181,7 +181,7 @@ fn create_app_with_asset_processor() -> AppWithProcessor { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new(default_source_builder)), + default_source: DefaultAssetSource::from_builder(default_source_builder), mode: AssetMode::Processed, use_asset_processor_override: Some(true), watch_for_changes_override: Some(true), @@ -1438,13 +1438,13 @@ fn only_reprocesses_wrong_hash_on_startup() { app.add_plugins(( TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( + default_source: DefaultAssetSource::from_builder( AssetSourceBuilder::new(move || Box::new(source_memory_reader.clone())) .with_processed_reader(move || Box::new(processed_memory_reader.clone())) .with_processed_writer(move |_| { Some(Box::new(processed_memory_writer.clone())) }), - )), + ), mode: AssetMode::Processed, use_asset_processor_override: Some(true), ..Default::default() diff --git a/crates/bevy_gltf/src/loader/mod.rs b/crates/bevy_gltf/src/loader/mod.rs index 280dd77be1f85..f27dfc85d290f 100644 --- a/crates/bevy_gltf/src/loader/mod.rs +++ b/crates/bevy_gltf/src/loader/mod.rs @@ -1901,7 +1901,7 @@ struct MorphTargetNames { #[cfg(test)] mod test { - use std::{path::Path, sync::Mutex}; + use std::path::Path; use crate::{Gltf, GltfAssetLabel, GltfNode, GltfSkin}; use bevy_app::{App, TaskPoolPlugin}; @@ -1928,8 +1928,8 @@ mod test { LogPlugin::default(), TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || Box::new(reader.clone())), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || Box::new(reader.clone()), )), ..Default::default() }, @@ -2347,12 +2347,12 @@ mod test { LogPlugin::default(), TaskPoolPlugin::default(), AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( - AssetSourceBuilder::new(move || { + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new( + move || { Box::new(MemoryAssetReader { root: Dir::default(), }) - }), + }, )), ..Default::default() }, diff --git a/examples/asset/asset_settings.rs b/examples/asset/asset_settings.rs index eb6aa4d4fce43..c11c4ab348fa2 100644 --- a/examples/asset/asset_settings.rs +++ b/examples/asset/asset_settings.rs @@ -11,10 +11,10 @@ fn main() { .add_plugins( // This just tells the asset server to look in the right examples folder DefaultPlugins.set(AssetPlugin { - default_source: DefaultAssetSource::FromPaths { - file_path: "examples/asset/files".to_string(), - processed_file_path: None, - }, + default_source: DefaultAssetSource::from_paths( + "examples/asset/files".to_string(), + None, + ), ..Default::default() }), ) diff --git a/examples/asset/custom_asset_reader.rs b/examples/asset/custom_asset_reader.rs index b6d3371c43137..4701b81e68568 100644 --- a/examples/asset/custom_asset_reader.rs +++ b/examples/asset/custom_asset_reader.rs @@ -12,7 +12,7 @@ use bevy::{ }, prelude::*, }; -use std::{path::Path, sync::Mutex}; +use std::path::Path; /// A custom asset reader implementation that wraps a given asset reader implementation struct CustomAssetReader(Box); @@ -42,14 +42,12 @@ fn main() { App::new() .add_plugins(DefaultPlugins.set(AssetPlugin { // The default source must be registered in the `AssetPlugin`. - default_source: DefaultAssetSource::FromBuilder(Mutex::new(AssetSourceBuilder::new( - || { - Box::new(CustomAssetReader( - // This is the default reader for the current platform - AssetSource::get_default_reader("assets".to_string())(), - )) - }, - ))), + default_source: DefaultAssetSource::from_builder(AssetSourceBuilder::new(|| { + Box::new(CustomAssetReader( + // This is the default reader for the current platform + AssetSource::get_default_reader("assets".to_string())(), + )) + })), ..Default::default() })) .add_systems(Startup, setup) diff --git a/examples/asset/processing/asset_processing.rs b/examples/asset/processing/asset_processing.rs index 7b4e1c1ce2500..2a0448524765d 100644 --- a/examples/asset/processing/asset_processing.rs +++ b/examples/asset/processing/asset_processing.rs @@ -31,12 +31,10 @@ fn main() { mode: AssetMode::Processed, // This is just overriding the default paths to scope this to the correct example folder // You can generally skip this in your own projects - default_source: DefaultAssetSource::FromPaths { - file_path: "examples/asset/processing/assets".to_string(), - processed_file_path: Some( - "examples/asset/processing/imported_assets/Default".to_string(), - ), - }, + default_source: DefaultAssetSource::from_paths( + "examples/asset/processing/assets".to_string(), + Some("examples/asset/processing/imported_assets/Default".to_string()), + ), ..default() }), TextPlugin, diff --git a/examples/tools/scene_viewer/main.rs b/examples/tools/scene_viewer/main.rs index 7436937425a80..209823addaa40 100644 --- a/examples/tools/scene_viewer/main.rs +++ b/examples/tools/scene_viewer/main.rs @@ -86,11 +86,10 @@ fn main() { ..default() }) .set(AssetPlugin { - default_source: DefaultAssetSource::FromPaths { - file_path: std::env::var("CARGO_MANIFEST_DIR") - .unwrap_or_else(|_| ".".to_string()), - processed_file_path: None, - }, + default_source: DefaultAssetSource::from_paths( + std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()), + None, + ), // Allow scenes to be loaded from anywhere on disk unapproved_path_mode: UnapprovedPathMode::Allow, ..default() diff --git a/release-content/migration-guides/registering_asset_sources.md b/release-content/migration-guides/registering_asset_sources.md index c229c68189743..95718584462f7 100644 --- a/release-content/migration-guides/registering_asset_sources.md +++ b/release-content/migration-guides/registering_asset_sources.md @@ -55,11 +55,11 @@ Now, this is written as: App::new() .add_plugins(DefaultPlugins.set( AssetPlugin { - default_source: DefaultAssetSource::FromPaths { - file_path: "some/path".to_string(), - // Note: Setting this to None will just use the default path. - processed_file_path: Some("some/processed_path".to_string()), - }, + default_source: DefaultAssetSource::from_paths( + "some/path".to_string(), + // Note: Setting this to None will just use the default processed path. + Some("some/processed_path".to_string()), + ), ..Default::default() } )); @@ -86,9 +86,9 @@ Now, this is written as: App::new() .add_plugins(DefaultPlugins.set( AssetPlugin { - default_source: DefaultAssetSource::FromBuilder(Mutex::new( + default_source: DefaultAssetSource::from_builder( AssetSourceBuilder::new(move || Box::new(todo!())) - )), + ), ..Default::default() } ));