diff --git a/Cargo.toml b/Cargo.toml index 16835b9..7f20fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ std = [ "bevy_color?/std", "bevy_input?/std", "bevy_time?/std", + "dep:async-channel", ] libm = ["bevy_math/libm", "dep:libm"] @@ -66,9 +67,34 @@ bevy_input = { version = "0.18.0", default-features = false, optional = true, fe ] } bevy_time = { version = "0.18.0", default-features = false, optional = true } libm = { version = "0.2", default-features = false, optional = true } +async-channel = { version = "2.5", optional = true } [dev-dependencies] -bevy = "0.18.0" +bevy = { version = "0.18.0", default-features = false, features = [ + "bevy_animation", + "bevy_asset", + "bevy_color", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_gltf", + "bevy_log", + "bevy_pbr", + "bevy_post_process", + "bevy_render", + "bevy_scene", + "bevy_remote", + "bevy_ui", + "bevy_ui_render", + "bevy_window", + "bevy_winit", + "default_font", + "multi_threaded", + "png", + "reflect_auto_register", + "tonemapping_luts", + "x11", + "zstd_rust" +] } noise = "0.9" turborand = "0.10" criterion = "0.8" diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index fa1c19e..554bf14 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -234,7 +234,7 @@ fn spatial_hashing(c: &mut Criterion) { .single(app.world()) .unwrap(); let spatial_map = app.world().resource::(); - let hash = CellId::__new_manual(parent, &CellCoord { x: 0, y: 0, z: 0 }); + let hash = CellId::new_manual(parent, &CellCoord { x: 0, y: 0, z: 0 }); let entry = spatial_map.get(&hash).unwrap(); assert_eq!(spatial_map.nearby(entry).count(), 27); @@ -263,7 +263,7 @@ fn spatial_hashing(c: &mut Criterion) { .single(app.world()) .unwrap(); let spatial_map = app.world().resource::(); - let hash = CellId::__new_manual(parent, &CellCoord { x: 0, y: 0, z: 0 }); + let hash = CellId::new_manual(parent, &CellCoord { x: 0, y: 0, z: 0 }); let entry = spatial_map.get(&hash).unwrap(); assert_eq!(spatial_map.nearby(entry).count(), 27); diff --git a/examples/spatial_hash.rs b/examples/spatial_hash.rs index 2e43688..c5a4b6b 100644 --- a/examples/spatial_hash.rs +++ b/examples/spatial_hash.rs @@ -1,4 +1,7 @@ //! Demonstrates the included optional spatial hashing and partitioning of grid cells. +//! +//! Spawns multiple independent grids to stress-test worlds with many smaller grids +//! rather than one mega grid. use bevy::{ core_pipeline::tonemapping::Tonemapping, post_process::bloom::Bloom, prelude::*, @@ -6,39 +9,109 @@ use bevy::{ }; use bevy_ecs::entity::EntityHasher; use bevy_math::DVec3; +use bevy_tasks::{available_parallelism, ComputeTaskPool, TaskPoolBuilder}; use big_space::prelude::*; use core::hash::Hasher; use noise::{NoiseFn, Simplex}; use turborand::prelude::*; -// Try bumping this up to stress-test. I'm able to push a million entities with an M3 Max. -const HALF_WIDTH: f32 = 50.0; -const CELL_WIDTH: f32 = 10.0; -// How fast the entities should move, causing them to move into neighboring cells. +/// How fast the non-stationary entities oscillate, causing them to move into neighboring cells. const MOVEMENT_SPEED: f32 = 5.0; -const PERCENT_STATIC: f32 = 0.9; + +/// Grid configurations to spawn on startup. Each entry produces an independent sub-grid +/// positioned so they don't overlap. +const GRIDS: &[GridConfig] = &[ + GridConfig { + seed: 111, + half_width: 15.0, + cell_width: 15.0, + percent_static: 0.999, + has_player: true, + initial_entities: 1_000, + }, + GridConfig { + seed: 222, + half_width: 15.0, + cell_width: 15.0, + percent_static: 1.0, + has_player: false, + initial_entities: 1_000, + }, + GridConfig { + seed: 333, + half_width: 15.0, + cell_width: 15.0, + percent_static: 0.999, + has_player: false, + initial_entities: 1_000, + }, + GridConfig { + seed: 444, + half_width: 15.0, + cell_width: 15.0, + percent_static: 1.0, + has_player: false, + initial_entities: 1_000, + }, +]; + +/// Configuration for a single stress-test grid. +/// +/// Stored as a component on the grid entity so systems can look up per-grid settings. +#[derive(Component, Clone, Debug)] +struct GridConfig { + /// RNG seed for reproducible entity placement. + seed: u64, + /// Half the number of cells across each dimension. Controls the spread of spawned entities. + half_width: f32, + /// Edge length of each cell in this grid. + cell_width: f32, + /// Fraction of entities that are [`Stationary`] (0.0 to 1.0). + percent_static: f32, + /// Whether this grid contains the roaming [`Player`] entity used for neighbor highlighting. + has_player: bool, + /// Number of entities to spawn on startup. + initial_entities: usize, +} + +impl GridConfig { + /// World-space extent of this grid along one axis. + fn extent(&self) -> f32 { + self.half_width * self.cell_width * 2.0 + } +} fn main() { - App::new() - .add_plugins(( - DefaultPlugins.build().disable::(), - BigSpaceDefaultPlugins, - CellHashingPlugin::default(), - PartitionPlugin::default(), - PartitionChangePlugin::default(), - )) - .add_systems(Startup, (spawn, setup_ui)) - .add_systems( - PostUpdate, - ( - move_player.after(TransformSystems::Propagate), - draw_partitions.after(SpatialHashSystems::UpdatePartitionLookup), - highlight_changed_entities.after(draw_partitions), - ), - ) - .add_systems(Update, (cursor_grab, spawn_spheres)) - .init_resource::() - .run(); + ComputeTaskPool::get_or_init(|| { + TaskPoolBuilder::new() + .num_threads(available_parallelism()) + .build() + }); + + let mut app = App::new(); + app.add_plugins(( + DefaultPlugins.build().disable::(), + BigSpaceDefaultPlugins, + CellHashingPlugin::default(), + PartitionPlugin::default(), + PartitionChangePlugin::default(), + )) + .add_systems(Startup, (spawn, setup_ui)) + .add_systems( + PostUpdate, + ( + move_player + .after(TransformSystems::Propagate) + .after(SpatialHashSystems::UpdateCellLookup), + draw_grid_axes.after(TransformSystems::Propagate), + draw_partitions.after(SpatialHashSystems::UpdatePartitionLookup), + highlight_changed_entities.after(draw_partitions), + ), + ) + .add_systems(Update, (cursor_grab, spawn_spheres)) + .init_resource::(); + + app.run(); } #[derive(Component)] @@ -50,14 +123,20 @@ struct NonPlayer; #[derive(Resource)] struct MaterialPresets { default: Handle, + #[allow(dead_code)] highlight: Handle, + #[allow(dead_code)] flood: Handle, changed: Handle, + #[allow(dead_code)] sphere: Handle, } impl FromWorld for MaterialPresets { fn from_world(world: &mut World) -> Self { + // Use the first grid's half_width for sphere size, or a reasonable default. + let half_width = GRIDS.first().map_or(50.0, |g| g.half_width); + let mut materials = world.resource_mut::>(); let default = materials.add(StandardMaterial { @@ -72,7 +151,7 @@ impl FromWorld for MaterialPresets { let mut meshes = world.resource_mut::>(); let sphere = meshes.add( - Sphere::new(HALF_WIDTH / 1_000_000_f32.powf(0.33) * 0.5) + Sphere::new(half_width / 1_000_000_f32.powf(0.33) * 0.5) .mesh() .ico(0) .unwrap(), @@ -88,6 +167,16 @@ impl FromWorld for MaterialPresets { } } +fn draw_grid_axes(mut gizmos: Gizmos, grids: Query<(&GlobalTransform, &Grid), With>) { + for (gt, grid) in grids.iter() { + let origin = gt.translation(); + let len = grid.cell_edge_length() * 2.0; + gizmos.ray(origin, gt.right() * len, Color::linear_rgb(1.0, 0.0, 0.0)); + gizmos.ray(origin, gt.up() * len, Color::linear_rgb(0.0, 1.0, 0.0)); + gizmos.ray(origin, gt.back() * len, Color::linear_rgb(0.0, 0.0, 1.0)); + } +} + fn draw_partitions( mut gizmos: Gizmos, partitions: Res, @@ -143,18 +232,19 @@ fn move_player( mut player: Query<(&mut Transform, &mut CellCoord, &ChildOf, &CellId), With>, mut non_player: Query< (&mut Transform, &mut CellCoord, &ChildOf), - (Without, With), + (Without, With, Without), >, + count: Query<(), With>, mut materials: Query<&mut MeshMaterial3d, Without>, mut neighbors: Local>, - grids: Query<&Grid>, - hash_grid: Res, + grids: Query<(&Grid, Option<&GridConfig>)>, + _hash_grid: Res, material_presets: Res, mut text: Query<&mut Text>, hash_stats: Res>, prop_stats: Res>, ) -> Result { - let n_entities = non_player.iter().len(); + let n_entities = count.count(); for neighbor in neighbors.iter() { if let Ok(mut material) = materials.get_mut(*neighbor) { material.set_if_neq(material_presets.default.clone().into()); @@ -162,58 +252,63 @@ fn move_player( } let t = time.elapsed_secs() * 1.0; - let scale = MOVEMENT_SPEED / HALF_WIDTH; + let scale = MOVEMENT_SPEED; if scale.abs() > 0.0 { - // Avoid change detection - for (i, (mut transform, _, _)) in non_player.iter_mut().enumerate() { - if i < ((1.0 - PERCENT_STATIC) * n_entities as f32) as usize { - transform.translation.x += t.sin() * scale; - transform.translation.y += t.cos() * scale; - transform.translation.z += (t * 2.3).sin() * scale; - } else { - break; - } + for (mut transform, _, parent) in non_player.iter_mut() { + // Scale movement relative to the parent grid's half_width so entities don't + // immediately escape smaller grids. + let hw = grids + .get(parent.parent()) + .ok() + .and_then(|(_, cfg)| cfg) + .map_or(50.0, |c| c.half_width); + let s = scale / hw; + transform.translation.x += t.sin() * s; + transform.translation.y += t.cos() * s; + transform.translation.z += (t * 2.3).sin() * s; } } + // Move the player along a path within its parent grid's extent. + let (mut transform, mut cell, child_of, _hash) = player.single_mut()?; + let (grid, cfg) = grids.get(child_of.parent())?; + let hw = cfg.map_or(50.0, |c| c.half_width); + let cw = grid.cell_edge_length(); + let t = time.elapsed_secs() * 0.01; - let (mut transform, mut cell, child_of, hash) = player.single_mut()?; - let absolute_pos = HALF_WIDTH - * CELL_WIDTH - * 0.8 - * Vec3::new((5.0 * t).sin(), (7.0 * t).cos(), (20.0 * t).sin()); - (*cell, transform.translation) = grids - .get(child_of.parent())? - .imprecise_translation_to_grid(absolute_pos); + let absolute_pos = + hw * cw * 0.8 * Vec3::new((5.0 * t).sin(), (7.0 * t).cos(), (20.0 * t).sin()); + (*cell, transform.translation) = grid.imprecise_translation_to_grid(absolute_pos); neighbors.clear(); - hash_grid.flood(hash, None).entities().for_each(|entity| { - neighbors.push(entity); - if let Ok(mut material) = materials.get_mut(entity) { - material.set_if_neq(material_presets.flood.clone().into()); - } - }); - - hash_grid - .get(hash) - .unwrap() - .nearby(&hash_grid) - .entities() - .for_each(|entity| { - neighbors.push(entity); - if let Ok(mut material) = materials.get_mut(entity) { - material.set_if_neq(material_presets.highlight.clone().into()); - } - }); + // hash_grid.flood(hash, None).entities().for_each(|entity| { + // neighbors.push(entity); + // if let Ok(mut material) = materials.get_mut(entity) { + // material.set_if_neq(material_presets.flood.clone().into()); + // } + // }); + // + // hash_grid + // .get(hash) + // .unwrap() + // .nearby(&hash_grid) + // .entities() + // .for_each(|entity| { + // neighbors.push(entity); + // if let Ok(mut material) = materials.get_mut(entity) { + // material.set_if_neq(material_presets.highlight.clone().into()); + // } + // }); let mut text = text.single_mut()?; text.0 = format!( "\ Controls: WASD to move, QE to roll -F to spawn 1,000, G to double +F to spawn 1,000/grid, G to double +Grids: {: >15} Population: {: >8} Entities Transform Propagation @@ -230,6 +325,7 @@ Update Maps: {: >16.1?} Update Partitions: {: >10.1?} Total: {: >22.1?}", + GRIDS.len(), n_entities .to_string() .as_bytes() @@ -256,27 +352,78 @@ Total: {: >22.1?}", Ok(()) } -fn spawn(mut commands: Commands) { - commands.spawn_big_space(Grid::new(CELL_WIDTH, 0.0), |root| { +fn spawn(mut commands: Commands, material_presets: Res) { + // Compute positions: lay grids out in a line along the X axis with spacing. + let spacing = 1.5; // multiplier for gap between grids + let mut offsets: Vec = Vec::with_capacity(GRIDS.len()); + let mut x_cursor: f32 = 0.0; + for (i, cfg) in GRIDS.iter().enumerate() { + if i > 0 { + x_cursor += GRIDS[i - 1].extent() * 0.5 * spacing + cfg.extent() * 0.5 * spacing; + } + offsets.push(x_cursor); + } + // Center the whole layout around the origin. + let total_center = if offsets.is_empty() { + 0.0 + } else { + (offsets[0] + offsets[offsets.len() - 1]) * 0.5 + }; + + // Use the first grid's cell width for the root BigSpace. The sub-grids each have their own. + let root_cell_width = GRIDS.first().map_or(10.0, |g| g.cell_width); + + commands.spawn_big_space(Grid::new(root_cell_width, 0.0), |root| { + // Camera as a direct child of the root. + let first_half_width = GRIDS.first().map_or(50.0, |g| g.half_width); + let first_cell_width = GRIDS.first().map_or(10.0, |g| g.cell_width); root.spawn_spatial(( FloatingOrigin, Camera3d::default(), Hdr, Camera::default(), Tonemapping::AcesFitted, - Transform::from_xyz(0.0, 0.0, HALF_WIDTH * CELL_WIDTH * 2.0), + Transform::from_xyz(0.0, 0.0, first_half_width * first_cell_width * 2.0), BigSpaceCameraController::default() .with_smoothness(0.98, 0.93) .with_slowing(false) .with_speed(15.0), Bloom::default(), - CellCoord::new(0, 0, HALF_WIDTH as GridPrecision / 2), + CellCoord::new(0, 0, first_half_width as GridPrecision / 2), )) .with_children(|b| { b.spawn(DirectionalLight::default()); }); - root.spawn_spatial(Player); + // Spawn each configured sub-grid. + for (i, cfg) in GRIDS.iter().enumerate() { + let x_offset = offsets[i] - total_center; + let grid = Grid::new(cfg.cell_width, 0.0); + + root.with_grid(grid, |sub_grid| { + // Derive a deterministic rotation from the seed so each grid is visibly misaligned. + let angle = (cfg.seed as f32) % core::f32::consts::TAU; + let rotation = Quat::from_euler(EulerRot::YXZ, angle, angle * 0.7, angle * 0.3); + sub_grid.insert(( + Transform::from_xyz(x_offset, 0.0, 0.0).with_rotation(rotation), + cfg.clone(), + )); + + if cfg.has_player { + sub_grid.spawn_spatial(Player); + } + + // Spawn initial entities. + let grid_entity = sub_grid.id(); + spawn_entities_in_grid( + sub_grid.commands(), + grid_entity, + cfg, + cfg.initial_entities, + &material_presets, + ); + }); + } }); } @@ -284,62 +431,98 @@ fn spawn_spheres( mut commands: Commands, input: Res>, material_presets: Res, - grid: Query>, + grids: Query<(Entity, &GridConfig)>, non_players: Query<(), With>, ) -> Result { - let n_entities = non_players.iter().len().max(1); - let n_spawn = if input.pressed(KeyCode::KeyG) { - n_entities + let total_existing = non_players.iter().len().max(1); + let n_spawn_per_grid = if input.pressed(KeyCode::KeyG) { + // Double: distribute evenly across grids. + total_existing / GRIDS.len().max(1) } else if input.pressed(KeyCode::KeyF) { 1_000 } else { return Ok(()); }; - let entity = grid.single()?; - commands.entity(entity).with_children(|builder| { - for value in sample_noise(n_spawn, &Simplex::new(345612), &Rng::new()) { - let hash = CellId::__new_manual(entity, &CellCoord::default()); - builder.spawn(( + for (entity, cfg) in grids.iter() { + spawn_entities_in_grid( + &mut commands, + entity, + cfg, + n_spawn_per_grid, + &material_presets, + ); + } + Ok(()) +} + +/// Single spawn point for all stress-test entities. Both startup and runtime spawning +/// funnel through here so there is one place to tweak the entity bundle. +fn spawn_entities_in_grid( + commands: &mut Commands, + grid_entity: Entity, + cfg: &GridConfig, + count: usize, + _material_presets: &MaterialPresets, +) { + let noise = Simplex::new(cfg.seed as u32); + let rng = Rng::with_seed(cfg.seed); + let num_moving = ((1.0 - cfg.percent_static) * count as f32) as usize; + + commands.entity(grid_entity).with_children(|builder| { + for (i, value) in + sample_noise(count, cfg.half_width, cfg.cell_width, &noise, &rng).enumerate() + { + let hash = CellId::new_manual(grid_entity, &CellCoord::default()); + + // -- Common components for every entity. Comment out lines here to + // disable mesh rendering / visibility etc. across all spawn paths. -- + let common = ( Transform::from_xyz(value.x, value.y, value.z), GlobalTransform::default(), CellCoord::default(), CellHash::from(hash), hash, NonPlayer, - Mesh3d(material_presets.sphere.clone()), - MeshMaterial3d(material_presets.default.clone()), - bevy_camera::visibility::VisibilityRange { - start_margin: 1.0..5.0, - end_margin: HALF_WIDTH * CELL_WIDTH * 0.5..HALF_WIDTH * CELL_WIDTH * 0.8, - use_aabb: false, - }, - bevy_camera::visibility::NoFrustumCulling, - )); + // Mesh3d(_material_presets.sphere.clone()), + // MeshMaterial3d(_material_presets.default.clone()), + // bevy_camera::visibility::VisibilityRange { + // start_margin: 1.0..5.0, + // end_margin: cfg.half_width * cfg.cell_width * 0.5 + // ..cfg.half_width * cfg.cell_width * 0.8, + // use_aabb: false, + // }, + // bevy_camera::visibility::NoFrustumCulling, + ); + + // Branch to avoid an extra .insert() and archetype move. + if i < num_moving { + builder.spawn(common); + } else { + builder.spawn((common, Stationary)); + } } }); - Ok(()) } #[inline] fn sample_noise<'a, T: NoiseFn>( n_entities: usize, + half_width: f32, + cell_width: f32, noise: &'a T, rng: &'a Rng, ) -> impl Iterator + use<'a, T> { - core::iter::repeat_with( - || loop { - let noise_scale = 0.05 * HALF_WIDTH as f64; - let threshold = 0.50; - let rng_val = || rng.f64_normalized() * noise_scale; - let coord = [rng_val(), rng_val(), rng_val()]; - if noise.get(coord) > threshold { - return DVec3::from_array(coord).as_vec3() * HALF_WIDTH * CELL_WIDTH - / noise_scale as f32; - } - }, - // Vec3::ONE - ) + core::iter::repeat_with(move || loop { + let noise_scale = 0.05 * half_width as f64; + let threshold = 0.50; + let rng_val = || rng.f64_normalized() * noise_scale; + let coord = [rng_val(), rng_val(), rng_val()]; + if noise.get(coord) > threshold { + return DVec3::from_array(coord).as_vec3() * half_width * cell_width + / noise_scale as f32; + } + }) .take(n_entities) } @@ -387,15 +570,12 @@ fn cursor_grab( Ok(()) } -// Highlight entities that changed partitions by setting their material to bright red fn highlight_changed_entities( mut materials: Query<&mut MeshMaterial3d>, material_presets: Res, entity_partitions: Res, - // Track highlighted entities with a countdown of remaining frames mut active: Local>, ) { - // We'll rebuild the active list each frame, carrying forward countdowns let mut next_active: Vec<(Entity, u8)> = Vec::with_capacity(active.len() + entity_partitions.changed.len()); @@ -407,23 +587,18 @@ fn highlight_changed_entities( } for (entity, mut frames_left) in active.drain(..) { - // If the entity also changed this frame, it's already added with 10 above if entity_partitions.changed.contains_key(&entity) { continue; } if frames_left > 0 { frames_left -= 1; if frames_left > 0 { - // Keep highlighted if let Ok(mut mat) = materials.get_mut(entity) { mat.set_if_neq(material_presets.changed.clone().into()); } next_active.push((entity, frames_left)); - } else { - // Countdown expired: reset to default - if let Ok(mut mat) = materials.get_mut(entity) { - mat.set_if_neq(material_presets.default.clone().into()); - } + } else if let Ok(mut mat) = materials.get_mut(entity) { + mat.set_if_neq(material_presets.default.clone().into()); } } } diff --git a/src/buffered_channel.rs b/src/buffered_channel.rs new file mode 100644 index 0000000..15565c6 --- /dev/null +++ b/src/buffered_channel.rs @@ -0,0 +1,266 @@ +//! Copied from bevy main, can switch over after bevy 0.19 + +use alloc::vec::Vec; +use async_channel::{Receiver, Sender}; +use bevy_utils::Parallel; +use core::ops::{Deref, DerefMut}; + +/// An asynchronous MPSC channel that buffers messages and reuses allocations with thread locals. +/// +/// This is a building block for efficient parallel worker tasks. +/// +/// Cache this channel in a system's `Local` to reuse allocated memory. +/// +/// This is faster than sending each message individually into a channel when communicating between +/// tasks. Unlike `Parallel`, this allows you to execute a consuming task while producing tasks are +/// concurrently sending data into the channel, enabling you to run a serial processing consumer +/// at the same time as many parallel processing producers. +pub struct BufferedChannel { + /// The minimum length of a `Vec` of buffered data before it is sent through the channel. + pub chunk_size: usize, + /// A pool of reusable vectors to minimize allocations. + pool: Parallel>>, +} + +impl Default for BufferedChannel { + fn default() -> Self { + Self { + // This was tuned based on benchmarks across a wide range of sizes. + chunk_size: 1024, + pool: Parallel::default(), + } + } +} + +impl BufferedChannel { + const MAX_POOL_SIZE: usize = 8; + + fn recycle(&self, mut chunk: Vec) { + if chunk.capacity() < self.chunk_size { + return; + } + chunk.clear(); + let mut pool = self.pool.borrow_local_mut(); + if pool.len() < Self::MAX_POOL_SIZE { + // Only push to the pool if it's not full + // Avoids memory leak if the sender and receiver never switch threads + pool.push(chunk); + } + } +} + +/// A wrapper around a [`Receiver`] that returns [`RecycledVec`]s to automatically return +/// buffers to the [`BufferedChannel`] pool. +pub struct BufferedReceiver<'a, T: Send> { + channel: &'a BufferedChannel, + rx: Receiver>, +} + +impl<'a, T: Send> BufferedReceiver<'a, T> { + /// Receive a message asynchronously. + /// + /// The returned [`RecycledVec`] will automatically return the buffer to the pool when dropped. + pub async fn recv(&self) -> Result, async_channel::RecvError> { + let buffer = self.rx.recv().await?; + Ok(RecycledVec { + buffer: Some(buffer), + channel: self.channel, + }) + } + + /// Receive a message blocking. + /// + /// The returned [`RecycledVec`] will automatically return the buffer to the pool when dropped. + pub fn recv_blocking(&self) -> Result, async_channel::RecvError> { + #[cfg(all(feature = "std", not(target_family = "wasm")))] + let buffer = self.rx.recv_blocking()?; + #[cfg(any(not(feature = "std"), target_family = "wasm"))] + let buffer = bevy_platform::future::block_on(self.rx.recv())?; + + Ok(RecycledVec { + buffer: Some(buffer), + channel: self.channel, + }) + } +} + +impl<'a, T: Send> Clone for BufferedReceiver<'a, T> { + fn clone(&self) -> Self { + Self { + channel: self.channel, + rx: self.rx.clone(), + } + } +} + +/// A wrapper around a `Vec` that automatically returns it to the [`BufferedChannel`]'s pool when +/// dropped. +pub struct RecycledVec<'a, T: Send> { + buffer: Option>, + channel: &'a BufferedChannel, +} + +impl<'a, T: Send> RecycledVec<'a, T> { + /// Drains the elements from the buffer as an iterator, keeping the allocation + /// so it can be recycled when this [`RecycledVec`] is dropped. + pub fn drain(&mut self) -> alloc::vec::Drain<'_, T> { + self.buffer.as_mut().unwrap().drain(..) + } +} + +impl<'a, T: Send> Deref for RecycledVec<'a, T> { + type Target = [T]; + fn deref(&self) -> &Self::Target { + self.buffer.as_ref().unwrap() + } +} + +impl<'a, T: Send> DerefMut for RecycledVec<'a, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.buffer.as_mut().unwrap() + } +} + +impl<'a, 'b, T: Send> IntoIterator for &'b RecycledVec<'a, T> { + type Item = &'b T; + type IntoIter = core::slice::Iter<'b, T>; + + fn into_iter(self) -> Self::IntoIter { + self.buffer.as_ref().unwrap().iter() + } +} + +impl<'a, 'b, T: Send> IntoIterator for &'b mut RecycledVec<'a, T> { + type Item = &'b mut T; + type IntoIter = core::slice::IterMut<'b, T>; + + fn into_iter(self) -> Self::IntoIter { + self.buffer.as_mut().unwrap().iter_mut() + } +} + +impl<'a, T: Send> Drop for RecycledVec<'a, T> { + fn drop(&mut self) { + if let Some(buffer) = self.buffer.take() { + self.channel.recycle(buffer); + } + } +} + +/// A [`BufferedChannel`] sender that buffers messages locally, flushing it when the sender is +/// dropped or [`BufferedChannel::chunk_size`] is reached. +pub struct BufferedSender<'a, T: Send> { + channel: &'a BufferedChannel, + /// We use an `Option` to lazily allocate the buffer or pull from the channel's buffer pool. + buffer: Option>, + tx: Sender>, +} + +impl BufferedChannel { + fn get_buffer(&self) -> Vec { + self.pool + .borrow_local_mut() + .pop() + .unwrap_or_else(|| Vec::with_capacity(self.chunk_size)) + } + + /// Create an unbounded channel and return the receiver and sender. + /// + /// The created channel can hold an unlimited number of messages. + pub fn unbounded(&self) -> (BufferedReceiver<'_, T>, BufferedSender<'_, T>) { + let (tx, rx) = async_channel::unbounded(); + ( + BufferedReceiver { channel: self, rx }, + BufferedSender { + channel: self, + buffer: None, + tx, + }, + ) + } + + /// Create a bounded channel and return the receiver and sender. + /// + /// The created channel has space to hold at most `cap` messages at a time. + /// + /// # Panics + /// + /// Capacity must be a positive number. If `cap` is zero, this function will panic. + pub fn bounded(&self, cap: usize) -> (BufferedReceiver<'_, T>, BufferedSender<'_, T>) { + let (tx, rx) = async_channel::bounded(cap); + ( + BufferedReceiver { channel: self, rx }, + BufferedSender { + channel: self, + buffer: None, + tx, + }, + ) + } +} + +impl<'a, T: Send> BufferedSender<'a, T> { + /// Send a message asynchronously. + /// + /// This is buffered and will not be sent into the channel until [`BufferedChannel::chunk_size`] + /// messages are accumulated or the sender is dropped. + pub async fn send(&mut self, msg: T) -> Result<(), async_channel::SendError>> { + let buffer = self.buffer.get_or_insert_with(|| self.channel.get_buffer()); + buffer.push(msg); + if buffer.len() >= self.channel.chunk_size { + let full_buffer = self.buffer.take().unwrap(); + self.tx.send(full_buffer).await?; + } + Ok(()) + } + + /// Send an item blocking. + /// + /// This is buffered and will not be sent into the channel until [`BufferedChannel::chunk_size`] + /// messages are accumulated or the sender is dropped. + pub fn send_blocking(&mut self, msg: T) -> Result<(), async_channel::SendError>> { + let buffer = self.buffer.get_or_insert_with(|| self.channel.get_buffer()); + buffer.push(msg); + if buffer.len() >= self.channel.chunk_size { + let full_buffer = self.buffer.take().unwrap(); + #[cfg(all(feature = "std", not(target_family = "wasm")))] + self.tx.send_blocking(full_buffer)?; + #[cfg(any(not(feature = "std"), target_family = "wasm"))] + bevy_platform::future::block_on(self.tx.send(full_buffer))?; + } + Ok(()) + } + + /// Flush any remaining messages in the local buffer, sending them into the channel. + pub fn flush(&mut self) { + if let Some(buffer) = self.buffer.take() { + if !buffer.is_empty() { + // The allocation is sent through the channel and will be reused when dropped. + #[cfg(all(feature = "std", not(target_family = "wasm")))] + let _ = self.tx.send_blocking(buffer); + #[cfg(any(not(feature = "std"), target_family = "wasm"))] + let _ = bevy_platform::future::block_on(self.tx.send(buffer)); + } else { + // If it's empty, just return it to the pool. + self.channel.recycle(buffer); + } + } + } +} + +impl<'a, T: Send> Clone for BufferedSender<'a, T> { + fn clone(&self) -> Self { + Self { + channel: self.channel, + buffer: None, + tx: self.tx.clone(), + } + } +} + +/// Automatically flush the buffer when a sender is dropped. +impl<'a, T: Send> Drop for BufferedSender<'a, T> { + fn drop(&mut self) { + self.flush(); + } +} diff --git a/src/camera.rs b/src/camera.rs index 3416b52..6d61cbb 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -286,8 +286,16 @@ pub fn camera_controller( let [min, max] = controller.speed_bounds; let speed = speed.clamp(min, max); - let lerp_translation = 1.0 - controller.smoothness.clamp(0.0, 0.999); - let lerp_rotation = 1.0 - controller.rotational_smoothness.clamp(0.0, 0.999); + let dt = time.delta_secs_f64(); + // Framerate-independent exponential smoothing. At 60fps (dt=1/60) the exponent + // is 1.0, reproducing the original per-frame behavior. At other framerates the + // decay scales correctly so the feel is consistent. + let lerp_translation = 1.0 - controller.smoothness.clamp(0.0, 0.999).powf(dt * 60.0); + let lerp_rotation = 1.0 + - controller + .rotational_smoothness + .clamp(0.0, 0.999) + .powf(dt * 60.0); let (vel_t_current, vel_r_current) = (controller.vel_translation, controller.vel_rotation); let (vel_t_target, vel_r_target) = diff --git a/src/grid/cell.rs b/src/grid/cell.rs index f031e68..49db568 100644 --- a/src/grid/cell.rs +++ b/src/grid/cell.rs @@ -82,7 +82,10 @@ impl CellCoord { pub fn recenter_large_transforms( mut stats: Option>, grids: Query<&Grid>, - mut changed_transform: Query<(&mut Self, &mut Transform, &ChildOf), Changed>, + mut changed_transform: Query< + (&mut Self, &mut Transform, &ChildOf), + (Changed, Without), + >, ) { let start = Instant::now(); changed_transform diff --git a/src/grid/local_origin.rs b/src/grid/local_origin.rs index eb37653..24a0da0 100644 --- a/src/grid/local_origin.rs +++ b/src/grid/local_origin.rs @@ -66,11 +66,9 @@ mod inner { /// The above requirements help to ensure this transform has a small magnitude, maximizing /// precision, and minimizing floating point error. grid_transform: DAffine3, - /// Returns `true` iff the position of the floating origin's grid origin has not moved - /// relative to this grid. - /// - /// When true, this means that any entities in this grid that have not moved do not need to - /// have their `GlobalTransform` recomputed. + /// `true` if the floating origin's position relative to this grid is identical to the + /// previous frame. When `true`, unchanged entities in this grid can skip GT recomputation. + /// Set by [`Self::set`] each frame by comparing the new value to the previous one. is_local_origin_unchanged: bool, } @@ -137,7 +135,22 @@ mod inner { } } - /// Returns true iff the local origin has not changed relative to the floating origin. + /// Returns `true` if the floating origin's position relative to this grid has not changed + /// since the last frame. + /// + /// When this returns `true`, entities in this grid whose own [`Transform`], [`CellCoord`], + /// or parent have not changed can skip [`GlobalTransform`] recomputation - the result + /// would be identical to the previous frame. + /// + /// When this returns `false`, the floating origin has moved into a different cell, or the + /// grid hierarchy has shifted, and **every** entity in this grid needs its + /// [`GlobalTransform`] recomputed, even if the entity itself hasn't moved. + /// + /// Updated each frame by [`Self::set`], which is called from + /// [`Self::compute_all`]. + /// + /// [`Transform`]: bevy_transform::prelude::Transform + /// [`GlobalTransform`]: bevy_transform::prelude::GlobalTransform #[inline] pub fn is_local_origin_unchanged(&self) -> bool { self.is_local_origin_unchanged diff --git a/src/grid/mod.rs b/src/grid/mod.rs index ed7fbac..4bf63cd 100644 --- a/src/grid/mod.rs +++ b/src/grid/mod.rs @@ -55,7 +55,13 @@ impl Grid { } } - /// Get the position of the floating origin relative to the current grid. + /// The [`LocalFloatingOrigin`] for this grid, describing where the floating origin's grid + /// cell origin is located relative to this grid. + /// + /// Computed each frame by [`LocalFloatingOrigin::compute_all`] before transform propagation. + /// Use [`LocalFloatingOrigin::is_local_origin_unchanged`] to check whether the relationship + /// between the floating origin and this grid has changed since the last frame - when + /// unchanged, entities in this grid that haven't moved can skip GT recomputation. #[inline] pub fn local_floating_origin(&self) -> &LocalFloatingOrigin { &self.local_floating_origin diff --git a/src/grid/propagation.rs b/src/grid/propagation.rs index 146762f..8da3a7c 100644 --- a/src/grid/propagation.rs +++ b/src/grid/propagation.rs @@ -1,7 +1,9 @@ //! Logic for propagating transforms through the hierarchy of grids. -use crate::prelude::*; -use bevy_ecs::prelude::*; +use crate::{prelude::*, stationary::GridDirtyTick}; +use bevy_ecs::{prelude::*, system::SystemChangeTick}; +#[cfg(feature = "std")] +use bevy_log::tracing::Instrument; use bevy_reflect::Reflect; use bevy_transform::prelude::*; @@ -16,74 +18,201 @@ use bevy_transform::prelude::*; pub struct LowPrecisionRoot; impl Grid { - /// Update the `GlobalTransform` of entities with a [`CellCoord`], using the [`Grid`] the entity - /// belongs to. + /// Update the [`GlobalTransform`] of root [`BigSpace`] grids. + /// + /// Root grids don't have a [`CellCoord`], so they aren't covered by + /// [`Self::propagate_high_precision`]. Their GT is determined entirely by the + /// [`LocalFloatingOrigin`]. + pub fn propagate_root_grids( + mut root_grids: Query<(&Grid, &mut GlobalTransform), With>, + ) { + root_grids.par_iter_mut().for_each(|(grid, mut gt)| { + if !grid.local_floating_origin().is_local_origin_unchanged() { + *gt = grid.global_transform(&CellCoord::default(), &Transform::IDENTITY); + } + }); + } + + /// Update the [`GlobalTransform`] of all entities with a [`CellCoord`], including both sub-grid + /// entities and leaf entities. + /// + /// Runs as a flat [`Query::par_iter_mut`], looking up each entity's parent [`Grid`] to retrieve + /// the [`LocalFloatingOrigin`] already propagated by [`LocalFloatingOrigin::compute_all`]. + /// Every entity's GT depends only on its own components (owned) and its parent grid's + /// [`LocalFloatingOrigin`] (shared read), so this is trivially parallelizable with no unsafe + /// code. + /// + /// If [`GridDirtyTick`] is present on the parent grid (inserted by + /// [`BigSpaceStationaryPlugin`]), entities in clean subtrees are skipped entirely. pub fn propagate_high_precision( + system_ticks: SystemChangeTick, mut stats: Option>, - grids: Query<&Grid>, - mut entities: ParamSet<( - Query<( + grids: Query<(&Grid, Option<&GridDirtyTick>)>, + mut entities: Query< + ( Ref, Ref, Ref, &mut GlobalTransform, - )>, - Query<(&Grid, &mut GlobalTransform), With>, - )>, + Option<&Stationary>, + Option<&StationaryComputed>, + ), + With, + >, ) { let start = bevy_platform::time::Instant::now(); - // Performance note: I've also tried to iterate over each grid's children at once, to avoid - // the grid and parent lookup, but that made things worse because it prevented dumb - // parallelism. The only thing I can see to make this faster is archetype change detection. - // Change filters are not archetype filters, so they scale with the total number of entities - // that match the query, regardless of change. - entities - .p0() - .par_iter_mut() - .for_each(|(cell, transform, parent, mut global_transform)| { - if let Ok(grid) = grids.get(parent.parent()) { - // Optimization: we don't need to recompute the transforms if the entity hasn't - // moved and the floating origin's local origin in that grid hasn't changed. - // - // This also ensures we don't trigger change detection on GlobalTransforms when - // they haven't changed. - // - // This check can have a big impact on reducing computations for entities in the - // same grid as the floating origin, i.e. the main camera. It also means that as - // the floating origin moves between cells, that could suddenly cause a spike in - // the amount of computation needed that grid. In the future, we might be able - // to spread that work across grids, entities far away can maybe be delayed for - // a grid or two without being noticeable. - if !grid.local_floating_origin().is_local_origin_unchanged() - || transform.is_changed() - || cell.is_changed() - || parent.is_changed() - { - *global_transform = grid.global_transform(&cell, &transform); - } + entities.par_iter_mut().for_each( + |(cell, transform, parent_rel, mut gt, stationary, computed)| { + let Ok((grid, dirty_tick)) = grids.get(parent_rel.parent()) else { + return; + }; + + let is_stationary = stationary.is_some(); + let is_computed = computed.is_some(); + + // Grid-level early exit: we can only skip when BOTH conditions hold: + // 1. The grid's local floating origin hasn't moved (no cell change by the FO), AND + // 2. The subtree is clean (no non-stationary entity changed this frame). + // If the FO moved to a new cell, every entity in the grid needs a new GT + // regardless of whether the entity itself changed. + let subtree_clean = dirty_tick.is_some_and(|dt| !dt.is_dirty(system_ticks)); + if grid.local_floating_origin().is_local_origin_unchanged() && subtree_clean { + return; } - }); - // Root grids - // - // These are handled separately because the root grid doesn't have a Transform or GridCell - - // it wouldn't make sense because it is the root, and these components are relative to their - // parent. Due to floating origins, it *is* possible for the root grid to have a - // GlobalTransform - this is what makes it possible to place a low precision (Transform - // only) entity in a root transform - it is relative to the origin of the root grid. - entities - .p1() - .iter_mut() - .for_each(|(grid, mut global_transform)| { - if grid.local_floating_origin().is_local_origin_unchanged() { - return; // By definition, this means the grid has not moved + // Recompute GT when: + // - The grid's local origin moved (FO changed cells), forcing all entities to + // update even if they haven't moved themselves, OR + // - The entity's own transform/cell/parent changed, OR + // - The entity is stationary but hasn't had its initial GT computed yet. + if !grid.local_floating_origin().is_local_origin_unchanged() + || (transform.is_changed() && !is_stationary) + || cell.is_changed() + || parent_rel.is_changed() + || (is_stationary && !is_computed) + { + *gt = grid.global_transform(&cell, &transform); } - // The global transform of the root grid is the same as the transform of an entity - // at the origin - it is determined entirely by the local origin position: - *global_transform = - grid.global_transform(&CellCoord::default(), &Transform::IDENTITY); - }); + }, + ); + + if let Some(stats) = stats.as_mut() { + stats.high_precision_propagation += start.elapsed(); + } + } + + /// Update the [`GlobalTransform`] of all entities with a [`CellCoord`], using a + /// producer-consumer architecture with a [`BufferedChannel`]. + /// + /// A single producer sequentially visits every [`Grid`], checks whether the grid can be + /// skipped (unchanged local floating origin **and** clean subtree), and sends the + /// [`Children`] of dirty grids through a buffered channel. Consumer tasks, spawned upfront + /// on the [`ComputeTaskPool`], pull batches of entities from the channel and update their + /// [`GlobalTransform`] via [`Query::get_unchecked`]. + /// + /// This is the default high-precision propagation system on `std` targets. The flat + /// [`Self::propagate_high_precision`] variant is used on `no_std`. + /// + /// [`BufferedChannel`]: crate::buffered_channel::BufferedChannel + #[allow(rustdoc::private_intra_doc_links)] + /// [`ComputeTaskPool`]: bevy_tasks::ComputeTaskPool + #[cfg(feature = "std")] + #[expect( + unsafe_code, + reason = "Uses get_unchecked and a Send wrapper for sharing queries across threads." + )] + pub fn propagate_high_precision_channeled( + system_ticks: SystemChangeTick, + mut stats: Option>, + grids: Query<(&Grid, Option<&GridDirtyTick>, Option<&Children>)>, + entities: Query< + ( + Ref, + Ref, + Ref, + &mut GlobalTransform, + Has, + Has, + ), + With, + >, + mut channel: Local>, + ) { + let start = bevy_platform::time::Instant::now(); + let task_pool = bevy_tasks::ComputeTaskPool::get(); + let shared_entities = &entities; + let shared_grids = &grids; + + task_pool.scope(|scope| { + channel.chunk_size = 1024 * 10; + let (rx, tx) = channel.unbounded(); + let shared_entities = &shared_entities; + + // Spawn consumer workers upfront. + let num_workers = task_pool.thread_num().max(1); + for _ in 0..num_workers { + let rx = rx.clone(); + scope.spawn( + async move { + while let Ok(chunk) = rx.recv().await { + for &entity in chunk.iter() { + // SAFETY: Each entity is sent through the channel at most + // once (each entity has exactly one parent grid, and each + // grid's children are sent exactly once by the producer). + // Therefore, no two consumer tasks will call get_unchecked + // on the same entity. + let Ok(( + cell, + transform, + parent, + mut gt, + is_stationary, + is_computed, + )) = (unsafe { shared_entities.get_unchecked(entity) }) + else { + continue; + }; + + // Read-only lookup of the parent grid. + let Ok((grid, _, _)) = shared_grids.get(parent.parent()) else { + continue; + }; + + if !grid.local_floating_origin().is_local_origin_unchanged() + || (transform.is_changed() && !is_stationary) + || cell.is_changed() + || parent.is_changed() + || (is_stationary && !is_computed) + { + *gt = grid.global_transform(&cell, &transform); + } + } + } + } + .instrument(bevy_log::info_span!("hp_propagation_worker")), + ); + } + // Drop the extra receiver clone so consumers see channel closure. + drop(rx); + + // Producer: visit each grid, skip clean subtrees, send dirty children. + grids.par_iter().for_each_init( + || tx.clone(), + |sender, (grid, dirty_tick, children)| { + let subtree_clean = dirty_tick.is_some_and(|dt| !dt.is_dirty(system_ticks)); + if grid.local_floating_origin().is_local_origin_unchanged() && subtree_clean { + return; + } + + let Some(children) = children else { return }; + for child in children.iter() { + sender.send_blocking(child).ok(); + } + }, + ); + drop(tx); + }); if let Some(stats) = stats.as_mut() { stats.high_precision_propagation += start.elapsed(); @@ -236,11 +365,7 @@ impl Grid { parent: &GlobalTransform, transform_query: &Query< (Ref, &mut GlobalTransform, Option<&Children>), - ( - With, - Without, // ***ADDED*** Only recurse low-precision entities - Without, // ***ADDED*** Only recurse low-precision entities - ), + (With, Without, Without), >, parent_query: &Query< (Entity, Ref), @@ -301,6 +426,97 @@ mod tests { use crate::prelude::*; use bevy::prelude::*; + /// Verifies that entities in sub-grids get the correct `GlobalTransform`. + /// + /// Hierarchy: Root `BigSpace` → `SubGrid` (`CellCoord` + Grid + Transform(100,0,0)) + /// → Entity (`CellCoord` + Transform(50,0,0)) + /// + /// Entity's GT should be 100 + 50 = 150 from the root FO. + #[test] + fn sub_grid_gt_is_correct() { + #[derive(Component)] + struct TestEntity; + + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_systems(Startup, |mut commands: Commands| { + commands.spawn_big_space_default(|root| { + root.spawn_spatial(FloatingOrigin); + // Sub-grid at (100, 0, 0) in root grid containing an entity at (50, 0, 0). + root.with_grid_default(|sub_grid| { + sub_grid.insert(Transform::from_xyz(100.0, 0.0, 0.0)); + sub_grid.spawn_spatial((Transform::from_xyz(50.0, 0.0, 0.0), TestEntity)); + }); + }); + }); + + app.update(); + + let mut q = app + .world_mut() + .query_filtered::<&GlobalTransform, With>(); + let gt = *q.single(app.world()).unwrap(); + assert_eq!( + gt.translation(), + Vec3::new(150.0, 0.0, 0.0), + "Entity in sub-grid should have GT = sub-grid pos + entity pos = 150" + ); + } + + /// Verifies that the root `BigSpace` grid's `GlobalTransform` updates when the floating + /// origin moves to a new cell. The root grid has no `CellCoord`, so it must be handled + /// separately from the flat `par_iter` over `CellCoord` entities. + #[test] + fn root_grid_gt_updates_when_fo_moves() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_systems(Startup, |mut commands: Commands| { + commands.spawn_big_space_default(|root| { + root.spawn_spatial(FloatingOrigin); + }); + }); + + app.update(); + + // Find the FO and root grid + let fo = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + let root = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + + let root_gt_before = app + .world() + .get::(root) + .unwrap() + .translation(); + assert_eq!(root_gt_before, Vec3::ZERO); + + // Move FO to cell (1, 0, 0) - root GT should shift by -cell_size + app.world_mut() + .entity_mut(fo) + .get_mut::() + .unwrap() + .x = 1; + app.update(); + + let root_gt_after = app + .world() + .get::(root) + .unwrap() + .translation(); + assert_ne!( + root_gt_after, + Vec3::ZERO, + "Root grid GT must update when the floating origin moves to a new cell" + ); + } + #[test] fn low_precision_in_big_space() { #[derive(Component)] diff --git a/src/hash/component.rs b/src/hash/component.rs index dda6f9b..8f380a9 100644 --- a/src/hash/component.rs +++ b/src/hash/component.rs @@ -1,9 +1,8 @@ //! Components for spatial hashing. -use alloc::vec::Vec; -use core::hash::{BuildHasher, Hash, Hasher}; - +use super::{ChangedCells, SpatialHashFilter}; use crate::prelude::*; +use alloc::vec::Vec; use bevy_ecs::prelude::*; use bevy_math::IVec3; use bevy_platform::{ @@ -12,8 +11,7 @@ use bevy_platform::{ time::Instant, }; use bevy_reflect::Reflect; - -use super::{ChangedCells, SpatialHashFilter}; +use core::hash::{BuildHasher, Hash, Hasher}; use crate::portable_par::PortableParallel; @@ -126,9 +124,16 @@ impl CellId { } } - /// Do not use this to manually construct this component. You've been warned. - #[doc(hidden)] - pub fn __new_manual(parent: Entity, cell: &CellCoord) -> Self { + /// Manually construct a [`CellId`] from a grid entity and cell coordinate. + /// + /// # Warning + /// + /// Prefer letting the plugin compute [`CellId`] automatically. Only use this when you + /// need to pre-compute a [`CellId`] at spawn time for batch insertion (e.g. to avoid + /// the one-frame delay of deferred commands). The caller is responsible for ensuring + /// that `parent` is a valid [`Grid`] entity and that `cell` matches the entity's + /// [`CellCoord`]. + pub fn new_manual(parent: Entity, cell: &CellCoord) -> Self { Self::from_parent(parent, cell) } @@ -180,17 +185,23 @@ impl CellId { mut commands: Commands, mut changed_cells: ResMut>, mut spatial_entities: Query< - (Entity, &ChildOf, &CellCoord, &mut CellId, &mut CellHash), - (F, Or<(Changed, Changed)>), + (Entity, &ChildOf, Ref, &mut CellId, &mut CellHash), + ( + F, + Without, + Or<(Changed, Changed)>, + ), + >, + added_entities: Query< + (Entity, &ChildOf, &CellCoord), + (F, Without, Without), >, - added_entities: Query<(Entity, &ChildOf, &CellCoord), (F, Without)>, mut removed_cells: RemovedComponents, mut stats: Option>, mut thread_updated_hashes: Local>>, mut thread_commands: Local>>, ) { let start = Instant::now(); - changed_cells.updated.clear(); // Create new added_entities @@ -208,12 +219,21 @@ impl CellId { // Update existing spatial_entities.par_iter_mut().for_each( |(entity, parent, cell, mut cell_guid, mut fast_hash)| { - let new_cell_guid = CellId::new(parent, cell); - let new_fast_hash = new_cell_guid.pre_hash; + let parent_entity = parent.parent(); + if cell_guid.coord == *cell && cell_guid.grid == parent_entity { + // Values are already correct. If the entity was just spawned with a + // pre-computed CellId (Added), register it so CellLookup picks it up. + if cell.is_added() { + thread_updated_hashes.scope(|tl| tl.push(entity)); + } + return; + } + + let new_cell_guid = CellId::from_parent(parent_entity, &cell); if cell_guid.replace_if_neq(new_cell_guid).is_some() { thread_updated_hashes.scope(|tl| tl.push(entity)); } - fast_hash.0 = new_fast_hash; + fast_hash.0 = new_cell_guid.pre_hash; }, ); @@ -235,4 +255,44 @@ impl CellId { pub fn grid(&self) -> Entity { self.grid } + + /// One-time initialization for [`Stationary`] entities. + pub fn initialize_stationary( + mut commands: Commands, + grids: Query<&Grid>, + mut stationary: Query< + ( + Entity, + &mut CellCoord, + &mut bevy_transform::prelude::Transform, + &ChildOf, + Option<&mut CellId>, + Option<&mut CellHash>, + ), + (F, With, Without), + >, + mut changed_cells: ResMut>, + ) { + for (entity, mut grid_pos, mut transform, parent, cell_id, cell_hash) in + stationary.iter_mut() + { + let parent_entity = parent.parent(); + if let Ok(grid) = grids.get(parent_entity) { + let (grid_cell_delta, translation) = grid + .imprecise_translation_to_grid(transform.bypass_change_detection().translation); + *grid_pos += grid_cell_delta; + transform.translation = translation; + + let current_id = CellId::new(parent, &grid_pos); + if let (Some(mut existing_id), Some(mut existing_hash)) = (cell_id, cell_hash) { + existing_id.set_if_neq(current_id); + existing_hash.set_if_neq(CellHash::from(current_id)); + } else { + let fast_hash: CellHash = current_id.into(); + commands.entity(entity).insert((current_id, fast_hash)); + } + changed_cells.insert(entity); + } + } + } } diff --git a/src/hash/mod.rs b/src/hash/mod.rs index 2d9092f..5c9ee92 100644 --- a/src/hash/mod.rs +++ b/src/hash/mod.rs @@ -45,9 +45,17 @@ where .add_systems( PostUpdate, ( + ChangedCells::::clear + .in_set(SpatialHashSystems::ClearChangedCells) + .after(BigSpaceSystems::Init), CellId::update:: .in_set(SpatialHashSystems::UpdateCellHashes) - .after(BigSpaceSystems::RecenterLargeTransforms), + .after(BigSpaceSystems::RecenterLargeTransforms) + .after(SpatialHashSystems::ClearChangedCells), + CellId::initialize_stationary:: + .in_set(SpatialHashSystems::UpdateCellHashes) + .after(CellId::update::) + .after(SpatialHashSystems::ClearChangedCells), CellLookup::::update .in_set(SpatialHashSystems::UpdateCellLookup) .after(SpatialHashSystems::UpdateCellHashes), @@ -73,6 +81,8 @@ pub enum SpatialHashSystems { UpdatePartitionLookup, /// [`PartitionEntities`] updated. UpdatePartitionChange, + /// Clear the [`ChangedCells`] resource. + ClearChangedCells, } /// Used as a [`QueryFilter`] to include or exclude certain types of entities from spatial @@ -118,10 +128,30 @@ impl Default for ChangedCells { } impl ChangedCells { + /// Clear the list of updated entities. + pub fn clear(mut this: ResMut) { + this.updated.clear(); + } + /// Iterate over all entities that have moved between cells. pub fn iter(&self) -> impl Iterator { self.updated.iter() } + + /// Returns the number of entities that have moved between cells this frame. + pub fn len(&self) -> usize { + self.updated.len() + } + + /// Returns true if there are no entities that have moved between cells this frame. + pub fn is_empty(&self) -> bool { + self.updated.is_empty() + } + + /// Mark an entity as having changed grid cells. + pub fn insert(&mut self, entity: Entity) { + self.updated.insert(entity); + } } // TODO: @@ -363,6 +393,57 @@ mod tests { ); } + /// Regression test for the `spatial_hash` example crash. + /// + /// On the first frame an entity spawned in `Startup` has its [`CellId`] inserted via deferred + /// commands during [`SpatialHashSystems::UpdateCellHashes`]. Any user system that queries the + /// [`CellLookup`] must therefore be ordered *after* + /// [`SpatialHashSystems::UpdateCellLookup`]; otherwise the entity is not yet present in the + /// map and an unwrap will panic. + #[test] + fn cell_lookup_populated_when_ordered_after_update() { + use bevy::prelude::*; + + static ENTITY: OnceLock = OnceLock::new(); + // Set inside the PostUpdate system when the entity's CellId is found in the lookup. + static FOUND: OnceLock<()> = OnceLock::new(); + + let setup = |mut commands: Commands| { + commands.spawn_big_space_default(|root| { + let entity = root.spawn_spatial(CellCoord::ZERO).id(); + ENTITY.set(entity).ok(); + }); + }; + + // This system mimics `move_player` from the spatial_hash example. + // It runs BEFORE UpdateCellLookup (wrong ordering), so the entity should NOT be + // in the lookup on the first frame – replicating the crash. + let check = |all_hashes: Query<(Entity, &CellId)>, lookup: Res| { + let target = *ENTITY.get().unwrap(); + if let Some((_, hash)) = all_hashes.iter().find(|(e, _)| *e == target) { + if lookup.get(hash).is_some() { + FOUND.set(()).ok(); + } + } + }; + + let mut app = App::new(); + app.add_plugins((BigSpaceMinimalPlugins, CellHashingPlugin::default())) + .add_systems(Startup, setup) + .add_systems( + PostUpdate, + check.after(SpatialHashSystems::UpdateCellLookup), + ); + + app.update(); + + assert!( + FOUND.get().is_some(), + "Entity spawned in Startup should be in CellLookup when the user system runs \ + after SpatialHashSystems::UpdateCellLookup" + ); + } + /// Verify that [`CellLookup::newly_emptied`] and [`CellLookup::newly_occupied`] work correctly when /// entities are spawned and move between cells. #[test] diff --git a/src/lib.rs b/src/lib.rs index fbdfb08..8d91e8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -216,10 +216,14 @@ pub mod grid; pub mod hash; pub mod partition; pub mod plugin; +pub mod stationary; pub mod timing; pub mod validation; pub mod world_query; +#[cfg(feature = "std")] +pub(crate) mod buffered_channel; + #[cfg(feature = "camera")] pub mod camera; #[cfg(feature = "debug")] @@ -250,6 +254,7 @@ pub mod prelude { }; pub use plugin::{BigSpaceDefaultPlugins, BigSpaceSystems}; pub use precision::GridPrecision; + pub use stationary::{BigSpaceStationaryPlugin, GridDirtyTick, Stationary, StationaryComputed}; pub use world_query::{CellTransform, CellTransformOwned, CellTransformReadOnly}; #[cfg(feature = "camera")] diff --git a/src/plugin.rs b/src/plugin.rs index d5f35a8..93250da 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -37,6 +37,7 @@ impl PluginGroup for BigSpaceDefaultPlugins { group = group .add_group(BigSpaceMinimalPlugins) + .add(BigSpaceStationaryPlugin) .add(BigSpaceTimingStatsPlugin); #[cfg(any(debug_assertions, feature = "debug"))] @@ -111,7 +112,7 @@ impl Plugin for BigSpacePropagationPlugin { LocalFloatingOrigin::compute_all .in_set(BigSpaceSystems::LocalFloatingOrigins) .after(BigSpaceSystems::RecenterLargeTransforms), - Grid::propagate_high_precision + Grid::propagate_root_grids .in_set(BigSpaceSystems::PropagateHighPrecision) .after(BigSpaceSystems::LocalFloatingOrigins), Grid::propagate_low_precision @@ -121,8 +122,23 @@ impl Plugin for BigSpacePropagationPlugin { .in_set(TransformSystems::Propagate) }; - app.add_systems(PostStartup, configs()) - .add_systems(PostUpdate, configs()); + #[cfg(feature = "std")] + let hp_system = || { + Grid::propagate_high_precision_channeled + .in_set(BigSpaceSystems::PropagateHighPrecision) + .after(BigSpaceSystems::LocalFloatingOrigins) + .in_set(TransformSystems::Propagate) + }; + #[cfg(not(feature = "std"))] + let hp_system = || { + Grid::propagate_high_precision + .in_set(BigSpaceSystems::PropagateHighPrecision) + .after(BigSpaceSystems::LocalFloatingOrigins) + .in_set(TransformSystems::Propagate) + }; + + app.add_systems(PostStartup, (configs(), hp_system())) + .add_systems(PostUpdate, (configs(), hp_system())); // These are the bevy transform propagation systems. Because these start from the root // of the hierarchy, and BigSpace bundles (at the root) do not contain a Transform, diff --git a/src/stationary.rs b/src/stationary.rs new file mode 100644 index 0000000..5fe5ece --- /dev/null +++ b/src/stationary.rs @@ -0,0 +1,223 @@ +//! Components and systems for optimizing stationary entities. +//! +//! See [`Stationary`], [`BigSpaceStationaryPlugin`]. + +use crate::prelude::*; +use bevy_app::prelude::*; +use bevy_ecs::{ + change_detection::Tick, lifecycle::HookContext, prelude::*, system::SystemChangeTick, + world::DeferredWorld, +}; +use bevy_reflect::prelude::*; +use bevy_transform::prelude::*; + +/// A component that optimizes entities that do not move. +/// +/// When an entity is marked as stationary, the plugin will skip most per-frame computations for it. +/// This includes grid recentering and spatial hashing updates. The `CellCoord` and `CellId` +/// will only be computed when the entity is spawned or when its parent changes. +/// +/// # Important +/// +/// Do **not** move a `Stationary` entity by mutating its [`Transform`] or [`CellCoord`]. +/// Stationary entities are excluded from grid-cell recentering and spatial hash updates, so +/// changes to these components will not be picked up by the plugin. If you need to relocate a +/// stationary entity, remove the `Stationary` component first, move the entity, and then +/// re-add it. +/// +/// Note that when a `Stationary` entity is first spawned, its [`Transform`] translation is +/// recentered into the correct grid cell (updating both [`CellCoord`] and [`Transform`]). +/// This one-time snap ensures the entity starts in a valid state regardless of the initial +/// translation magnitude. +#[derive(Debug, Clone, Reflect, Component, Default)] +#[component(on_remove = Stationary::on_remove)] +#[reflect(Component, Default)] +pub struct Stationary; + +impl Stationary { + /// Removes [`StationaryComputed`] when [`Stationary`] is removed, so that the entity + /// re-enters the normal update path for recentering and spatial hashing. + fn on_remove(mut world: DeferredWorld, ctx: HookContext) { + world + .commands() + .entity(ctx.entity) + .remove::(); + } +} + +/// Internal marker component used to identify [`Stationary`] entities that have had their initial +/// [`GlobalTransform`] computed. +/// +/// Inserted by [`BigSpaceStationaryPlugin`] (via `mark_stationary_computed`) after the first +/// frame's [`Grid::propagate_high_precision`] run. When present, propagation skips +/// recomputing the [`GlobalTransform`] for this entity unless the floating origin moves. +/// +/// Also inserted by [`CellHashingPlugin`] after the first +/// spatial hash computation, so both plugins can be used independently without conflict. +#[derive(Debug, Clone, Reflect, Component, Default)] +#[reflect(Component, Default)] +pub struct StationaryComputed; + +/// Enables subtree pruning in [`Grid::propagate_high_precision`]. +/// +/// Auto-inserted on all [`Grid`] entities by [`BigSpaceStationaryPlugin`]. +/// Absence means pruning is disabled for that grid (always treated as dirty). +/// +/// Stores the last tick when any non-[`Stationary`] entity in this grid's subtree +/// had a changed [`Transform`], [`CellCoord`], or [`ChildOf`]. +#[derive(Component, Default, Reflect)] +#[reflect(Component, Default)] +pub struct GridDirtyTick(u32); + +impl GridDirtyTick { + /// Returns `true` if this subtree has dirty non-stationary entities this frame. + pub(crate) fn is_dirty(&self, system_ticks: SystemChangeTick) -> bool { + Tick::new(self.0).is_newer_than(system_ticks.last_run(), system_ticks.this_run()) + } +} + +/// Marks grid subtrees as dirty when non-[`Stationary`] entities change. +/// +/// This pre-pass runs before [`Grid::propagate_high_precision`]. It walks the ancestors +/// of changed non-stationary entities and marks each ancestor [`Grid`] dirty via +/// [`GridDirtyTick`]. It also auto-inserts [`GridDirtyTick`] on any [`Grid`] that doesn't +/// have it yet. +/// +/// Additionally, any [`Grid`] whose [`Children`] list changed this frame (entities added +/// or removed) marks itself and all ancestor grids dirty, ensuring newly spawned entities +/// (including [`Stationary`] ones excluded by `changed`) always get their initial +/// [`GlobalTransform`] computed even if the grid subtree was previously clean. +pub(crate) fn mark_dirty_subtrees( + mut commands: Commands, + system_ticks: SystemChangeTick, + parents: Query<&ChildOf>, + mut dirty_ticks: Query<&mut GridDirtyTick>, + grids_without: Query, Without)>, + changed: Query< + &ChildOf, + ( + Without, + Or<(Changed, Changed, Changed)>, + ), + >, + // Catches grids that gained or lost children this frame (including newly spawned + // Stationary entities excluded by `changed`) without scanning all CellCoord entities. + grids_with_changed_children: Query, Changed)>, +) { + // Auto-insert on any Grid that doesn't have GridDirtyTick yet. + // Commands are deferred, so newly inserted grids treat themselves as dirty (correct for + // first GT initialization). + for entity in grids_without.iter() { + commands.entity(entity).insert(GridDirtyTick::default()); + } + + let current_tick = system_ticks.this_run().get(); + + for parent_rel in changed.iter() { + mark_ancestor_grids( + parent_rel.parent(), + current_tick, + &mut dirty_ticks, + &parents, + ); + } + + // Mark the grid itself (and its ancestors) dirty whenever its children list changes. + // This ensures a freshly spawned child entity receives its initial GlobalTransform + // even when the grid's subtree was otherwise clean. + for grid_entity in grids_with_changed_children.iter() { + mark_ancestor_grids(grid_entity, current_tick, &mut dirty_ticks, &parents); + } +} + +fn mark_ancestor_grids( + start: Entity, + current_tick: u32, + dirty_ticks: &mut Query<&mut GridDirtyTick>, + parents: &Query<&ChildOf>, +) { + let mut ancestor = start; + loop { + let Ok(mut dirty) = dirty_ticks.get_mut(ancestor) else { + break; + }; + // bypass_change_detection to avoid spurious Changed noise + let d = dirty.bypass_change_detection(); + // Early exit: if already marked this tick, all ancestors were marked too + if d.0 == current_tick { + break; + } + d.0 = current_tick; + match parents.get(ancestor) { + Ok(p) => ancestor = p.parent(), + Err(_) => break, + } + } +} + +/// Inserts [`StationaryComputed`] on [`Stationary`] entities at the end of the frame. +/// +/// Runs in [`Last`] to guarantee every [`PostUpdate`] system (including spatial hashing) has +/// had one full frame to observe entities with [`Stationary`] but without [`StationaryComputed`]. +/// Placing this in [`Last`] avoids the Bevy auto-`apply_deferred` that would otherwise be +/// inserted mid-[`PostUpdate`] between this system (Commands writer) and any system that +/// filters `Without`. +fn mark_stationary_computed( + mut commands: Commands, + uninitialized: Query, Without)>, +) { + for entity in uninitialized.iter() { + commands.entity(entity).insert(StationaryComputed); + } +} + +/// Opt-in plugin that enables the stationary entity subtree-pruning optimization. +/// +/// Add this plugin to enable dirty-tick tracking for [`Grid`] subtrees. When active, +/// [`Grid::propagate_high_precision`] skips entire subtrees where both: +/// - The grid's local floating origin has not changed, **and** +/// - No non-[`Stationary`] entity in the subtree has a changed [`Transform`], +/// [`CellCoord`], or [`ChildOf`] this frame. +/// +/// Without this plugin, all grid subtrees are visited every frame (correct, just less efficient +/// for worlds with many stationary entities spread across many grids). +/// +/// This plugin also registers reflection for [`Stationary`], [`StationaryComputed`], and +/// [`GridDirtyTick`]. +/// +/// # Note +/// +/// This plugin is included in [`BigSpaceDefaultPlugins`] but **not** in +/// [`BigSpaceMinimalPlugins`](crate::plugin::BigSpaceMinimalPlugins). Add it manually alongside +/// [`BigSpaceMinimalPlugins`](crate::plugin::BigSpaceMinimalPlugins) when you want the +/// optimization without the full default plugin set. +pub struct BigSpaceStationaryPlugin; + +impl Plugin for BigSpaceStationaryPlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .register_type::() + .register_type::(); + + #[cfg(feature = "std")] + let dirty_configs = || { + mark_dirty_subtrees + .in_set(BigSpaceSystems::PropagateHighPrecision) + .before(Grid::propagate_high_precision_channeled) + .after(BigSpaceSystems::LocalFloatingOrigins) + }; + #[cfg(not(feature = "std"))] + let dirty_configs = || { + mark_dirty_subtrees + .in_set(BigSpaceSystems::PropagateHighPrecision) + .before(Grid::propagate_high_precision) + .after(BigSpaceSystems::LocalFloatingOrigins) + }; + // mark_stationary_computed runs in Last (not PostUpdate) so it cannot trigger + // Bevy's auto-apply_deferred before any PostUpdate system that filters + // Without (e.g. CellId::initialize_stationary). + app.add_systems(PostUpdate, dirty_configs()) + .add_systems(PostStartup, dirty_configs()) + .add_systems(Last, mark_stationary_computed); + } +} diff --git a/src/tests.rs b/src/tests.rs index f830e17..834b646 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,4 +1,5 @@ -use crate::plugin::BigSpaceMinimalPlugins; +use crate::hash::ChangedCells; +use crate::plugin::{BigSpaceDefaultPlugins, BigSpaceMinimalPlugins}; use crate::prelude::*; use bevy::prelude::*; @@ -82,3 +83,610 @@ fn child_global_transforms_are_updated_when_floating_origin_changes() { assert_eq!(child_transform.translation(), Vec3::new(0.0, 0.0, 600.0)); } + +#[test] +fn stationary_entities_do_not_recenter() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let stationary = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), + CellCoord::new(0, 0, 0), + Stationary, + )) + .set_parent_in_place(grid_entity) + .id(); + + app.update(); + + // Move the stationary entity far away + app.world_mut() + .entity_mut(stationary) + .get_mut::() + .unwrap() + .translation = Vec3::new(100_000.0, 0.0, 0.0); + + app.update(); + + // It should NOT have recentered + let cell = app.world_mut().get::(stationary).unwrap(); + assert_eq!(*cell, CellCoord::new(0, 0, 0)); + + let transform = app.world_mut().get::(stationary).unwrap(); + assert_eq!(transform.translation.x, 100_000.0); +} + +#[test] +fn remove_stationary_move_then_readd() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_plugins(BigSpaceStationaryPlugin) + .add_plugins(CellHashingPlugin::default()); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + // FO at origin + app.world_mut() + .spawn((CellCoord::default(), FloatingOrigin)) + .set_parent_in_place(grid_entity); + + let entity = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::ZERO), + CellCoord::new(1, 0, 0), + Stationary, + )) + .set_parent_in_place(grid_entity) + .id(); + + // Stabilize + app.update(); + app.update(); + + let cell_before = *app.world().get::(entity).unwrap(); + assert_eq!(cell_before, CellCoord::new(1, 0, 0)); + + // Remove Stationary, move the entity far enough to trigger recentering, then re-add + app.world_mut().entity_mut(entity).remove::(); + app.update(); // cleanup_removed_stationary removes StationaryComputed + + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .translation = Vec3::new(100_000.0, 0.0, 0.0); + app.update(); // recentering runs because entity is no longer Stationary + + let cell_after_move = *app.world().get::(entity).unwrap(); + assert_ne!( + cell_after_move, + CellCoord::new(1, 0, 0), + "Entity should have been recentered into a new cell after removing Stationary" + ); + + // Re-add Stationary + app.world_mut().entity_mut(entity).insert(Stationary); + app.update(); + + // Verify StationaryComputed is re-applied and the entity is in the CellLookup + assert!( + app.world().get::(entity).is_some(), + "StationaryComputed should be re-inserted after re-adding Stationary" + ); + + let cell_id = *app.world().get::(entity).unwrap(); + let lookup = app.world().resource::>(); + assert!( + lookup + .get(&cell_id) + .unwrap() + .entities() + .any(|e| e == entity), + "Entity should be in CellLookup after re-adding Stationary" + ); + + // Verify it no longer recenters + let cell_snapshot = *app.world().get::(entity).unwrap(); + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .translation = Vec3::new(200_000.0, 0.0, 0.0); + app.update(); + + assert_eq!( + *app.world().get::(entity).unwrap(), + cell_snapshot, + "After re-adding Stationary, recentering should be skipped again" + ); +} + +#[test] +fn stationary_entities_are_correctly_initialized() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + app.add_plugins(CellHashingPlugin::default()); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let stationary = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), + CellCoord::new(1, 2, 3), + Stationary, + )) + .set_parent_in_place(grid_entity) + .id(); + + app.update(); + + // Verify it got a CellId + let cell_id = *app + .world_mut() + .get::(stationary) + .expect("Stationary entity should have a CellId after the first frame"); + assert_eq!(cell_id.coord(), CellCoord::new(1, 2, 3)); + + // Verify it is in CellLookup + let lookup = app.world().resource::>(); + assert!(lookup.contains(&cell_id)); + assert!(lookup + .get(&cell_id) + .unwrap() + .entities() + .any(|e| e == stationary)); +} + +#[test] +fn stationary_entity_spawned_with_cellid_is_registered() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + app.add_plugins(CellHashingPlugin::default()); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let coord = CellCoord::new(1, 2, 3); + let cell_id = CellId::new_manual(grid_entity, &coord); + let cell_hash = CellHash::from(cell_id); + + let stationary = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::ZERO), + coord, + cell_id, + cell_hash, + Stationary, + )) + .set_parent_in_place(grid_entity) + .id(); + + app.update(); + + // Verify it is in CellLookup + let lookup = app.world().resource::>(); + assert!( + lookup.contains(&cell_id), + "Stationary entity spawned with CellId should be in CellLookup" + ); + assert!( + lookup + .get(&cell_id) + .unwrap() + .entities() + .any(|e| e == stationary), + "Stationary entity should be found in CellLookup entry" + ); +} + +#[test] +fn moving_entity_spawned_with_cellid_is_registered() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + app.add_plugins(CellHashingPlugin::default()); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let coord = CellCoord::new(1, 2, 3); + let cell_id = CellId::new_manual(grid_entity, &coord); + let cell_hash = CellHash::from(cell_id); + + let _moving = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::ZERO), + coord, + cell_id, + cell_hash, + )) + .set_parent_in_place(grid_entity) + .id(); + + app.update(); + + // Verify it is in CellLookup + let lookup = app.world().resource::>(); + assert!( + lookup.contains(&cell_id), + "Moving entity spawned with CellId should be in CellLookup" + ); +} + +#[test] +fn stationary_entities_do_not_trigger_unnecessary_updates() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_plugins(BigSpaceStationaryPlugin) + .add_plugins(CellHashingPlugin::default()); + + let grid_entity = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let mut stationary_entities = Vec::new(); + for i in 0..100 { + let entity = app + .world_mut() + .spawn(( + Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), + CellCoord::new(i, 0, 0), + Stationary, + )) + .set_parent_in_place(grid_entity) + .id(); + stationary_entities.push(entity); + } + + app.update(); + + // After first frame, they all should have CellId and be in ChangedCells + { + let changed_cells = app.world().resource::>(); + assert_eq!(changed_cells.len(), 100); + } + + // Second frame - nothing should change + app.update(); + { + let changed_cells = app.world().resource::>(); + assert_eq!( + changed_cells.len(), + 0, + "No updates should happen for stationary entities after the first frame" + ); + } + + // Now move them - Transform changes, but they are Stationary so they don't recenter and don't change CellCoord + for entity in &stationary_entities { + app.world_mut() + .entity_mut(*entity) + .get_mut::() + .unwrap() + .translation + .x = 1000.0; + } + + app.update(); + { + let changed_cells = app.world().resource::>(); + assert_eq!( + changed_cells.len(), + 0, + "Stationary entities should skip updates even if their Transform changes" + ); + } + + // Manually change CellCoord for one of them - it should STILL skip updates because of Without + app.world_mut() + .entity_mut(stationary_entities[0]) + .get_mut::() + .unwrap() + .x = 500; + + app.update(); + { + let changed_cells = app.world().resource::>(); + assert_eq!( + changed_cells.len(), + 0, + "Stationary entities should skip updates even if their CellCoord changes" + ); + } +} + +/// Verifies that a [`Stationary`] entity's [`GlobalTransform`] is updated when the floating +/// origin moves to a new cell, even though the grid's dirty tick says the subtree is clean. +/// +/// This exercises the `!is_local_origin_unchanged()` override path in the pruning logic. +#[test] +fn stationary_entity_gt_updates_when_fo_moves() { + let mut app = App::new(); + // Use the stationary plugin so GridDirtyTick is active + app.add_plugins(BigSpaceMinimalPlugins) + .add_plugins(BigSpaceStationaryPlugin); + + let root = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + // FO starts at cell (0, 0, 0) + let fo = app + .world_mut() + .spawn((CellCoord::default(), FloatingOrigin)) + .set_parent_in_place(root) + .id(); + + // Stationary entity at cell (2, 0, 0) - 2 * 2000 = 4000 from the FO + let stationary = app + .world_mut() + .spawn((CellCoord::new(2, 0, 0), Stationary)) + .set_parent_in_place(root) + .id(); + + // Let the world stabilize so GridDirtyTick is in "clean" state + app.update(); // frame 1: GTs computed, GridDirtyTick inserted + app.update(); // frame 2: subtree clean + + let gt_before = app + .world() + .get::(stationary) + .unwrap() + .translation(); + assert_eq!( + gt_before, + Vec3::new(4000.0, 0.0, 0.0), + "Stationary entity should be at 4000 with FO at cell 0" + ); + + // Move the FO to cell (1, 0, 0) - now entity is only 2000 away + app.world_mut() + .entity_mut(fo) + .get_mut::() + .unwrap() + .x = 1; + app.update(); + + let gt_after = app + .world() + .get::(stationary) + .unwrap() + .translation(); + assert_eq!( + gt_after, + Vec3::new(2000.0, 0.0, 0.0), + "Stationary entity GT must update when floating origin moves, even in a clean subtree" + ); +} + +/// Verifies that a [`Stationary`] entity spawned *after* the grid has already stabilized +/// (i.e., [`GridDirtyTick`] is present and clean) still receives its initial +/// [`GlobalTransform`] on the very next frame. +/// +/// This is a regression test for the bug where newly spawned stationary entities were +/// permanently stuck at [`GlobalTransform::IDENTITY`] because `mark_dirty_subtrees` +/// excluded them from the dirty walk via `Without`. +#[test] +fn dynamically_spawned_stationary_entity_gets_gt() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_plugins(BigSpaceStationaryPlugin); + + let root = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + app.world_mut() + .spawn((CellCoord::default(), FloatingOrigin)) + .set_parent_in_place(root); + + // Let the grid settle: GridDirtyTick is inserted and the subtree becomes clean. + app.update(); // frame 1: GridDirtyTick deferred-inserted, GT computed for initial entities + app.update(); // frame 2: grid is now "clean" (no changed non-stationary entities) + + // Spawn a Stationary entity into the now-stable grid. + let late_stationary = app + .world_mut() + .spawn((CellCoord::new(1, 0, 0), Stationary)) + .set_parent_in_place(root) + .id(); + + // One more frame: mark_dirty_subtrees detects Changed on the grid and marks it dirty. + app.update(); + + let gt = app + .world() + .get::(late_stationary) + .unwrap() + .translation(); + assert_ne!( + gt, + Vec3::ZERO, + "Dynamically spawned Stationary entity must have its GT computed (not stuck at IDENTITY)" + ); + assert_eq!( + gt, + Vec3::new(2000.0, 0.0, 0.0), + "Stationary entity at CellCoord(1,0,0) should have GT = 2000 with FO at cell 0" + ); +} + +/// Verifies that [`BigSpaceStationaryPlugin`] and no plugin produce identical +/// [`GlobalTransform`]s for the same world state after several frames of activity. +/// +/// This is an equivalence/regression guard for the tree-walk rewrite: adding the +/// stationary optimization must never change the computed GT values. +#[test] +fn plugin_and_no_plugin_produce_same_gts() { + fn build_and_run(with_stationary_plugin: bool) -> (Vec3, Vec3) { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + if with_stationary_plugin { + app.add_plugins(BigSpaceStationaryPlugin); + } + + let root = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + + let fo = app + .world_mut() + .spawn((CellCoord::default(), FloatingOrigin)) + .set_parent_in_place(root) + .id(); + + let moving = app + .world_mut() + .spawn(( + CellCoord::new(1, 0, 0), + Transform::from_xyz(100.0, 0.0, 0.0), + )) + .set_parent_in_place(root) + .id(); + + let stationary = app + .world_mut() + .spawn((CellCoord::new(3, 0, 0), Stationary)) + .set_parent_in_place(root) + .id(); + + app.update(); // frame 1 + app.update(); // frame 2: grid clean + + // Move the floating origin, forcing all GTs to recompute + app.world_mut() + .entity_mut(fo) + .get_mut::() + .unwrap() + .x = 1; + + app.update(); // frame 3: FO moved + + let gt_moving = app + .world() + .get::(moving) + .unwrap() + .translation(); + let gt_stationary = app + .world() + .get::(stationary) + .unwrap() + .translation(); + (gt_moving, gt_stationary) + } + + let (gt_moving_no_plugin, gt_stationary_no_plugin) = build_and_run(false); + let (gt_moving_with_plugin, gt_stationary_with_plugin) = build_and_run(true); + + assert_eq!( + gt_moving_no_plugin, gt_moving_with_plugin, + "Moving entity GT must be identical with and without BigSpaceStationaryPlugin" + ); + assert_eq!( + gt_stationary_no_plugin, gt_stationary_with_plugin, + "Stationary entity GT must be identical with and without BigSpaceStationaryPlugin" + ); +} + +/// Verifies that a non-stationary entity changing in a nested sub-grid correctly propagates +/// dirty-marking up through ancestor grids, and that its [`GlobalTransform`] is updated. +/// +/// Exercises the `mark_dirty_subtrees` ancestor walk for deeply nested hierarchies. +#[test] +fn nested_sub_grid_entity_gt_updates_correctly() { + #[derive(Component)] + struct Marker; + + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins) + .add_plugins(BigSpaceStationaryPlugin) + .add_systems(Startup, |mut commands: Commands| { + commands.spawn_big_space_default(|root| { + root.spawn_spatial(FloatingOrigin); + // Sub-grid at (1000, 0, 0) in root. + root.with_grid_default(|sub_grid| { + sub_grid.insert(Transform::from_xyz(1000.0, 0.0, 0.0)); + // Entity inside sub-grid at (500, 0, 0) → total GT = 1000 + 500 = 1500 + sub_grid.spawn_spatial((Transform::from_xyz(500.0, 0.0, 0.0), Marker)); + }); + }); + }); + + app.update(); // frame 1: initial GTs computed + + let entity = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + + let gt_initial = app + .world() + .get::(entity) + .unwrap() + .translation(); + assert_eq!( + gt_initial, + Vec3::new(1500.0, 0.0, 0.0), + "Initial GT should be sub-grid pos + entity pos = 1500" + ); + + app.update(); // frame 2: subtree clean + + // Move the entity within the sub-grid + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .translation + .x = 600.0; + + app.update(); // frame 3: mark_dirty_subtrees must mark sub_grid AND root dirty + + let gt_after = app + .world() + .get::(entity) + .unwrap() + .translation(); + assert_eq!( + gt_after, + Vec3::new(1600.0, 0.0, 0.0), + "GT must update when entity moves inside a sub-grid: 1000 + 600 = 1600" + ); +} + +/// Verifies that [`BigSpaceStationaryPlugin`] is not accidentally included in +/// [`BigSpaceMinimalPlugins`], which would impose the optimization overhead on all users. +#[test] +fn stationary_plugin_excluded_from_minimal_plugins() { + let mut app = App::new(); + app.add_plugins(BigSpaceMinimalPlugins); + // BigSpaceStationaryPlugin registers GridDirtyTick for reflection. + // If it were accidentally included, GridDirtyTick would be registered. + // We verify by checking that no Grid entity automatically gets a GridDirtyTick + // after an update (the auto-insertion only happens when the plugin is active). + let root = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + app.update(); + assert!( + app.world().get::(root).is_none(), + "GridDirtyTick should not be auto-inserted without BigSpaceStationaryPlugin" + ); +} + +/// Verifies that [`BigSpaceStationaryPlugin`] is included in [`BigSpaceDefaultPlugins`]. +#[test] +fn stationary_plugin_included_in_default_plugins() { + use crate::plugin::BigSpaceValidationPlugin; + + let mut app = App::new(); + let group = BigSpaceDefaultPlugins.build(); + #[cfg(feature = "camera")] + let group = group.disable::(); + #[cfg(feature = "debug")] + let group = group.disable::(); + let group = group.disable::(); + app.add_plugins(group); + let root = app.world_mut().spawn(BigSpaceRootBundle::default()).id(); + app.update(); + assert!( + app.world().get::(root).is_some(), + "GridDirtyTick should be auto-inserted when BigSpaceStationaryPlugin is active via BigSpaceDefaultPlugins" + ); +} diff --git a/src/timing.rs b/src/timing.rs index 151ea87..bd29abc 100644 --- a/src/timing.rs +++ b/src/timing.rs @@ -3,6 +3,7 @@ use alloc::collections::VecDeque; use core::{iter::Sum, ops::Div, time::Duration}; +use crate::hash::SpatialHashSystems; use crate::prelude::*; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -30,7 +31,9 @@ impl Plugin for BigSpaceTimingStatsPlugin { PostUpdate, (update_totals, update_averages) .chain() - .after(TransformSystems::Propagate), + .after(TransformSystems::Propagate) + .after(SpatialHashSystems::UpdateCellLookup) + .after(SpatialHashSystems::UpdatePartitionLookup), ); } }