diff --git a/Cargo.lock b/Cargo.lock index fa20dbf..d6fbe79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,6 +1171,18 @@ dependencies = [ "toml_edit 0.23.7", ] +[[package]] +name = "bevy_malek_async" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48b3542ea649e1fdf7800a006b8970fc7ce2fbfdc663610aa6f9f91399cd08be" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "crossbeam", +] + [[package]] name = "bevy_materialize" version = "0.8.0" @@ -1506,6 +1518,7 @@ dependencies = [ "bevy_ecs", "bevy_gizmos", "bevy_light", + "bevy_malek_async", "bevy_math", "bevy_mesh", "bevy_pbr", @@ -1530,6 +1543,7 @@ version = "0.3.0" dependencies = [ "anyhow", "bevy", + "bevy_malek_async", "bevy_rerecast", "bevy_ui_text_input", "bincode", @@ -2460,6 +2474,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index ce62cb2..8a92374 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ slotmap = { version = "1.0.7", default-features = false } ehttp = { version = "0.5", default-features = false } rfd = "0.15" +bevy_malek_async = { version = "0.1.1" } + # In sync with Bevy thiserror = { version = "2.0.12", default-features = false } wgpu-types = { version = "26", default-features = false } diff --git a/crates/bevy_rerecast_core/Cargo.toml b/crates/bevy_rerecast_core/Cargo.toml index 00cb28c..ee496dd 100644 --- a/crates/bevy_rerecast_core/Cargo.toml +++ b/crates/bevy_rerecast_core/Cargo.toml @@ -22,6 +22,7 @@ bevy_app = { workspace = true } bevy_utils = { workspace = true } bevy_math = { workspace = true, features = ["bevy_reflect", "serialize"] } bevy_platform = { workspace = true } +bevy_malek_async = { workspace = true} # debug-plugin bevy_gizmos = { workspace = true, optional = true, features = ["bevy_render"] } diff --git a/crates/bevy_rerecast_editor/Cargo.toml b/crates/bevy_rerecast_editor/Cargo.toml index 15ee250..38e7f43 100644 --- a/crates/bevy_rerecast_editor/Cargo.toml +++ b/crates/bevy_rerecast_editor/Cargo.toml @@ -25,6 +25,7 @@ thiserror = { workspace = true } bevy_ui_text_input = { workspace = true } rfd = { workspace = true } cosmic-text = { workspace = true } +bevy_malek_async = { workspace = true } [lints] workspace = true diff --git a/crates/bevy_rerecast_editor/src/get_navmesh_input.rs b/crates/bevy_rerecast_editor/src/get_navmesh_input.rs index a12d48f..55cca0b 100644 --- a/crates/bevy_rerecast_editor/src/get_navmesh_input.rs +++ b/crates/bevy_rerecast_editor/src/get_navmesh_input.rs @@ -1,11 +1,12 @@ -use anyhow::anyhow; +use anyhow::{Result, anyhow}; use bevy::{ asset::RenderAssetUsages, + ecs::world::WorldId, mesh::{Indices, PrimitiveTopology}, platform::collections::HashMap, prelude::*, remote::BrpRequest, - tasks::{AsyncComputeTaskPool, IoTaskPool, Task, futures_lite::future}, + tasks::{IoTaskPool, Task}, }; use bevy_rerecast::editor_integration::{ brp::{ @@ -21,241 +22,221 @@ use crate::{ ui::ConnectionInput, visualization::{ObstacleGizmo, VisualMesh}, }; +use bevy_malek_async::{WorldIdRes, async_access}; pub(super) fn plugin(app: &mut App) { - app.add_observer(generate_navmesh_input); - app.add_systems( - Update, - // the `run_if` needs to be on both systems because the resource is allowed to stop existing in-between them. - ( - poll_remote_navmesh_input.run_if(resource_exists::), - poll_navmesh_input.run_if(resource_exists::), - ) - .chain(), - ); + app.add_observer(on_get_navmesh_input); } #[derive(Event)] pub(crate) struct GetNavmeshInput; -#[derive(Resource)] -enum GetNavmeshInputRequestTask { - Generate(Task>), - Poll(Task>), -} - -fn generate_navmesh_input( +fn on_get_navmesh_input( _: On, - mut commands: Commands, - settings: Res, - connection_input: Single<&TextInputContents, With>, - maybe_task: Option>, + mut task: Local>>, + world_id: Res, ) { - if maybe_task.is_some() { - // There's already an ongoing task, so we'll wait for it to complete. - return; + let world_id = world_id.0.clone(); + if task.as_ref().is_some_and(|task| task.is_finished()) { + task.take(); } - let settings = settings.0.clone(); - let url = connection_input.get().to_string(); - let future = async move { - let params = GenerateEditorInputParams { - backend_input: settings, - }; - let json = serde_json::to_value(params)?; + match task.as_ref() { + None => { + task.replace(IoTaskPool::get().spawn(async move { + if let Err(e) = navmesh_pipeline(world_id).await { + error!("navmesh pipeline failed: {e:?}"); + } + })); + } + Some(_) => { + error!("a navmesh task is already running"); + } + } +} + +async fn navmesh_pipeline(world_id: WorldId) -> Result<()> { + let (settings, url): (serde_json::Value, String) = + async_access::< + ( + Res, + Single<&TextInputContents, With>, + ), + _, + _, + >(world_id, |(settings, connection_input)| { + Ok::<_, anyhow::Error>(( + serde_json::to_value(GenerateEditorInputParams { + backend_input: settings.0.clone(), + })?, + connection_input.get().to_string(), + )) + }) + .await?; + + let generate_id = { let req = BrpRequest { - jsonrpc: String::from("2.0"), - method: String::from(BRP_GENERATE_EDITOR_INPUT), + jsonrpc: "2.0".into(), + method: BRP_GENERATE_EDITOR_INPUT.into(), id: None, - params: Some(json), + params: Some(settings), }; - let request = ehttp::Request::json(url, &req)?; - let resp = ehttp::fetch_async(request) + let resp = ehttp::fetch_async(ehttp::Request::json(url, &req)?) .await .map_err(|s| anyhow!("{s}"))?; let mut v: serde_json::Value = resp.json()?; - - let Some(val) = v.get_mut("result") else { - let Some(error) = v.get("error") else { - return Err(anyhow!( - "BRP error: Response returned neither 'result' nor 'error' field" - )); - }; - return Err(anyhow!("BRP error: {error}")); - }; - let val = val.take(); - - // Decode manually - let response: GenerateEditorInputResponse = serde_json::from_value(val)?; - Ok(response) + let val = v.get_mut("result").map(|r| r.take()).ok_or_else(|| { + anyhow!( + "BRP error: {}", + v.get("error").unwrap_or(&serde_json::Value::Null) + ) + })?; + let GenerateEditorInputResponse { id, .. } = serde_json::from_value(val)?; + id }; - let task = IoTaskPool::get().spawn(future); - commands.insert_resource(GetNavmeshInputRequestTask::Generate(task)); -} - -fn poll_remote_navmesh_input( - mut commands: Commands, - mut task: ResMut, -) -> Result { - let GetNavmeshInputRequestTask::Generate(task) = task.as_mut() else { - return Ok(()); - }; - let Some(result) = future::block_on(future::poll_once(task)) else { - return Ok(()); - }; - let response = result.inspect_err(|_e| { - commands.remove_resource::(); - })?; - let future = async { - // Create the URL. We're going to need it to issue the HTTP request. - let host_part = format!("{}:{}", "127.0.0.1", 15702); - let url = format!("http://{host_part}/"); - let params = PollEditorInputParams { id: response.id }; - let json = serde_json::to_value(params)?; + let response: PollEditorInputResponse = { + let params = serde_json::to_value(PollEditorInputParams { id: generate_id })?; let req = BrpRequest { - jsonrpc: String::from("2.0"), - method: String::from(BRP_POLL_EDITOR_INPUT), + jsonrpc: "2.0".into(), + method: BRP_POLL_EDITOR_INPUT.into(), id: None, - params: Some(json), + params: Some(params), }; - let request = ehttp::Request::json(url, &req)?; - let resp = ehttp::fetch_async(request) + let resp = ehttp::fetch_async(ehttp::Request::json("http://127.0.0.1:15702/", &req)?) .await .map_err(|s| anyhow!("{s}"))?; let mut v: serde_json::Value = resp.json()?; - - let Some(val) = v.get_mut("result") else { - let Some(error) = v.get("error") else { - return Err(anyhow!( - "BRP error: Response returned neither 'result' nor 'error' field" - )); - }; - return Err(anyhow!("BRP error: {error}")); - }; - let val = val.take(); - - // Decode manually - let response: PollEditorInputResponse = deserialize(&val)?; - Ok(response) + let val = v.get_mut("result").map(|r| r.take()).ok_or_else(|| { + anyhow!( + "BRP error: {}", + v.get("error").unwrap_or(&serde_json::Value::Null) + ) + })?; + deserialize(&val)? }; - let task = AsyncComputeTaskPool::get().spawn(future); - commands.insert_resource(GetNavmeshInputRequestTask::Poll(task)); - Ok(()) -} - -fn poll_navmesh_input( - mut task: ResMut, - mut commands: Commands, - mut meshes: ResMut>, - mut materials: ResMut>, - mut images: ResMut>, - mesh_handles: Query, With)>, - gizmo_handles: Query<&Gizmo>, - mut gizmos: ResMut>, - mut navmesh_handle: ResMut, -) -> Result { - let GetNavmeshInputRequestTask::Poll(task) = task.as_mut() else { - return Ok(()); - }; - let Some(result) = future::block_on(future::poll_once(task)) else { - return Ok(()); - }; - commands.remove_resource::(); - let response = result?; + async_access::< + ( + Commands, + ResMut>, + ResMut>, + ResMut>, + Query, With)>, + Query<&Gizmo>, + ResMut>, + ResMut, + ), + _, + _, + >( + world_id, + move |( + mut commands, + mut meshes, + mut materials, + mut images, + mesh_handles, + gizmo_handles, + mut gizmos, + mut navmesh_handle, + )| { + // Clear existing scene bits. + for e in mesh_handles.iter() { + commands.entity(e).despawn(); + } + for gizmo in gizmo_handles.iter() { + if let Some(g) = gizmos.get_mut(&gizmo.handle) { + g.clear(); + } + } - for entity in mesh_handles.iter() { - commands.entity(entity).despawn(); - } - for gizmo in gizmo_handles.iter() { - let Some(gizmo) = gizmos.get_mut(&gizmo.handle) else { - continue; - }; - gizmo.clear(); - } + // Obstacles preview mesh. + let mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all()) + .with_inserted_attribute( + Mesh::ATTRIBUTE_POSITION, + response.obstacles.vertices.clone(), + ) + .with_inserted_indices(Indices::U32( + response + .obstacles + .indices + .iter() + .flat_map(|tri| tri.to_array()) + .collect(), + )) + .with_computed_normals(); + + commands.spawn(( + Transform::default(), + Mesh3d(meshes.add(mesh)), + Visibility::Hidden, + ObstacleGizmo, + Gizmo { + handle: gizmos.add(GizmoAsset::new()), + line_config: GizmoLineConfig { + perspective: true, + width: 15.0, + joints: GizmoLineJoint::Bevel, + ..default() + }, + depth_bias: -0.005, + }, + )); + commands.insert_resource(NavmeshObstacles(response.obstacles.clone())); + + // Visual meshes + materials (with per-index caches). + let mut image_indices: HashMap> = HashMap::default(); + let mut material_indices: HashMap> = HashMap::default(); + let mut mesh_indices: HashMap> = HashMap::default(); + let fallback_material = materials.add(Color::WHITE); + + for visual in response.visual_meshes { + let mesh_handle = mesh_indices + .entry(visual.mesh) + .or_insert_with(|| { + let mut m = response.meshes[visual.mesh as usize].clone().into_mesh(); + // Avoid skinned attributes without SkinnedMesh + m.remove_attribute(Mesh::ATTRIBUTE_JOINT_INDEX); + m.remove_attribute(Mesh::ATTRIBUTE_JOINT_WEIGHT); + meshes.add(m) + }) + .clone(); + + let material_handle = if let Some(idx) = visual.material { + material_indices + .entry(idx) + .or_insert_with(|| { + let mat = response.materials[idx as usize] + .clone() + .into_standard_material( + &mut image_indices, + &mut images, + &response.images, + ); + materials.add(mat) + }) + .clone() + } else { + fallback_material.clone() + }; + + commands.spawn(( + visual.transform.compute_transform(), + Mesh3d(mesh_handle), + MeshMaterial3d(material_handle), + VisualMesh, + )); + } - let mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all()) - .with_inserted_attribute( - Mesh::ATTRIBUTE_POSITION, - response.obstacles.vertices.clone(), - ) - .with_inserted_indices(Indices::U32( - response - .obstacles - .indices - .iter() - .flat_map(|indices| indices.to_array()) - .collect(), - )) - .with_computed_normals(); + // Clear previous navmesh handle + navmesh_handle.0 = Default::default(); - commands.spawn(( - Transform::default(), - Mesh3d(meshes.add(mesh)), - Visibility::Hidden, - ObstacleGizmo, - Gizmo { - handle: gizmos.add(GizmoAsset::new()), - line_config: GizmoLineConfig { - perspective: true, - width: 15.0, - joints: GizmoLineJoint::Bevel, - ..default() - }, - depth_bias: -0.005, + Ok::<_, anyhow::Error>(()) }, - )); - commands.insert_resource(NavmeshObstacles(response.obstacles)); - - let mut image_indices: HashMap> = HashMap::new(); - let mut material_indices: HashMap> = HashMap::new(); - let mut mesh_indices: HashMap> = HashMap::new(); - let fallback_material = materials.add(Color::WHITE); - - for visual in response.visual_meshes { - let mesh = if let Some(mesh_handle) = mesh_indices.get(&visual.mesh) { - mesh_handle.clone() - } else { - let serialized_mesh = response.meshes[visual.mesh as usize].clone(); - let mut mesh = serialized_mesh.into_mesh(); - // Need to exclude these as we don't replicate `SkinnedMesh`, but having joint attributes without a `SkinnedMesh` crashes Bevy. - // See https://github.com/bevyengine/bevy/issues/16929 - mesh.remove_attribute(Mesh::ATTRIBUTE_JOINT_INDEX); - mesh.remove_attribute(Mesh::ATTRIBUTE_JOINT_WEIGHT); - let handle = meshes.add(mesh); - mesh_indices.insert(visual.mesh, handle.clone()); - handle - }; - - let material = if let Some(index) = visual.material { - if let Some(material_handle) = material_indices.get(&index) { - material_handle.clone() - } else { - let serialized_material = response.materials[index as usize].clone(); - let material = serialized_material.into_standard_material( - &mut image_indices, - &mut images, - &response.images, - ); - let handle = materials.add(material.clone()); - material_indices.insert(index, handle.clone()); - handle - } - } else { - fallback_material.clone() - }; - - commands.spawn(( - visual.transform.compute_transform(), - Mesh3d(mesh), - MeshMaterial3d(material), - VisualMesh, - )); - } - // Clear previous navmesh - navmesh_handle.0 = Default::default(); + ) + .await?; Ok(()) } diff --git a/crates/bevy_rerecast_editor/src/main.rs b/crates/bevy_rerecast_editor/src/main.rs index 83361f5..0ceec6b 100644 --- a/crates/bevy_rerecast_editor/src/main.rs +++ b/crates/bevy_rerecast_editor/src/main.rs @@ -6,6 +6,7 @@ use bevy::{ input_focus::{InputDispatchPlugin, tab_navigation::TabNavigationPlugin}, prelude::*, }; +use bevy_malek_async::AsyncPlugin; use bevy_rerecast::prelude::*; use bevy_ui_text_input::TextInputPlugin; @@ -39,7 +40,7 @@ fn main() -> AppExit { .disable::(), )) .insert_resource(UiTheme(create_dark_theme())) - .add_plugins((NavmeshPlugins::default(), TextInputPlugin)) + .add_plugins((NavmeshPlugins::default(), TextInputPlugin, AsyncPlugin)) .add_plugins(( camera::plugin, get_navmesh_input::plugin, @@ -47,7 +48,6 @@ fn main() -> AppExit { theme::plugin, visualization::plugin, backend::plugin, - save::plugin, load::plugin, )) .run() diff --git a/crates/bevy_rerecast_editor/src/save.rs b/crates/bevy_rerecast_editor/src/save.rs index 10facb8..3084957 100644 --- a/crates/bevy_rerecast_editor/src/save.rs +++ b/crates/bevy_rerecast_editor/src/save.rs @@ -1,85 +1,45 @@ use std::{fs::File, io}; -use bevy::{ - prelude::*, - tasks::{AsyncComputeTaskPool, Task, futures_lite::future}, -}; +use crate::backend::NavmeshHandle; +use bevy::ecs::world::WorldId; +use bevy::{prelude::*}; +use bevy_malek_async::async_access; use bevy_rerecast::Navmesh; use rfd::FileHandle; use thiserror::Error; -use crate::backend::NavmeshHandle; - -pub(super) fn plugin(app: &mut App) { - app.init_resource::(); - app.add_systems( - Update, - ( - poll_save_task.run_if(resource_exists::), - poll_write_tasks, - ) - .chain(), - ); -} - -#[derive(Resource, Deref, DerefMut)] -pub(crate) struct SaveTask(pub(crate) Task>); - -fn poll_save_task( - mut commands: Commands, - mut task: ResMut, - navmesh: Res, - navmeshes: Res>, - mut write_tasks: ResMut, -) { - let Some(file_handle) = future::block_on(future::poll_once(&mut task.0)) else { - return; - }; - commands.remove_resource::(); - let Some(file) = file_handle else { - // User canceled the save operation - return; - }; - - let Some(navmesh) = navmeshes.get(navmesh.id()) else { - // There's no navmesh to save - return; - }; - let thread_pool = AsyncComputeTaskPool::get(); - - let navmesh = navmesh.clone(); - let future = async move { - let path = file.path(); - let mut file = File::create(path)?; - let config = bincode::config::standard(); - bincode::serde::encode_into_std_write(navmesh, &mut file, config)?; - Ok(()) +pub(crate) async fn save_navmesh( + world_id: WorldId, + save: impl Future>, +) -> core::result::Result<(), SaveError> { + let Some(file_handle) = save.await else { + return Err(SaveError::UserCanceled); }; - write_tasks.push(thread_pool.spawn(future)); + let navmesh = async_access::<(Res, Res>), _, _>( + world_id, + |(navmesh, navmeshes)| { + navmeshes + .get(navmesh.id()) + .ok_or(SaveError::NoNavmesh) + .cloned() + }, + ) + .await?; + let path = file_handle.path(); + let mut file = File::create(path)?; + let config = bincode::config::standard(); + bincode::serde::encode_into_std_write(navmesh, &mut file, config)?; + Ok(()) } #[derive(Debug, Error)] pub enum SaveError { + #[error("User canceled the save operation")] + UserCanceled, + #[error("There's no navmesh to save")] + NoNavmesh, #[error("Failed to create file: {0}")] CreateFile(#[from] io::Error), #[error("Failed to encode navmesh: {0}")] WriteNavmesh(#[from] bincode::error::EncodeError), } - -#[derive(Resource, Default, Deref, DerefMut)] -struct WriteTasks(Vec>>); - -fn poll_write_tasks(mut write_tasks: ResMut) { - write_tasks.retain_mut(|task| { - let Some(result) = future::block_on(future::poll_once(task)) else { - return true; - }; - match result { - Ok(()) => false, - Err(err) => { - error!("Failed to save navmesh: {}", err); - false - } - } - }); -} diff --git a/crates/bevy_rerecast_editor/src/ui.rs b/crates/bevy_rerecast_editor/src/ui.rs index 4734d45..06b79aa 100644 --- a/crates/bevy_rerecast_editor/src/ui.rs +++ b/crates/bevy_rerecast_editor/src/ui.rs @@ -1,3 +1,4 @@ +use bevy::tasks::Task; use bevy::{ ecs::{ prelude::*, @@ -19,6 +20,7 @@ use bevy::{ ui_widgets::{Activate, ValueChange, observe}, window::{PrimaryWindow, RawHandleWrapper}, }; +use bevy_malek_async::WorldIdRes; use bevy_rerecast::prelude::*; use bevy_ui_text_input::{ TextInputContents, TextInputFilter, TextInputMode, TextInputNode, TextInputQueue, @@ -31,7 +33,7 @@ use crate::{ backend::{BuildNavmesh, GlobalNavmeshSettings}, get_navmesh_input::GetNavmeshInput, load::LoadTask, - save::SaveTask, + save, visualization::{AvailableGizmos, GizmosToDraw, ObstacleGizmo}, }; @@ -320,19 +322,22 @@ fn read_config_inputs( fn save_navmesh( _: On, - mut commands: Commands, - maybe_task: Option>, + world_id: Res, + mut task: Local>>, window_handle: Single<&RawHandleWrapper, With>, ) { - if maybe_task.is_some() { + let world_id = world_id.0.clone(); + if task.as_ref().is_some_and(|task| task.is_finished()) { // Already saving, do nothing + task.take(); + } + if let Some(_) = task.as_ref() { + info!("a navmesh save task is already running"); return; } - // Safety: we're on the main thread, so this is fine??? I think?? let window_handle = unsafe { window_handle.get_handle() }; - let thread_pool = AsyncComputeTaskPool::get(); - let future = AsyncFileDialog::new() + let save_file_dialog = AsyncFileDialog::new() .add_filter("Navmesh", &["nav"]) .add_filter("All files", &["*"]) .set_title("Save Navmesh") @@ -340,8 +345,11 @@ fn save_navmesh( .set_parent(&window_handle) .set_can_create_directories(true) .save_file(); - let task = thread_pool.spawn(future); - commands.insert_resource(SaveTask(task)); + task.replace(AsyncComputeTaskPool::get().spawn(async move { + if let Err(e) = save::save_navmesh(world_id, save_file_dialog).await { + error!("navmesh save failed: {e:?}"); + } + })); } fn load_navmesh(