Skip to content

Commit f913e9a

Browse files
authored
Fix partition change tracking (#66)
Fix an issue with partition change tracking for stationary entities, add regression tests. Snuck in a passhash perf improvement I noticed while I was in there.
1 parent 69e9845 commit f913e9a

File tree

4 files changed

+233
-59
lines changed

4 files changed

+233
-59
lines changed

src/grid/propagation.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,16 @@ impl Grid {
174174
) {
175175
let start = bevy_platform::time::Instant::now();
176176
let update_transforms = |low_precision_root, parent_transform: Ref<GlobalTransform>| {
177-
// High precision global transforms are change-detected, and are only updated if that
177+
// High precision global transforms are change-detected and are only updated if that
178178
// entity has moved relative to the floating origin's grid cell.
179179
let changed = parent_transform.is_changed();
180180

181+
#[expect(
182+
unsafe_code,
183+
reason = "`propagate_recursive()` is unsafe due to its use of `Query::get_unchecked()`."
184+
)]
181185
// SAFETY:
182-
// - Unlike the bevy version of this, we do not iterate over all children of the root,
186+
// - Unlike the bevy version of this, we do not iterate over all children of the root
183187
// and manually verify each child has a parent component that points back to the same
184188
// entity. Instead, we query the roots directly, so we know they are unique.
185189
// - We may operate as if all descendants are consistent, since `propagate_recursive`
@@ -189,10 +193,6 @@ impl Grid {
189193
// other root entities' `propagate_recursive` calls will not conflict with this one.
190194
// - Since this is the only place where `transform_query` gets used, there will be no
191195
// conflicting fetches elsewhere.
192-
#[expect(
193-
unsafe_code,
194-
reason = "`propagate_recursive()` is unsafe due to its use of `Query::get_unchecked()`."
195-
)]
196196
unsafe {
197197
Self::propagate_recursive(
198198
&parent_transform,

src/partition/change_tracking.rs

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ use crate::hash::SpatialHashFilter;
88
use crate::hash::SpatialHashSystems;
99
use crate::partition::map::PartitionLookup;
1010
use crate::partition::PartitionId;
11-
use alloc::vec::Vec;
1211
use bevy_app::prelude::*;
1312
use bevy_ecs::entity::EntityHashMap;
1413
use bevy_ecs::prelude::*;
@@ -20,7 +19,7 @@ use core::marker::PhantomData;
2019
pub struct PartitionChangePlugin<F: SpatialHashFilter = ()>(PhantomData<F>);
2120

2221
impl<F: SpatialHashFilter> PartitionChangePlugin<F> {
23-
/// Create a new instance of [`crate::prelude::PartitionChangePlugin`].
22+
/// Create a new instance of [`PartitionChangePlugin`].
2423
pub fn new() -> Self {
2524
Self(PhantomData)
2625
}
@@ -68,88 +67,87 @@ impl<F: SpatialHashFilter> FromWorld for PartitionEntities<F> {
6867

6968
impl<F: SpatialHashFilter> PartitionEntities<F> {
7069
fn update(
71-
mut entity_partitions: ResMut<Self>,
70+
mut this: ResMut<Self>,
7271
cells: Res<CellLookup<F>>,
7372
changed_cells: Res<ChangedCells<F>>,
7473
all_hashes: Query<(Entity, &CellId), F>,
7574
mut old_reverse: Local<CellHashMap<PartitionId>>,
7675
partitions: Res<PartitionLookup<F>>,
7776
) {
78-
entity_partitions.changed.clear();
77+
// 1. Clear the list of entities that have changed partitions
78+
this.changed.clear();
7979

80-
// Compute cell-level partition changes: cells that remained occupied but changed partitions.
81-
for (cell_hash, entry) in cells.all_entries() {
82-
if let Some(&new_pid) = partitions.get(cell_hash) {
83-
// Only mark entities whose previous recorded partition differs from the new one
84-
for entity in entry.entities.iter().copied() {
85-
if let Some(prev_pid) = entity_partitions.map.get(&entity).copied() {
86-
if prev_pid != new_pid {
87-
if let Some((_from, to)) = entity_partitions.changed.get_mut(&entity) {
88-
*to = Some(new_pid);
89-
} else {
90-
entity_partitions
91-
.changed
92-
.insert(entity, (Some(prev_pid), Some(new_pid)));
93-
}
94-
}
95-
}
96-
}
97-
}
98-
}
99-
100-
// Compute entity-level partition changes for entities that moved cells this frame,
101-
// were newly spawned (added cell), or had cell removed (despawned/removed component).
80+
// 2. Iterate through all entities that have moved between cells, using the already
81+
// optimized `ChangedCells` resource used for grid cells. Check these moved entities to see
82+
// if they have also changed partitions. This should also include spawned/despawned.
10283
for entity in changed_cells.iter() {
10384
match all_hashes.get(*entity) {
104-
// Entity currently has a CellId
10585
Ok((entity_id, cell_hash)) => {
10686
let new_pid = partitions.get(cell_hash).copied();
107-
let old_pid = entity_partitions.map.get(&entity_id).copied();
108-
let record = match (old_pid, new_pid) {
87+
let old_pid = this.map.get(&entity_id).copied();
88+
let partition_change = match (old_pid, new_pid) {
10989
(Some(o), Some(n)) if o == n => None, // Partition unchanged
11090
(None, None) => None, // Nonsensical
111-
other => Some(other),
91+
other => Some(other), // Valid change
11292
};
113-
if let Some((from, to)) = record {
114-
if let Some((existing_from, existing_to)) =
115-
entity_partitions.changed.get_mut(entity)
116-
{
93+
if let Some((from, to)) = partition_change {
94+
if let Some((existing_from, existing_to)) = this.changed.get_mut(entity) {
11795
// Preserve the earliest known source if we already have one
11896
if existing_from.is_none() {
11997
*existing_from = from;
12098
}
12199
*existing_to = to;
122100
} else {
123-
entity_partitions.changed.insert(*entity, (from, to));
101+
this.changed.insert(*entity, (from, to));
124102
}
125103
}
126104
}
127-
// Entity no longer has a CellId -> removed/despawned
105+
// If the query fails, the entity no longer has a `CellId` because it was removed
106+
// or the entity was despawned.
128107
Err(_) => {
129-
if let Some(prev_pid) = entity_partitions.map.get(entity).copied() {
130-
entity_partitions
131-
.changed
132-
.insert(*entity, (Some(prev_pid), None));
108+
if let Some(prev_pid) = this.map.get(entity).copied() {
109+
this.changed.insert(*entity, (Some(prev_pid), None));
133110
}
134111
}
135112
}
136113
}
137114

138-
// Apply the delta only after all changes have been collected.
139-
let mut apply_list: Vec<(Entity, Option<PartitionId>)> =
140-
Vec::with_capacity(entity_partitions.changed.len());
141-
for (entity, (_from, to)) in entity_partitions.changed.iter() {
142-
apply_list.push((*entity, *to));
115+
// 3. Consider entities that have not moved, but their partition has changed out from
116+
// underneath them. This can happen when partitions merge and split - the entity did not
117+
// move but is now in a new partition.
118+
//
119+
// Check these changes at the cell level so we scale with the number of cells, not the
120+
// number of entities, and additionally avoid many lookups.
121+
//
122+
// It's important this is run after the moved-entity checks in step 2, because the entities
123+
// found from cell changes may *also* have moved, and we would miss that information if we
124+
// only used cell-level tracking.
125+
for (cell_id, entry) in cells.all_entries() {
126+
if let Some(&new_pid) = partitions.get(cell_id) {
127+
if let Some(&old_pid) = old_reverse.get(cell_id) {
128+
if new_pid != old_pid {
129+
for entity in entry.entities.iter().copied() {
130+
this.changed
131+
.entry(entity)
132+
// Don't overwrite entities that moved cells, they have already been tracked.
133+
.or_insert((Some(old_pid), Some(new_pid)));
134+
}
135+
}
136+
}
137+
}
143138
}
144-
for (entity, to) in apply_list {
145-
match to {
139+
140+
// 4. Apply the changes to the entity partition map after all changes have been collected.
141+
let PartitionEntities { map, changed, .. } = this.as_mut();
142+
for (entity, (_source, destination)) in changed.iter() {
143+
match destination {
146144
Some(pid) => {
147-
entity_partitions.map.insert(entity, pid);
145+
map.insert(*entity, *pid);
148146
}
149147
None => {
150-
entity_partitions.map.remove(&entity);
148+
map.remove(entity);
151149
}
152-
}
150+
};
153151
}
154152

155153
// Snapshot the cell->partition mapping to compute deltas next update

src/partition/map.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use alloc::vec;
1010
use alloc::vec::Vec;
1111
use bevy_ecs::prelude::*;
1212
use bevy_platform::collections::HashMap;
13+
use bevy_platform::hash::PassHash;
1314
use bevy_platform::time::Instant;
1415
use bevy_tasks::{ComputeTaskPool, ParallelSliceMut};
1516
use core::marker::PhantomData;
@@ -31,7 +32,7 @@ pub struct PartitionLookup<F = ()>
3132
where
3233
F: SpatialHashFilter,
3334
{
34-
partitions: HashMap<PartitionId, Partition>,
35+
partitions: HashMap<PartitionId, Partition, PassHash>,
3536
pub(crate) reverse_map: CellHashMap<PartitionId>,
3637
next_partition: u64,
3738
spooky: PhantomData<F>,
@@ -55,7 +56,7 @@ impl<F> Deref for PartitionLookup<F>
5556
where
5657
F: SpatialHashFilter,
5758
{
58-
type Target = HashMap<PartitionId, Partition>;
59+
type Target = HashMap<PartitionId, Partition, PassHash>;
5960

6061
fn deref(&self) -> &Self::Target {
6162
&self.partitions
@@ -187,7 +188,7 @@ where
187188
cells: Res<CellLookup<F>>,
188189
// Scratch space allocations
189190
mut added_neighbors: Local<Vec<PartitionId>>,
190-
mut split_candidates_map: Local<HashMap<PartitionId, CellHashSet>>,
191+
mut split_candidates_map: Local<HashMap<PartitionId, CellHashSet, PassHash>>,
191192
mut split_candidates: Local<Vec<(PartitionId, CellHashSet)>>,
192193
mut split_results: Local<Vec<Vec<SplitResult>>>,
193194
) {

src/partition/tests.rs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,178 @@ fn split_then_merge_back_same_frame_no_false_positive() {
295295
assert!(ep.changed.get(&app.world().resource::<R>().left).is_none());
296296
assert!(ep.changed.get(&app.world().resource::<R>().right).is_none());
297297
}
298+
299+
#[test]
300+
fn partition_split_records_changes_for_stationary_entities() {
301+
let mut app = App::new();
302+
app.add_plugins((
303+
BigSpaceMinimalPlugins,
304+
CellHashingPlugin::default(),
305+
PartitionPlugin::default(),
306+
PartitionChangePlugin::default(),
307+
));
308+
309+
#[derive(Resource, Clone, Copy)]
310+
struct R {
311+
left: Entity,
312+
mid: Entity,
313+
right: Entity,
314+
}
315+
316+
let setup = |mut commands: Commands| {
317+
commands.spawn_big_space_default(|root| {
318+
let left = root.spawn_spatial(CellCoord::new(0, 0, 0)).id();
319+
let mid = root.spawn_spatial(CellCoord::new(1, 0, 0)).id();
320+
let right = root.spawn_spatial(CellCoord::new(2, 0, 0)).id();
321+
root.commands().insert_resource(R { left, mid, right });
322+
});
323+
};
324+
app.add_systems(Startup, setup);
325+
326+
for _ in 0..10 {
327+
run_app_once(&mut app);
328+
}
329+
330+
let (left, right, mid) = {
331+
let r = app.world().resource::<R>();
332+
(r.left, r.right, r.mid)
333+
};
334+
335+
let (pid_left, pid_mid, pid_right) = {
336+
let parts = app.world().resource::<PartitionLookup>();
337+
let cell_left = *app.world().get::<CellId>(left).unwrap();
338+
let cell_mid = *app.world().get::<CellId>(mid).unwrap();
339+
let cell_right = *app.world().get::<CellId>(right).unwrap();
340+
(
341+
parts.get(&cell_left).copied().unwrap(),
342+
parts.get(&cell_mid).copied().unwrap(),
343+
parts.get(&cell_right).copied().unwrap(),
344+
)
345+
};
346+
assert_eq!(pid_left, pid_mid);
347+
assert_eq!(pid_mid, pid_right);
348+
349+
// Remove the middle cell to cause a split
350+
app.world_mut().commands().entity(mid).despawn();
351+
352+
let mut left_changed = false;
353+
let mut right_changed = false;
354+
let mut left_pid_entities = None;
355+
let mut right_pid_entities = None;
356+
357+
for _ in 0..100 {
358+
run_app_once(&mut app);
359+
let ep = app.world().resource::<PartitionEntities>();
360+
if ep.changed.contains_key(&left) {
361+
left_changed = true;
362+
}
363+
if ep.changed.contains_key(&right) {
364+
right_changed = true;
365+
}
366+
left_pid_entities = ep.map.get(&left).copied();
367+
right_pid_entities = ep.map.get(&right).copied();
368+
}
369+
370+
let parts = app.world().resource::<PartitionLookup>();
371+
let cell_left = *app.world().get::<CellId>(left).unwrap();
372+
let cell_right = *app.world().get::<CellId>(right).unwrap();
373+
let left_pid_actual = parts.get(&cell_left).copied().unwrap();
374+
let right_pid_actual = parts.get(&cell_right).copied().unwrap();
375+
376+
assert_ne!(
377+
left_pid_actual, right_pid_actual,
378+
"Partitions should have split in PartitionLookup"
379+
);
380+
assert_ne!(
381+
left_pid_entities, right_pid_entities,
382+
"Partitions should have split in PartitionEntities"
383+
);
384+
385+
assert!(
386+
left_changed || right_changed,
387+
"At least one entity should have recorded a partition change"
388+
);
389+
}
390+
391+
#[test]
392+
fn partition_merge_records_changes_for_stationary_entities() {
393+
let mut app = App::new();
394+
app.add_plugins((
395+
BigSpaceMinimalPlugins,
396+
CellHashingPlugin::default(),
397+
PartitionPlugin::default(),
398+
PartitionChangePlugin::default(),
399+
));
400+
401+
#[derive(Resource, Clone, Copy)]
402+
struct R {
403+
root: Entity,
404+
left: Entity,
405+
right: Entity,
406+
}
407+
408+
let setup = |mut commands: Commands| {
409+
commands.spawn_big_space_default(|root| {
410+
let left = root.spawn_spatial(CellCoord::new(0, 0, 0)).id();
411+
let right = root.spawn_spatial(CellCoord::new(2, 0, 0)).id();
412+
let root_id = root.id();
413+
root.commands().insert_resource(R {
414+
root: root_id,
415+
left,
416+
right,
417+
});
418+
});
419+
};
420+
app.add_systems(Startup, setup);
421+
422+
for _ in 0..10 {
423+
run_app_once(&mut app);
424+
}
425+
426+
let (left, right) = {
427+
let r = app.world().resource::<R>();
428+
(r.left, r.right)
429+
};
430+
431+
let ep = app.world().resource::<PartitionEntities>();
432+
let left_pid_initial = *ep.map.get(&left).expect("left not mapped");
433+
let right_pid_initial = *ep.map.get(&right).expect("right not mapped");
434+
assert_ne!(left_pid_initial, right_pid_initial);
435+
436+
// Add a connector to merge them
437+
let root_id = app.world().resource::<R>().root;
438+
app.world_mut()
439+
.commands()
440+
.grid(root_id, Grid::default())
441+
.spawn_spatial(CellCoord::new(1, 0, 0));
442+
443+
let mut left_changed = false;
444+
let mut right_changed = false;
445+
let mut left_pid_entities = None;
446+
let mut right_pid_entities = None;
447+
448+
for _ in 0..100 {
449+
run_app_once(&mut app);
450+
let ep = app.world().resource::<PartitionEntities>();
451+
if ep.changed.contains_key(&left) {
452+
left_changed = true;
453+
}
454+
if ep.changed.contains_key(&right) {
455+
right_changed = true;
456+
}
457+
left_pid_entities = ep.map.get(&left).copied();
458+
right_pid_entities = ep.map.get(&right).copied();
459+
}
460+
461+
// After merge, both should be in the same partition
462+
assert_eq!(
463+
left_pid_entities, right_pid_entities,
464+
"Partitions should have merged in PartitionEntities"
465+
);
466+
467+
// At least one of them MUST have changed its partition ID to match the other
468+
assert!(
469+
left_changed || right_changed,
470+
"At least one entity should have recorded a partition change during merge"
471+
);
472+
}

0 commit comments

Comments
 (0)