From 2f8f9101e5eeca9da9f41bb5b0c688c97003e7cb Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Sun, 2 Mar 2025 00:14:15 -0800 Subject: [PATCH] Partition Bounds (#39) Compute partition bounds during updates, to avoid needing to iterate over all cells in a partition every time this information is needed. --- examples/spatial_hash.rs | 209 +++++++++++++++------------ src/commands.rs | 34 +++-- src/grid/cell.rs | 24 +++- src/hash/component.rs | 46 +++--- src/hash/map.rs | 70 +++++---- src/hash/mod.rs | 4 +- src/hash/partition.rs | 296 ++++++++++++++++++++++++++------------- 7 files changed, 434 insertions(+), 249 deletions(-) diff --git a/examples/spatial_hash.rs b/examples/spatial_hash.rs index c72487f..25aee31 100644 --- a/examples/spatial_hash.rs +++ b/examples/spatial_hash.rs @@ -7,7 +7,16 @@ use bevy::{ use bevy_ecs::entity::EntityHasher; use bevy_math::DVec3; use big_space::prelude::*; -use noise::{NoiseFn, Perlin}; +use noise::{NoiseFn, Simplex}; +use smallvec::SmallVec; +use turborand::prelude::*; + +// Try bumping this up to really 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. +const MOVEMENT_SPEED: f32 = 5.0; +const PERCENT_STATIC: f32 = 0.99; fn main() { App::new() @@ -26,19 +35,11 @@ fn main() { draw_partitions.after(GridHashMapSystem::UpdatePartition), ), ) - .add_systems(Update, cursor_grab) + .add_systems(Update, (cursor_grab, spawn_spheres)) .init_resource::() .run(); } -// Try bumping this up to really stress test. I'm able to push a million entities with an M3 Max. -const N_ENTITIES: usize = 100_000; -const HALF_WIDTH: f32 = 40.0; -const CELL_WIDTH: f32 = 10.0; -// How fast the entities should move, causing them to move into neighboring cells. -const MOVEMENT_SPEED: f32 = 5.0; -const PERCENT_STATIC: f32 = 0.9; - #[derive(Component)] struct Player; @@ -50,25 +51,35 @@ struct MaterialPresets { default: Handle, highlight: Handle, flood: Handle, + sphere: Handle, } impl FromWorld for MaterialPresets { fn from_world(world: &mut World) -> Self { let mut materials = world.resource_mut::>(); - let d: StandardMaterial = StandardMaterial { + let default = materials.add(StandardMaterial { base_color: Color::from(Srgba::new(0.5, 0.5, 0.5, 1.0)), perceptual_roughness: 0.2, metallic: 0.0, ..Default::default() - }; - let h: StandardMaterial = Color::from(Srgba::new(2.0, 0.0, 8.0, 1.0)).into(); - let f: StandardMaterial = Color::from(Srgba::new(1.1, 0.1, 1.0, 1.0)).into(); + }); + let highlight = materials.add(Color::from(Srgba::new(2.0, 0.0, 8.0, 1.0))); + let flood = materials.add(Color::from(Srgba::new(1.1, 0.1, 1.0, 1.0))); + + let mut meshes = world.resource_mut::>(); + let sphere = meshes.add( + Sphere::new(HALF_WIDTH / (1_000_000_f32).powf(0.33) * 0.5) + .mesh() + .ico(0) + .unwrap(), + ); Self { - default: materials.add(d), - highlight: materials.add(h), - flood: materials.add(f), + default, + highlight, + flood, + sphere, } } } @@ -79,7 +90,7 @@ fn draw_partitions( grids: Query<(&GlobalTransform, &Grid)>, camera: Query<&GridHash, With>, ) { - for (id, p) in partitions.iter() { + for (id, p) in partitions.iter().take(10_000) { let Ok((transform, grid)) = grids.get(p.grid()) else { return; }; @@ -92,35 +103,19 @@ fn draw_partitions( p.iter() .filter(|hash| *hash != camera.single()) + .take(1_000) .for_each(|h| { let center = [h.cell().x, h.cell().y, h.cell().z]; let local_trans = Transform::from_translation(IVec3::from(center).as_vec3() * l) .with_scale(Vec3::splat(l)); gizmos.cuboid( transform.mul_transform(local_trans), - Hsla::new(hue, 1.0, 0.5, 0.2), + Hsla::new(hue, 1.0, 0.5, 0.05), ); }); - let Some(min) = p - .iter() - .filter(|hash| *hash != camera.single()) - .map(|h| [h.cell().x, h.cell().y, h.cell().z]) - .reduce(|[ax, ay, az], [ix, iy, iz]| [ax.min(ix), ay.min(iy), az.min(iz)]) - .map(|v| IVec3::from(v).as_vec3() * l) - else { - continue; - }; - - let Some(max) = p - .iter() - .filter(|hash| *hash != camera.single()) - .map(|h| [h.cell().x, h.cell().y, h.cell().z]) - .reduce(|[ax, ay, az], [ix, iy, iz]| [ax.max(ix), ay.max(iy), az.max(iz)]) - .map(|v| IVec3::from(v).as_vec3() * l) - else { - continue; - }; + let min = IVec3::from([p.min().x, p.min().y, p.min().z]).as_vec3() * l; + let max = IVec3::from([p.max().x, p.max().y, p.max().z]).as_vec3() * l; let size = max - min; let center = min + (size) * 0.5; @@ -152,6 +147,7 @@ fn move_player( hash_stats: Res>, prop_stats: Res>, ) { + let n_entities = non_player.iter().len(); for neighbor in neighbors.iter() { if let Ok(mut material) = materials.get_mut(*neighbor) { **material = material_presets.default.clone_weak(); @@ -163,10 +159,12 @@ fn move_player( if scale.abs() > 0.0 { // Avoid change detection for (i, (mut transform, _, _)) in non_player.iter_mut().enumerate() { - if i > (PERCENT_STATIC * N_ENTITIES as f32) as usize { + 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; } } } @@ -213,6 +211,10 @@ fn move_player( let mut text = text.single_mut(); text.0 = format!( "\ +Controls: +WASD to move, QE to roll +F to spawn 1,000, G to double + Population: {: >8} Entities Transform Propagation @@ -229,7 +231,15 @@ Update Maps: {: >16.1?} Update Partitions: {: >10.1?} Total: {: >22.1?}", - N_ENTITIES, + n_entities + .to_string() + .as_bytes() + .rchunks(3) + .rev() + .map(std::str::from_utf8) + .collect::, _>>() + .unwrap() + .join(","), // prop_stats.avg().grid_recentering(), prop_stats.avg().low_precision_root_tagging(), @@ -246,36 +256,7 @@ Total: {: >22.1?}", ); } -fn spawn( - mut commands: Commands, - mut materials: ResMut>, - mut meshes: ResMut>, - material_presets: Res, -) { - use turborand::prelude::*; - let rng = Rng::with_seed(342525); - let noise = Perlin::new(345612); - - let rng = || loop { - let noise_scale = 5.0; - let threshold = 0.70; - 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; - } - }; - - let values: Vec<_> = std::iter::repeat_with(rng).take(N_ENTITIES).collect(); - - let sphere_mesh_lq = meshes.add( - Sphere::new(HALF_WIDTH / (N_ENTITIES as f32).powf(0.33) * 0.2) - .mesh() - .ico(0) - .unwrap(), - ); - +fn spawn(mut commands: Commands) { commands.spawn_big_space::(Grid::new(CELL_WIDTH, 0.0), |root| { root.spawn_spatial(( FloatingOrigin, @@ -298,32 +279,84 @@ fn spawn( b.spawn(DirectionalLight::default()); }); - for (i, value) in values.iter().enumerate() { - let mut sphere_builder = root.spawn((BigSpatialBundle:: { - transform: Transform::from_xyz(value.x, value.y, value.z), - ..default() - },)); - if i == 0 { - sphere_builder.insert(( - Player, - Mesh3d(meshes.add(Sphere::new(1.0))), - MeshMaterial3d(materials.add(Color::from(Srgba::new(20.0, 20.0, 0.0, 1.0)))), - Transform::from_scale(Vec3::splat(2.0)), - )); - } else { - sphere_builder.insert(( + root.spawn_spatial(Player); + }); +} + +fn spawn_spheres( + mut commands: Commands, + input: Res>, + material_presets: Res, + mut grid: Query<(Entity, &Grid, &mut Children)>, + non_players: Query<(), With>, +) { + let n_entities = non_players.iter().len().max(1); + let n_spawn = if input.pressed(KeyCode::KeyG) { + n_entities + } else if input.pressed(KeyCode::KeyF) { + 1_000 + } else { + return; + }; + + let (entity, _grid, mut children) = grid.single_mut(); + let mut dyn_parent = bevy_reflect::DynamicTupleStruct::default(); + dyn_parent.insert(entity); + let dyn_parent = dyn_parent.as_partial_reflect(); + + let new_children = sample_noise(n_spawn, &Simplex::new(345612), &Rng::new()) + .map(|value| { + let hash = GridHash::::__new_manual(entity, &GridCell::default()); + commands + .spawn(( + Transform::from_xyz(value.x, value.y, value.z), + GlobalTransform::default(), + GridCell::::default(), + FastGridHash::from(hash), + hash, NonPlayer, - Mesh3d(sphere_mesh_lq.clone()), + Parent::from_reflect(dyn_parent).unwrap(), + Mesh3d(material_presets.sphere.clone_weak()), MeshMaterial3d(material_presets.default.clone_weak()), bevy_render::view::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_render::view::NoFrustumCulling, + )) + .id() + }) + .chain(children.iter().copied()) + .collect::>(); + + let mut dyn_children = bevy_reflect::DynamicTupleStruct::default(); + dyn_children.insert(new_children); + let dyn_children = dyn_children.as_partial_reflect(); + + *children = Children::from_reflect(dyn_children).unwrap(); +} + +#[inline] +fn sample_noise<'a, T: NoiseFn>( + n_entities: usize, + noise: &'a T, + rng: &'a Rng, +) -> impl Iterator + use<'a, T> { + std::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 + ) + .take(n_entities) } fn setup_ui(mut commands: Commands, asset_server: Res) { diff --git a/src/commands.rs b/src/commands.rs index 5a0ab12..3eff910 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -21,6 +21,10 @@ pub trait BigSpaceCommands { &mut self, child_builder: impl FnOnce(&mut GridCommands

), ); + + /// Access the [`GridCommands`] of an entity by passing in the [`Entity`] and [`Grid`]. Note + /// that the value of `grid` will be inserted in this entity when the command is applied. + fn grid(&mut self, entity: Entity, grid: Grid

) -> GridCommands

