mirror of
https://github.com/eliasstepanik/big_space_with_trim.git
synced 2026-01-10 08:48:28 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
2a1cb54e63
@ -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::<MaterialPresets>()
|
||||
.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<StandardMaterial>,
|
||||
highlight: Handle<StandardMaterial>,
|
||||
flood: Handle<StandardMaterial>,
|
||||
sphere: Handle<Mesh>,
|
||||
}
|
||||
|
||||
impl FromWorld for MaterialPresets {
|
||||
fn from_world(world: &mut World) -> Self {
|
||||
let mut materials = world.resource_mut::<Assets<StandardMaterial>>();
|
||||
|
||||
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::<Assets<Mesh>>();
|
||||
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<i32>)>,
|
||||
camera: Query<&GridHash<i32>, With<Camera>>,
|
||||
) {
|
||||
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<big_space::timing::SmoothedStat<big_space::timing::GridHashStats>>,
|
||||
prop_stats: Res<big_space::timing::SmoothedStat<big_space::timing::PropagationStats>>,
|
||||
) {
|
||||
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::<Result<Vec<&str>, _>>()
|
||||
.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<Assets<StandardMaterial>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
material_presets: Res<MaterialPresets>,
|
||||
) {
|
||||
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::<i32>(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::<i32> {
|
||||
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<ButtonInput<KeyCode>>,
|
||||
material_presets: Res<MaterialPresets>,
|
||||
mut grid: Query<(Entity, &Grid<i32>, &mut Children)>,
|
||||
non_players: Query<(), With<NonPlayer>>,
|
||||
) {
|
||||
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::<i32>::__new_manual(entity, &GridCell::default());
|
||||
commands
|
||||
.spawn((
|
||||
Transform::from_xyz(value.x, value.y, value.z),
|
||||
GlobalTransform::default(),
|
||||
GridCell::<i32>::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::<SmallVec<[Entity; 8]>>();
|
||||
|
||||
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<f64, 3>>(
|
||||
n_entities: usize,
|
||||
noise: &'a T,
|
||||
rng: &'a Rng,
|
||||
) -> impl Iterator<Item = Vec3> + 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<AssetServer>) {
|
||||
|
||||
@ -21,6 +21,10 @@ pub trait BigSpaceCommands {
|
||||
&mut self,
|
||||
child_builder: impl FnOnce(&mut GridCommands<P>),
|
||||
);
|
||||
|
||||
/// 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<P: GridPrecision>(&mut self, entity: Entity, grid: Grid<P>) -> GridCommands<P>;
|
||||
}
|
||||
|
||||
impl BigSpaceCommands for Commands<'_, '_> {
|
||||
@ -45,6 +49,15 @@ impl BigSpaceCommands for Commands<'_, '_> {
|
||||
) {
|
||||
self.spawn_big_space(Grid::default(), child_builder);
|
||||
}
|
||||
|
||||
fn grid<P: GridPrecision>(&mut self, entity: Entity, grid: Grid<P>) -> GridCommands<P> {
|
||||
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<P> {
|
||||
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<P> {
|
||||
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<P>),
|
||||
@ -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<P>,
|
||||
@ -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<P>)) -> &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<P>, bundle: impl Bundle) -> GridCommands<P> {
|
||||
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<B: Bundle>(&mut self, bundle: B) -> &mut Self {
|
||||
self.commands.entity(self.entity).with_child(bundle);
|
||||
self
|
||||
|
||||
@ -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<P: GridPrecision> GridCell<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@ -34,6 +34,12 @@ impl<P: GridPrecision> PartialEq<GridHash<P>> for FastGridHash {
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: GridPrecision> From<GridHash<P>> for FastGridHash {
|
||||
fn from(value: GridHash<P>) -> 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<P: GridPrecision> GridHash<P> {
|
||||
pub(super) fn update<F: GridHashMapFilter>(
|
||||
mut commands: Commands,
|
||||
mut changed_hashes: ResMut<ChangedGridHashes<P, F>>,
|
||||
mut spatial_entities: ParamSet<(
|
||||
Query<
|
||||
(
|
||||
Entity,
|
||||
&Parent,
|
||||
&GridCell<P>,
|
||||
&mut GridHash<P>,
|
||||
&mut FastGridHash,
|
||||
),
|
||||
(F, Or<(Changed<Parent>, Changed<GridCell<P>>)>),
|
||||
>,
|
||||
Query<(Entity, &Parent, &GridCell<P>), (F, Without<GridHash<P>>)>,
|
||||
)>,
|
||||
mut spatial_entities: Query<
|
||||
(
|
||||
Entity,
|
||||
&Parent,
|
||||
&GridCell<P>,
|
||||
&mut GridHash<P>,
|
||||
&mut FastGridHash,
|
||||
),
|
||||
(F, Or<(Changed<Parent>, Changed<GridCell<P>>)>),
|
||||
>,
|
||||
added_entities: Query<(Entity, &Parent, &GridCell<P>), (F, Without<GridHash<P>>)>,
|
||||
mut stats: Option<ResMut<crate::timing::GridHashStats>>,
|
||||
mut thread_changed_hashes: Local<Parallel<Vec<Entity>>>,
|
||||
mut thread_updated_hashes: Local<Parallel<Vec<Entity>>>,
|
||||
mut thread_commands: Local<Parallel<Vec<(Entity, GridHash<P>, 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();
|
||||
|
||||
@ -116,7 +116,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
impl<P, F> GridHashMap<P, F>
|
||||
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<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
}
|
||||
|
||||
/// Private Systems
|
||||
impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
impl<P, F> GridHashMap<P, F>
|
||||
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<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
}
|
||||
|
||||
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<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
}
|
||||
|
||||
/// Private Methods
|
||||
impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
|
||||
impl<P, F> GridHashMap<P, F>
|
||||
where
|
||||
P: GridPrecision,
|
||||
F: GridHashMapFilter,
|
||||
{
|
||||
/// Insert an entity into the [`GridHashMap`], updating any existing entries.
|
||||
#[inline]
|
||||
fn insert(&mut self, entity: Entity, hash: GridHash<P>) {
|
||||
@ -361,31 +373,35 @@ impl<P: GridPrecision> InnerGridHashMap<P> {
|
||||
} 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<P>, entities: HashSet<Entity, EntityHash>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -92,14 +92,14 @@ impl<T: QueryFilter + Send + Sync + 'static> GridHashMapFilter for T {}
|
||||
/// react to a component being mutated. For now, this performs well enough.
|
||||
#[derive(Resource)]
|
||||
struct ChangedGridHashes<P: GridPrecision, F: GridHashMapFilter> {
|
||||
list: Vec<Entity>,
|
||||
updated: Vec<Entity>,
|
||||
spooky: PhantomData<(P, F)>,
|
||||
}
|
||||
|
||||
impl<P: GridPrecision, F: GridHashMapFilter> Default for ChangedGridHashes<P, F> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
list: Vec::new(),
|
||||
updated: Vec::new(),
|
||||
spooky: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<P, F = ()>(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<P: GridPrecision> {
|
||||
original_partition: GridPartitionId,
|
||||
original_partition_id: GridPartitionId,
|
||||
new_partitions: Vec<HashSet<GridHash<P>, 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<P: GridPrecision> {
|
||||
grid: Entity,
|
||||
tables: Vec<HashSet<GridHash<P>, PassHash>>,
|
||||
}
|
||||
impl<P: GridPrecision> GridPartition<P> {
|
||||
/// 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<P>) -> 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<P: GridPrecision> {
|
||||
grid: Entity,
|
||||
tables: Vec<HashSet<GridHash<P>, PassHash>>,
|
||||
min: GridCell<P>,
|
||||
max: GridCell<P>,
|
||||
}
|
||||
|
||||
/// Iterates over all [`GridHash`]s in this partition.
|
||||
#[inline]
|
||||
pub fn iter(&self) -> impl Iterator<Item = &GridHash<P>> {
|
||||
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<P>) {
|
||||
if self.contains(&cell) {
|
||||
return;
|
||||
impl<P: GridPrecision> GridPartition<P> {
|
||||
/// Returns `true` if the `hash` is in this partition.
|
||||
#[inline]
|
||||
pub fn contains(&self, hash: &GridHash<P>) -> 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<Item = &GridHash<P>> {
|
||||
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<P> {
|
||||
self.max
|
||||
}
|
||||
|
||||
/// The minimum grid cell extent of the partition.
|
||||
pub fn min(&self) -> GridCell<P> {
|
||||
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<usize> {
|
||||
self.tables
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| (i, t.len()))
|
||||
.min_by_key(|(_, len)| *len)
|
||||
.map(|(i, _len)| i)
|
||||
}
|
||||
/// Private internal methods
|
||||
impl<P: GridPrecision> GridPartition<P> {
|
||||
pub(crate) fn new(
|
||||
grid: Entity,
|
||||
tables: Vec<HashSet<GridHash<P>, PassHash>>,
|
||||
min: GridCell<P>,
|
||||
max: GridCell<P>,
|
||||
) -> Self {
|
||||
Self {
|
||||
grid,
|
||||
tables,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn extend(&mut self, mut partition: GridPartition<P>) {
|
||||
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<P>) {
|
||||
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<usize> {
|
||||
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<P>) {
|
||||
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<P>) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user