diff --git a/crates/bevy_asset/src/asset_server.rs b/crates/bevy_asset/src/asset_server.rs index de6108ffab796..08cd8349298f7 100644 --- a/crates/bevy_asset/src/asset_server.rs +++ b/crates/bevy_asset/src/asset_server.rs @@ -82,10 +82,11 @@ pub struct AssetServerInternal { /// ``` /// # use bevy_asset::*; /// # use bevy_app::*; +/// # use bevy_utils::Duration; /// # let mut app = App::new(); /// // The asset plugin can be configured to watch for asset changes. /// app.add_plugin(AssetPlugin { -/// watch_for_changes: true, +/// watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), /// ..Default::default() /// }); /// ``` @@ -702,7 +703,7 @@ mod test { fn setup(asset_path: impl AsRef) -> AssetServer { use crate::FileAssetIo; IoTaskPool::init(Default::default); - AssetServer::new(FileAssetIo::new(asset_path, false)) + AssetServer::new(FileAssetIo::new(asset_path, &None)) } #[test] diff --git a/crates/bevy_asset/src/debug_asset_server.rs b/crates/bevy_asset/src/debug_asset_server.rs index 955fadb80f0ac..93595020ec9a0 100644 --- a/crates/bevy_asset/src/debug_asset_server.rs +++ b/crates/bevy_asset/src/debug_asset_server.rs @@ -5,14 +5,15 @@ use bevy_app::{App, Plugin, Update}; use bevy_ecs::{prelude::*, system::SystemState}; use bevy_tasks::{IoTaskPool, TaskPoolBuilder}; -use bevy_utils::HashMap; +use bevy_utils::{Duration, HashMap}; use std::{ ops::{Deref, DerefMut}, path::Path, }; use crate::{ - Asset, AssetEvent, AssetPlugin, AssetServer, Assets, FileAssetIo, Handle, HandleUntyped, + Asset, AssetEvent, AssetPlugin, AssetServer, Assets, ChangeWatcher, FileAssetIo, Handle, + HandleUntyped, }; /// A helper [`App`] used for hot reloading internal assets, which are compiled-in to Bevy plugins. @@ -72,7 +73,7 @@ impl Plugin for DebugAssetServerPlugin { let mut debug_asset_app = App::new(); debug_asset_app.add_plugin(AssetPlugin { asset_folder: "crates".to_string(), - watch_for_changes: true, + watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), }); app.insert_non_send_resource(DebugAssetApp(debug_asset_app)); app.add_systems(Update, run_debug_asset_app); diff --git a/crates/bevy_asset/src/filesystem_watcher.rs b/crates/bevy_asset/src/filesystem_watcher.rs index e91dc651457a3..fa1b9c173e89b 100644 --- a/crates/bevy_asset/src/filesystem_watcher.rs +++ b/crates/bevy_asset/src/filesystem_watcher.rs @@ -1,8 +1,10 @@ -use bevy_utils::{default, HashMap, HashSet}; +use bevy_utils::{default, Duration, HashMap, HashSet}; use crossbeam_channel::Receiver; use notify::{Event, RecommendedWatcher, RecursiveMode, Result, Watcher}; use std::path::{Path, PathBuf}; +use crate::ChangeWatcher; + /// Watches for changes to files on the local filesystem. /// /// When hot-reloading is enabled, the [`AssetServer`](crate::AssetServer) uses this to reload @@ -11,10 +13,11 @@ pub struct FilesystemWatcher { pub watcher: RecommendedWatcher, pub receiver: Receiver>, pub path_map: HashMap>, + pub delay: Duration, } -impl Default for FilesystemWatcher { - fn default() -> Self { +impl FilesystemWatcher { + pub fn new(configuration: &ChangeWatcher) -> Self { let (sender, receiver) = crossbeam_channel::unbounded(); let watcher: RecommendedWatcher = RecommendedWatcher::new( move |res| { @@ -27,11 +30,10 @@ impl Default for FilesystemWatcher { watcher, receiver, path_map: default(), + delay: configuration.delay, } } -} -impl FilesystemWatcher { /// Watch for changes recursively at the provided path. pub fn watch>(&mut self, to_watch: P, to_reload: PathBuf) -> Result<()> { self.path_map diff --git a/crates/bevy_asset/src/io/android_asset_io.rs b/crates/bevy_asset/src/io/android_asset_io.rs index 4da0b6d5f100e..a5e91bb8be268 100644 --- a/crates/bevy_asset/src/io/android_asset_io.rs +++ b/crates/bevy_asset/src/io/android_asset_io.rs @@ -1,4 +1,4 @@ -use crate::{AssetIo, AssetIoError, Metadata}; +use crate::{AssetIo, AssetIoError, ChangeWatcher, Metadata}; use anyhow::Result; use bevy_utils::BoxedFuture; use std::{ @@ -59,7 +59,7 @@ impl AssetIo for AndroidAssetIo { Ok(()) } - fn watch_for_changes(&self) -> Result<(), AssetIoError> { + fn watch_for_changes(&self, _configuration: &ChangeWatcher) -> Result<(), AssetIoError> { bevy_log::warn!("Watching for changes is not supported on Android"); Ok(()) } diff --git a/crates/bevy_asset/src/io/file_asset_io.rs b/crates/bevy_asset/src/io/file_asset_io.rs index 867b9ed4d0852..74f01725b6280 100644 --- a/crates/bevy_asset/src/io/file_asset_io.rs +++ b/crates/bevy_asset/src/io/file_asset_io.rs @@ -1,12 +1,12 @@ #[cfg(feature = "filesystem_watcher")] use crate::{filesystem_watcher::FilesystemWatcher, AssetServer}; -use crate::{AssetIo, AssetIoError, Metadata}; +use crate::{AssetIo, AssetIoError, ChangeWatcher, Metadata}; use anyhow::Result; #[cfg(feature = "filesystem_watcher")] -use bevy_ecs::system::Res; +use bevy_ecs::system::{Local, Res}; use bevy_utils::BoxedFuture; #[cfg(feature = "filesystem_watcher")] -use bevy_utils::{default, HashSet}; +use bevy_utils::{default, HashMap, Instant}; #[cfg(feature = "filesystem_watcher")] use crossbeam_channel::TryRecvError; use fs::File; @@ -35,13 +35,13 @@ impl FileAssetIo { /// watching for changes. /// /// See `get_base_path` below. - pub fn new>(path: P, watch_for_changes: bool) -> Self { + pub fn new>(path: P, watch_for_changes: &Option) -> Self { let file_asset_io = FileAssetIo { #[cfg(feature = "filesystem_watcher")] filesystem_watcher: default(), root_path: Self::get_base_path().join(path.as_ref()), }; - if watch_for_changes { + if let Some(configuration) = watch_for_changes { #[cfg(any( not(feature = "filesystem_watcher"), target_arch = "wasm32", @@ -52,7 +52,7 @@ impl FileAssetIo { wasm32 / android targets" ); #[cfg(feature = "filesystem_watcher")] - file_asset_io.watch_for_changes().unwrap(); + file_asset_io.watch_for_changes(configuration).unwrap(); } file_asset_io } @@ -143,10 +143,10 @@ impl AssetIo for FileAssetIo { Ok(()) } - fn watch_for_changes(&self) -> Result<(), AssetIoError> { + fn watch_for_changes(&self, configuration: &ChangeWatcher) -> Result<(), AssetIoError> { #[cfg(feature = "filesystem_watcher")] { - *self.filesystem_watcher.write() = Some(default()); + *self.filesystem_watcher.write() = Some(FilesystemWatcher::new(configuration)); } #[cfg(not(feature = "filesystem_watcher"))] bevy_log::warn!("Watching for changes is not supported when the `filesystem_watcher` feature is disabled"); @@ -174,7 +174,10 @@ impl AssetIo for FileAssetIo { feature = "filesystem_watcher", all(not(target_arch = "wasm32"), not(target_os = "android")) ))] -pub fn filesystem_watcher_system(asset_server: Res) { +pub fn filesystem_watcher_system( + asset_server: Res, + mut changed: Local>, +) { let asset_io = if let Some(asset_io) = asset_server.server.asset_io.downcast_ref::() { asset_io @@ -182,14 +185,15 @@ pub fn filesystem_watcher_system(asset_server: Res) { return; }; let watcher = asset_io.filesystem_watcher.read(); + if let Some(ref watcher) = *watcher { - let mut changed = HashSet::<&PathBuf>::default(); loop { let event = match watcher.receiver.try_recv() { Ok(result) => result.unwrap(), Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => panic!("FilesystemWatcher disconnected."), }; + if let notify::event::Event { kind: notify::event::EventKind::Modify(_), paths, @@ -199,13 +203,22 @@ pub fn filesystem_watcher_system(asset_server: Res) { for path in &paths { let Some(set) = watcher.path_map.get(path) else {continue}; for to_reload in set { - if !changed.contains(to_reload) { - changed.insert(to_reload); - let _ = asset_server.load_untracked(to_reload.as_path().into(), true); - } + // When an asset is modified, note down the timestamp (overriding any previous modification events) + changed.insert(to_reload.to_owned(), Instant::now()); } } } } + + // Reload all assets whose last modification was at least 50ms ago. + // + // When changing and then saving a shader, several modification events are sent in short succession. + // Unless we wait until we are sure the shader is finished being modified (and that there will be no more events coming), + // we will sometimes get a crash when trying to reload a partially-modified shader. + for (to_reload, _) in + changed.drain_filter(|_, last_modified| last_modified.elapsed() >= watcher.delay) + { + let _ = asset_server.load_untracked(to_reload.as_path().into(), true); + } } } diff --git a/crates/bevy_asset/src/io/mod.rs b/crates/bevy_asset/src/io/mod.rs index 5a11e887dc2a3..081076f4ed8d1 100644 --- a/crates/bevy_asset/src/io/mod.rs +++ b/crates/bevy_asset/src/io/mod.rs @@ -25,6 +25,8 @@ use std::{ }; use thiserror::Error; +use crate::ChangeWatcher; + /// Errors that occur while loading assets. #[derive(Error, Debug)] pub enum AssetIoError { @@ -81,7 +83,7 @@ pub trait AssetIo: Downcast + Send + Sync + 'static { ) -> Result<(), AssetIoError>; /// Enables change tracking in this asset I/O. - fn watch_for_changes(&self) -> Result<(), AssetIoError>; + fn watch_for_changes(&self, configuration: &ChangeWatcher) -> Result<(), AssetIoError>; /// Returns `true` if the path is a directory. fn is_dir(&self, path: &Path) -> bool { diff --git a/crates/bevy_asset/src/io/wasm_asset_io.rs b/crates/bevy_asset/src/io/wasm_asset_io.rs index ce70fe295ae2b..00afb398ea5e6 100644 --- a/crates/bevy_asset/src/io/wasm_asset_io.rs +++ b/crates/bevy_asset/src/io/wasm_asset_io.rs @@ -1,4 +1,4 @@ -use crate::{AssetIo, AssetIoError, Metadata}; +use crate::{AssetIo, AssetIoError, ChangeWatcher, Metadata}; use anyhow::Result; use bevy_utils::BoxedFuture; use js_sys::Uint8Array; @@ -64,7 +64,7 @@ impl AssetIo for WasmAssetIo { Ok(()) } - fn watch_for_changes(&self) -> Result<(), AssetIoError> { + fn watch_for_changes(&self, _configuration: &ChangeWatcher) -> Result<(), AssetIoError> { bevy_log::warn!("Watching for changes is not supported in WASM"); Ok(()) } diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 7a444cfdc17fb..cc325d26c4dd5 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -49,6 +49,7 @@ pub use reflect::*; use bevy_app::{prelude::*, MainScheduleOrder}; use bevy_ecs::schedule::ScheduleLabel; +use bevy_utils::Duration; /// Asset storages are updated. #[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)] @@ -57,6 +58,30 @@ pub struct LoadAssets; #[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)] pub struct AssetEvents; +/// Configuration for hot reloading assets by watching for changes. +#[derive(Debug, Clone)] +pub struct ChangeWatcher { + /// Minimum delay after which a file change will trigger a reload. + /// + /// The change watcher will wait for this duration after a file change before reloading the + /// asset. This is useful to avoid reloading an asset multiple times when it is changed + /// multiple times in a short period of time, or to avoid reloading an asset that is still + /// being written to. + /// + /// If you have a slow hard drive or expect to reload large assets, you may want to increase + /// this value. + pub delay: Duration, +} + +impl ChangeWatcher { + /// Enable change watching with the given delay when a file is changed. + /// + /// See [`Self::delay`] for more details on how this value is used. + pub fn with_delay(delay: Duration) -> Option { + Some(Self { delay }) + } +} + /// Adds support for [`Assets`] to an App. /// /// Assets are typed collections with change tracking, which are added as App Resources. Examples of @@ -67,14 +92,14 @@ pub struct AssetPlugin { pub asset_folder: String, /// Whether to watch for changes in asset files. Requires the `filesystem_watcher` feature, /// and cannot be supported on the wasm32 arch nor android os. - pub watch_for_changes: bool, + pub watch_for_changes: Option, } impl Default for AssetPlugin { fn default() -> Self { Self { asset_folder: "assets".to_string(), - watch_for_changes: false, + watch_for_changes: None, } } } @@ -86,7 +111,7 @@ impl AssetPlugin { /// delegate to the default `AssetIo` for the platform. pub fn create_platform_default_asset_io(&self) -> Box { #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))] - let source = FileAssetIo::new(&self.asset_folder, self.watch_for_changes); + let source = FileAssetIo::new(&self.asset_folder, &self.watch_for_changes); #[cfg(target_arch = "wasm32")] let source = WasmAssetIo::new(&self.asset_folder); #[cfg(target_os = "android")] diff --git a/examples/asset/custom_asset_io.rs b/examples/asset/custom_asset_io.rs index 3a525e7dd282c..df73709633e60 100644 --- a/examples/asset/custom_asset_io.rs +++ b/examples/asset/custom_asset_io.rs @@ -3,7 +3,7 @@ //! It does not know anything about the asset formats, only how to talk to the underlying storage. use bevy::{ - asset::{AssetIo, AssetIoError, Metadata}, + asset::{AssetIo, AssetIoError, ChangeWatcher, Metadata}, prelude::*, utils::BoxedFuture, }; @@ -39,9 +39,9 @@ impl AssetIo for CustomAssetIo { self.0.watch_path_for_changes(to_watch, to_reload) } - fn watch_for_changes(&self) -> Result<(), AssetIoError> { + fn watch_for_changes(&self, configuration: &ChangeWatcher) -> Result<(), AssetIoError> { info!("watch_for_changes()"); - self.0.watch_for_changes() + self.0.watch_for_changes(configuration) } fn get_metadata(&self, path: &Path) -> Result { diff --git a/examples/asset/hot_asset_reloading.rs b/examples/asset/hot_asset_reloading.rs index 971fca33c3a62..8c31d8d94d0c6 100644 --- a/examples/asset/hot_asset_reloading.rs +++ b/examples/asset/hot_asset_reloading.rs @@ -2,13 +2,13 @@ //! running. This lets you immediately see the results of your changes without restarting the game. //! This example illustrates hot reloading mesh changes. -use bevy::prelude::*; +use bevy::{asset::ChangeWatcher, prelude::*, utils::Duration}; fn main() { App::new() .add_plugins(DefaultPlugins.set(AssetPlugin { // Tell the asset server to watch for asset changes on disk: - watch_for_changes: true, + watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), ..default() })) .add_systems(Startup, setup) diff --git a/examples/scene/scene.rs b/examples/scene/scene.rs index 537d669e7b921..ad4dd8d3efea2 100644 --- a/examples/scene/scene.rs +++ b/examples/scene/scene.rs @@ -1,5 +1,5 @@ //! This example illustrates loading scenes from files. -use bevy::{prelude::*, tasks::IoTaskPool, utils::Duration}; +use bevy::{asset::ChangeWatcher, prelude::*, tasks::IoTaskPool, utils::Duration}; use std::{fs::File, io::Write}; fn main() { @@ -7,7 +7,7 @@ fn main() { .add_plugins(DefaultPlugins.set(AssetPlugin { // This tells the AssetServer to watch for changes to assets. // It enables our scenes to automatically reload in game when we modify their files. - watch_for_changes: true, + watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), ..default() })) .register_type::() diff --git a/examples/shader/post_processing.rs b/examples/shader/post_processing.rs index 54f45e646bde6..b087879c0621f 100644 --- a/examples/shader/post_processing.rs +++ b/examples/shader/post_processing.rs @@ -6,6 +6,7 @@ //! This is a fairly low level example and assumes some familiarity with rendering concepts and wgpu. use bevy::{ + asset::ChangeWatcher, core_pipeline::{ clear_color::ClearColorConfig, core_3d, fullscreen_vertex_shader::fullscreen_shader_vertex_state, @@ -29,13 +30,14 @@ use bevy::{ view::{ExtractedView, ViewTarget}, RenderApp, }, + utils::Duration, }; fn main() { App::new() .add_plugins(DefaultPlugins.set(AssetPlugin { // Hot reloading the shader works correctly - watch_for_changes: true, + watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), ..default() })) .add_plugin(PostProcessPlugin) diff --git a/examples/tools/scene_viewer/main.rs b/examples/tools/scene_viewer/main.rs index a1519fbcda2e1..1295d7d809d3c 100644 --- a/examples/tools/scene_viewer/main.rs +++ b/examples/tools/scene_viewer/main.rs @@ -6,9 +6,11 @@ //! With no arguments it will load the `FlightHelmet` glTF model from the repository assets subdirectory. use bevy::{ + asset::ChangeWatcher, math::Vec3A, prelude::*, render::primitives::{Aabb, Sphere}, + utils::Duration, window::WindowPlugin, }; @@ -36,7 +38,7 @@ fn main() { .set(AssetPlugin { asset_folder: std::env::var("CARGO_MANIFEST_DIR") .unwrap_or_else(|_| ".".to_string()), - watch_for_changes: true, + watch_for_changes: ChangeWatcher::with_delay(Duration::from_millis(200)), }), ) .add_plugin(CameraControllerPlugin)