; } impl BigSpaceCommands for Commands<'_, '_> { @@ -45,6 +49,15 @@ impl BigSpaceCommands for Commands<'_, '_> { ) { self.spawn_big_space(Grid::default(), child_builder); } + + fn grid(&mut self, entity: Entity, grid: Grid

) -> GridCommands

{ + GridCommands { + entity, + commands: self.reborrow(), + grid, + children: Default::default(), + } + } } /// Build [`big_space`](crate) hierarchies more easily, with access to grids. @@ -68,6 +81,7 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { } /// Spawn an entity in this grid. + #[inline] pub fn spawn(&mut self, bundle: impl Bundle) -> SpatialEntityCommands

{ let entity = self.commands.spawn(bundle).id(); self.children.push(entity); @@ -80,9 +94,9 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and insert the provided /// bundle. + #[inline] pub fn spawn_spatial(&mut self, bundle: impl Bundle) -> SpatialEntityCommands

{ let entity = self - .commands .spawn(( #[cfg(feature = "bevy_render")] bevy_render::view::Visibility::default(), @@ -92,8 +106,6 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { .insert(bundle) .id(); - self.children.push(entity); - SpatialEntityCommands { entity, commands: self.commands.reborrow(), @@ -101,7 +113,8 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { } } - /// Returns the [`Entity``] id of the entity. + /// Returns the [`Entity`] id of the entity. + #[inline] pub fn id(&self) -> Entity { self.entity } @@ -109,6 +122,7 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and apply entity commands /// to it via the closure. This allows you to insert bundles on this new spatial entities, and /// add more children to it. + #[inline] pub fn with_spatial( &mut self, spatial: impl FnOnce(&mut SpatialEntityCommands

), @@ -120,6 +134,7 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and apply entity commands /// to it via the closure. This allows you to insert bundles on this new spatial entities, and /// add more children to it. + #[inline] pub fn with_grid( &mut self, new_grid: Grid

, @@ -130,16 +145,15 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { } /// Same as [`Self::with_grid`], but using the default [`Grid`] value. + #[inline] pub fn with_grid_default(&mut self, builder: impl FnOnce(&mut GridCommands

)) -> &mut Self { self.with_grid(Grid::default(), builder) } /// Spawn a grid as a child of the current grid. + #[inline] pub fn spawn_grid(&mut self, new_grid: Grid

, bundle: impl Bundle) -> GridCommands

{ - let mut entity_commands = self.commands.entity(self.entity); - let mut commands = entity_commands.commands(); - - let entity = commands + let entity = self .spawn(( #[cfg(feature = "bevy_render")] bevy_render::view::Visibility::default(), @@ -150,8 +164,6 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { .insert(bundle) .id(); - self.children.push(entity); - GridCommands { entity, commands: self.commands.reborrow(), @@ -166,11 +178,13 @@ impl<'a, P: GridPrecision> GridCommands<'a, P> { } /// Access the underlying commands. + #[inline] pub fn commands(&mut self) -> &mut Commands<'a, 'a> { &mut self.commands } /// Spawns the passed bundle which provides this grid, and adds it to this entity as a child. + #[inline] pub fn with_child(&mut self, bundle: B) -> &mut Self { self.commands.entity(self.entity).with_child(bundle); self diff --git a/src/grid/cell.rs b/src/grid/cell.rs index 7953dbf..67a7070 100644 --- a/src/grid/cell.rs +++ b/src/grid/cell.rs @@ -36,7 +36,7 @@ pub struct GridCellAny; /// /// [`BigSpace`]s are only allowed to have a single type of `GridCell`, you cannot mix /// [`GridPrecision`]s. -#[derive(Component, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Reflect)] +#[derive(Component, Default, Debug, PartialEq, Eq, Clone, Copy, Hash, Reflect)] #[reflect(Component, Default, PartialEq)] #[require(Transform, GlobalTransform)] #[component(storage = "Table", on_add = Self::on_add, on_remove = Self::on_remove)] @@ -87,6 +87,28 @@ impl GridCell

{ } } + /// Returns a cell containing the minimum values for each element of self and rhs. + /// + /// In other words this computes [self.x.min(rhs.x), self.y.min(rhs.y), ..]. + pub fn min(&self, rhs: Self) -> Self { + Self { + x: self.x.min(rhs.x), + y: self.y.min(rhs.y), + z: self.z.min(rhs.z), + } + } + + /// Returns a cell containing the maximum values for each element of self and rhs. + /// + /// In other words this computes [self.x.max(rhs.x), self.y.max(rhs.y), ..]. + pub fn max(&self, rhs: Self) -> Self { + Self { + x: self.x.max(rhs.x), + y: self.y.max(rhs.y), + z: self.z.max(rhs.z), + } + } + /// If an entity's transform translation becomes larger than the limit specified in its /// [`Grid`], it will be relocated to the nearest grid cell to reduce the size of the transform. pub fn recenter_large_transforms( diff --git a/src/hash/component.rs b/src/hash/component.rs index 0cc91ae..d058409 100644 --- a/src/hash/component.rs +++ b/src/hash/component.rs @@ -34,6 +34,12 @@ impl PartialEq> for FastGridHash { } } +impl From> for FastGridHash { + fn from(value: GridHash

) -> Self { + Self(value.pre_hash) + } +} + /// A unique spatial hash shared by all entities in the same [`GridCell`] within the same [`Grid`]. /// /// Once computed, a spatial hash can be used to rapidly check if any two entities are in the same @@ -153,52 +159,48 @@ impl GridHash

{ pub(super) fn update( mut commands: Commands, mut changed_hashes: ResMut>, - mut spatial_entities: ParamSet<( - Query< - ( - Entity, - &Parent, - &GridCell

, - &mut GridHash

, - &mut FastGridHash, - ), - (F, Or<(Changed, Changed>)>), - >, - Query<(Entity, &Parent, &GridCell

), (F, Without>)>, - )>, + mut spatial_entities: Query< + ( + Entity, + &Parent, + &GridCell

, + &mut GridHash

, + &mut FastGridHash, + ), + (F, Or<(Changed, Changed>)>), + >, + added_entities: Query<(Entity, &Parent, &GridCell

), (F, Without>)>, mut stats: Option>, - mut thread_changed_hashes: Local>>, + mut thread_updated_hashes: Local>>, mut thread_commands: Local, FastGridHash)>>>, ) { let start = Instant::now(); // Create new - spatial_entities - .p1() + added_entities .par_iter() .for_each(|(entity, parent, cell)| { let spatial_hash = GridHash::new(parent, cell); - let fast_hash = FastGridHash(spatial_hash.pre_hash); + let fast_hash = spatial_hash.into(); thread_commands.scope(|tl| tl.push((entity, spatial_hash, fast_hash))); - thread_changed_hashes.scope(|tl| tl.push(entity)); + thread_updated_hashes.scope(|tl| tl.push(entity)); }); for (entity, spatial_hash, fast_hash) in thread_commands.drain() { commands.entity(entity).insert((spatial_hash, fast_hash)); } // Update existing - spatial_entities.p0().par_iter_mut().for_each( + spatial_entities.par_iter_mut().for_each( |(entity, parent, cell, mut hash, mut fast_hash)| { let new_hash = GridHash::new(parent, cell); let new_fast_hash = new_hash.pre_hash; if hash.replace_if_neq(new_hash).is_some() { - thread_changed_hashes.scope(|tl| tl.push(entity)); + thread_updated_hashes.scope(|tl| tl.push(entity)); } fast_hash.0 = new_fast_hash; }, ); - - changed_hashes.list.extend(thread_changed_hashes.drain()); + thread_updated_hashes.drain_into(&mut changed_hashes.updated); if let Some(ref mut stats) = stats { stats.hash_update_duration += start.elapsed(); diff --git a/src/hash/map.rs b/src/hash/map.rs index 1471e76..87c7265 100644 --- a/src/hash/map.rs +++ b/src/hash/map.rs @@ -116,7 +116,11 @@ where } } -impl GridHashMap { +impl GridHashMap +where + P: GridPrecision, + F: GridHashMapFilter, +{ /// Get information about all entities located at this [`GridHash`], as well as its /// neighbors. #[inline] @@ -243,7 +247,11 @@ impl GridHashMap { } /// Private Systems -impl GridHashMap { +impl GridHashMap +where + P: GridPrecision, + F: GridHashMapFilter, +{ /// Update the [`GridHashMap`] with entities that have changed [`GridHash`]es, and meet the /// optional [`GridHashMapFilter`]. pub(super) fn update( @@ -263,12 +271,12 @@ impl GridHashMap { } if let Some(ref mut stats) = stats { - stats.moved_entities = changed_hashes.list.len(); + stats.moved_entities = changed_hashes.updated.len(); } // See the docs on ChangedGridHash understand why we don't use query change detection. for (entity, spatial_hash) in changed_hashes - .list + .updated .drain(..) .filter_map(|entity| all_hashes.get(entity).ok()) { @@ -282,7 +290,11 @@ impl GridHashMap { } /// Private Methods -impl GridHashMap { +impl GridHashMap +where + P: GridPrecision, + F: GridHashMapFilter, +{ /// Insert an entity into the [`GridHashMap`], updating any existing entries. #[inline] fn insert(&mut self, entity: Entity, hash: GridHash

) { @@ -361,31 +373,35 @@ impl InnerGridHashMap

{ } else { let mut entities = self.hash_set_pool.pop().unwrap_or_default(); entities.insert(entity); + self.insert_entry(hash, entities); + } + } - let mut occupied_neighbors = self.neighbor_pool.pop().unwrap_or_default(); - occupied_neighbors.extend(hash.adjacent(1).filter(|neighbor| { - self.inner - .get_mut(neighbor) - .map(|entry| { - entry.occupied_neighbors.push(hash); - true - }) - .unwrap_or_default() - })); + #[inline] + fn insert_entry(&mut self, hash: GridHash

, entities: HashSet) { + let mut occupied_neighbors = self.neighbor_pool.pop().unwrap_or_default(); + occupied_neighbors.extend(hash.adjacent(1).filter(|neighbor| { + self.inner + .get_mut(neighbor) + .map(|entry| { + entry.occupied_neighbors.push(hash); + true + }) + .unwrap_or_default() + })); - self.inner.insert( - hash, - GridHashEntry { - entities, - occupied_neighbors, - }, - ); + self.inner.insert( + hash, + GridHashEntry { + entities, + occupied_neighbors, + }, + ); - if !self.just_removed.remove(&hash) { - // If a cell is removed then added within the same update, it can't be considered - // "just added" because it *already existed* at the start of the update. - self.just_inserted.insert(hash); - } + if !self.just_removed.remove(&hash) { + // If a cell is removed then added within the same update, it can't be considered + // "just added" because it *already existed* at the start of the update. + self.just_inserted.insert(hash); } } diff --git a/src/hash/mod.rs b/src/hash/mod.rs index 67e2256..f4006b8 100644 --- a/src/hash/mod.rs +++ b/src/hash/mod.rs @@ -92,14 +92,14 @@ impl GridHashMapFilter for T {} /// react to a component being mutated. For now, this performs well enough. #[derive(Resource)] struct ChangedGridHashes { - list: Vec, + updated: Vec, spooky: PhantomData<(P, F)>, } impl Default for ChangedGridHashes { fn default() -> Self { Self { - list: Vec::new(), + updated: Vec::new(), spooky: PhantomData, } } diff --git a/src/hash/partition.rs b/src/hash/partition.rs index 2d0e96f..794f39a 100644 --- a/src/hash/partition.rs +++ b/src/hash/partition.rs @@ -1,16 +1,18 @@ //! Detect and update groups of nearby occupied cells. -use std::{hash::Hash, marker::PhantomData, ops::Deref, time::Instant}; +use std::{hash::Hash, marker::PhantomData, ops::Deref}; use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_tasks::{ComputeTaskPool, ParallelSliceMut}; use bevy_utils::{ hashbrown::{HashMap, HashSet}, - PassHash, + Instant, PassHash, }; -use super::{GridHash, GridHashMap, GridHashMapFilter, GridHashMapSystem, GridPrecision}; +use super::{GridCell, GridHash, GridHashMap, GridHashMapFilter, GridHashMapSystem, GridPrecision}; + +pub use private::GridPartition; /// Adds support for spatial partitioning. Requires [`GridHashPlugin`](super::GridHashPlugin). pub struct GridPartitionPlugin(PhantomData<(P, F)>) @@ -61,7 +63,7 @@ impl Hash for GridPartitionId { } } -/// Groups connected [`GridCell`](crate::GridCell)s into [`GridPartition`]s. +/// Groups connected [`GridCell`]s into [`GridPartition`]s. /// /// Partitions divide space into independent groups of cells. /// @@ -135,15 +137,16 @@ where let Some(hash) = set.iter().next() else { return; }; + let mut min = hash.cell(); + let mut max = hash.cell(); for hash in set.iter() { self.reverse_map.insert(*hash, partition); + min = min.min(hash.cell()); + max = max.max(hash.cell()); } self.partitions.insert( partition, - GridPartition { - grid: hash.grid(), - tables: vec![set], - }, + GridPartition::new(hash.grid(), vec![set], min, max), ); } @@ -162,8 +165,14 @@ where let Some(old_id) = self.reverse_map.remove(hash) else { return; }; + let mut empty = false; if let Some(partition) = self.partitions.get_mut(&old_id) { - partition.tables.iter_mut().any(|table| table.remove(hash)); + if partition.remove(hash) && partition.is_empty() { + empty = true; + } + } + if empty { + self.partitions.remove(&old_id); } } @@ -252,12 +261,6 @@ where partition_map.remove(removed_cell); } - // Clean up empty tables and partitions - partition_map.partitions.retain(|_id, partition| { - partition.tables.retain(|table| !table.is_empty()); - !partition.tables.is_empty() - }); - for removed_cell in hash_grid.just_removed().iter() { // Group occupied neighbor cells by partition, so we can check if they are still // connected to each other after this removal. @@ -293,7 +296,7 @@ where let _task_span = tracing::info_span!("parallel partition split").entered(); affected_cells .iter_mut() - .filter_map(|(original_partition, adjacent_hashes)| { + .filter_map(|(id, adjacent_hashes)| { let mut new_partitions = Vec::with_capacity(0); let mut counter = 0; while let Some(this_cell) = adjacent_hashes.iter().next().copied() { @@ -309,7 +312,7 @@ where if adjacent_hashes.is_empty() && counter == 0 { // If it only took a single iteration to connect all affected cells, // it means the partition has not been split, and we can continue to - // the next // partition. + // the next partition. return None; } else { new_partitions @@ -319,7 +322,7 @@ where } Some(SplitResult { - original_partition: *original_partition, + original_partition_id: *id, new_partitions, }) }) @@ -328,27 +331,15 @@ where ); for SplitResult { - original_partition, + original_partition_id, ref mut new_partitions, } in split_results.iter_mut().flatten() { // We want the original partition to retain the most cells to ensure that the smaller // sets are the ones that are assigned a new partition ID. - new_partitions.sort_unstable_by_key(|v| v.len()); - if let Some(partition) = new_partitions.pop() { - if let Some(tables) = partition_map - .partitions - .get_mut(original_partition) - .map(|p| &mut p.tables) - { - // TODO: keep these in an object pool to reuse allocs - tables.drain(1..); - if let Some(table) = tables.get_mut(0) { - *table = partition; - } else { - tables.push(partition); - } - } + new_partitions.sort_unstable_by_key(|set| set.len()); + if let Some(largest_partition) = new_partitions.pop() { + partition_map.insert(*original_partition_id, largest_partition); } // At this point the reverse map will be out of date. However, `partitions.insert()` @@ -363,86 +354,193 @@ where } struct SplitResult { - original_partition: GridPartitionId, + original_partition_id: GridPartitionId, new_partitions: Vec, PassHash>>, } -/// A group of nearby [`GridCell`](crate::GridCell)s in an island disconnected from all other -/// [`GridCell`](crate::GridCell)s. -#[derive(Debug)] -pub struct GridPartition { - grid: Entity, - tables: Vec, PassHash>>, -} -impl GridPartition

{ - /// Tables smaller than this will be drained into other tables when merging. Tables larger than - /// this limit will instead be added to a list of tables. This prevents partitions ending up - /// with many tables containing a few entries. - /// - /// Draining and extending a hash set is much slower than moving the entire hash set into a - /// list. The tradeoff is that the more tables added, the more there are that need to be - /// iterated over when searching for a cell. - const MIN_TABLE_SIZE: usize = 128; - - /// Returns `true` if the `hash` is in this partition. - #[inline] - pub fn contains(&self, hash: &GridHash

) -> bool { - self.tables.iter().any(|table| table.contains(hash)) +/// A private module to ensure the internal fields of the partition are not accessed directly. +/// Needed to ensure invariants are upheld. +mod private { + use super::{GridCell, GridHash, GridPrecision}; + use bevy_ecs::prelude::*; + use bevy_utils::{hashbrown::HashSet, PassHash}; + /// A group of nearby [`GridCell`](crate::GridCell)s in an island disconnected from all other + /// [`GridCell`](crate::GridCell)s. + #[derive(Debug)] + pub struct GridPartition { + grid: Entity, + tables: Vec, PassHash>>, + min: GridCell

, + max: GridCell

, } - /// Iterates over all [`GridHash`]s in this partition. - #[inline] - pub fn iter(&self) -> impl Iterator> { - self.tables.iter().flat_map(|table| table.iter()) - } - - /// Returns the total number of cells in this partition. - #[inline] - pub fn num_cells(&self) -> usize { - self.tables.iter().map(|t| t.len()).sum() - } - - #[inline] - fn insert(&mut self, cell: GridHash

) { - if self.contains(&cell) { - return; + impl GridPartition

{ + /// Returns `true` if the `hash` is in this partition. + #[inline] + pub fn contains(&self, hash: &GridHash

) -> bool { + self.tables.iter().any(|table| table.contains(hash)) } - if let Some(i) = self.smallest_table() { - self.tables[i].insert(cell); - } else { - let mut table = HashSet::default(); - table.insert(cell); - self.tables.push(table); + + /// Iterates over all [`GridHash`]s in this partition. + #[inline] + pub fn iter(&self) -> impl Iterator> { + self.tables.iter().flat_map(|table| table.iter()) + } + + /// Returns the total number of cells in this partition. + #[inline] + pub fn num_cells(&self) -> usize { + self.tables.iter().map(|t| t.len()).sum() + } + + /// The grid this partition resides in. + #[inline] + pub fn grid(&self) -> Entity { + self.grid + } + + /// The maximum grid cell extent of the partition. + pub fn max(&self) -> GridCell

{ + self.max + } + + /// The minimum grid cell extent of the partition. + pub fn min(&self) -> GridCell

{ + self.min + } + + /// Frees up any unused memory. Returns `false` if the partition is completely empty. + pub fn is_empty(&self) -> bool { + self.tables.is_empty() } } - #[inline] - fn smallest_table(&self) -> Option { - self.tables - .iter() - .enumerate() - .map(|(i, t)| (i, t.len())) - .min_by_key(|(_, len)| *len) - .map(|(i, _len)| i) - } + /// Private internal methods + impl GridPartition

{ + pub(crate) fn new( + grid: Entity, + tables: Vec, PassHash>>, + min: GridCell

, + max: GridCell

, + ) -> Self { + Self { + grid, + tables, + min, + max, + } + } - #[inline] - fn extend(&mut self, mut partition: GridPartition

) { - for mut table in partition.tables.drain(..) { - if table.len() < Self::MIN_TABLE_SIZE { - if let Some(i) = self.smallest_table() { - self.tables[i].extend(table.drain()); + /// Tables smaller than this will be drained into other tables when merging. Tables larger than + /// this limit will instead be added to a list of tables. This prevents partitions ending up + /// with many tables containing a few entries. + /// + /// Draining and extending a hash set is much slower than moving the entire hash set into a + /// list. The tradeoff is that the more tables added, the more there are that need to be + /// iterated over when searching for a cell. + const MIN_TABLE_SIZE: usize = 20_000; + + #[inline] + pub(crate) fn insert(&mut self, cell: GridHash

) { + if self.contains(&cell) { + return; + } + if let Some(i) = self.smallest_table() { + self.tables[i].insert(cell); + } else { + let mut table = HashSet::default(); + table.insert(cell); + self.tables.push(table); + } + self.min = self.min.min(cell.cell()); + self.max = self.max.max(cell.cell()); + } + + #[inline] + fn smallest_table(&self) -> Option { + self.tables + .iter() + .enumerate() + .map(|(i, t)| (i, t.len())) + .min_by_key(|(_, len)| *len) + .map(|(i, _len)| i) + } + + #[inline] + pub(crate) fn extend(&mut self, mut partition: GridPartition

) { + for mut table in partition.tables.drain(..) { + if table.len() < Self::MIN_TABLE_SIZE { + if let Some(i) = self.smallest_table() { + for hash in table.drain() { + self.tables[i].insert_unique_unchecked(hash); + } + } else { + self.tables.push(table); + } } else { self.tables.push(table); } + } + self.min = self.min.min(partition.min); + self.max = self.max.max(partition.max); + } + + /// Removes a grid hash from the partition. Returns whether the value was present. + #[inline] + pub(crate) fn remove(&mut self, hash: &GridHash

) -> bool { + let Some(i_table) = self + .tables + .iter_mut() + .enumerate() + .find_map(|(i, table)| table.remove(hash).then_some(i)) + else { + return false; + }; + if self.tables[i_table].is_empty() { + self.tables.swap_remove(i_table); + } + + let (cell, min, max) = (hash.cell(), self.min, self.max); + // Only need to recompute the bounds if the removed cell was touching the boundary. + if min.x == cell.x || min.y == cell.y || min.z == cell.z { + self.compute_min(); + } + // Note this is not an `else if`. The cell might be on the max bound in one axis, and the + // min bound in another. + if max.x == cell.x || max.y == cell.y || max.z == cell.z { + self.compute_max(); + } + true + } + + /// Computes the minimum bounding coordinate. Requires linearly scanning over entries in the + /// partition. + #[inline] + fn compute_min(&mut self) { + if let Some(min) = self + .iter() + .map(|hash| hash.cell()) + .reduce(|acc, e| acc.min(e)) + { + self.min = min } else { - self.tables.push(table); + self.min = GridCell::ONE * P::from_f64(1e10); + } + } + + /// Computes the maximum bounding coordinate. Requires linearly scanning over entries in the + /// partition. + #[inline] + fn compute_max(&mut self) { + if let Some(max) = self + .iter() + .map(|hash| hash.cell()) + .reduce(|acc, e| acc.max(e)) + { + self.max = max + } else { + self.min = GridCell::ONE * P::from_f64(-1e10); } } } - - /// The grid this partition resides in. - pub fn grid(&self) -> Entity { - self.grid - } }