feat(ecs): add tick-based change detection with query_changed

Add per-component tick tracking to SparseSet. Insert and get_mut mark
the current tick; increment_tick advances it. World gains query_changed
to find entities whose component changed this tick, and clear_changed
to advance all storages at end of frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 15:00:50 +09:00
parent c6ac2ded81
commit bbb11d9d47
2 changed files with 152 additions and 0 deletions

View File

@@ -5,6 +5,8 @@ pub struct SparseSet<T> {
sparse: Vec<Option<usize>>,
dense_entities: Vec<Entity>,
dense_data: Vec<T>,
ticks: Vec<u64>,
current_tick: u64,
}
impl<T> SparseSet<T> {
@@ -13,6 +15,8 @@ impl<T> SparseSet<T> {
sparse: Vec::new(),
dense_entities: Vec::new(),
dense_data: Vec::new(),
ticks: Vec::new(),
current_tick: 1,
}
}
@@ -27,11 +31,13 @@ impl<T> SparseSet<T> {
// Overwrite existing
self.dense_data[dense_idx] = value;
self.dense_entities[dense_idx] = entity;
self.ticks[dense_idx] = self.current_tick;
} else {
let dense_idx = self.dense_data.len();
self.sparse[id] = Some(dense_idx);
self.dense_entities.push(entity);
self.dense_data.push(value);
self.ticks.push(self.current_tick);
}
}
@@ -49,12 +55,14 @@ impl<T> SparseSet<T> {
if dense_idx == last_idx {
self.dense_entities.pop();
self.ticks.pop();
Some(self.dense_data.pop().unwrap())
} else {
// Swap with last
let swapped_entity = self.dense_entities[last_idx];
self.sparse[swapped_entity.id as usize] = Some(dense_idx);
self.dense_entities.swap_remove(dense_idx);
self.ticks.swap_remove(dense_idx);
Some(self.dense_data.swap_remove(dense_idx))
}
}
@@ -74,6 +82,7 @@ impl<T> SparseSet<T> {
if self.dense_entities[dense_idx] != entity {
return None;
}
self.ticks[dense_idx] = self.current_tick;
Some(&mut self.dense_data[dense_idx])
}
@@ -114,6 +123,29 @@ impl<T> SparseSet<T> {
pub fn data_mut(&mut self) -> &mut [T] {
&mut self.dense_data
}
/// Check if an entity's component was changed this tick.
pub fn is_changed(&self, entity: Entity) -> bool {
if let Some(&index) = self.sparse.get(entity.id as usize).and_then(|o| o.as_ref()) {
self.ticks[index] == self.current_tick
} else {
false
}
}
/// Advance the tick counter (call at end of frame).
pub fn increment_tick(&mut self) {
self.current_tick += 1;
}
/// Return entities changed this tick with their data.
pub fn iter_changed(&self) -> impl Iterator<Item = (Entity, &T)> + '_ {
self.dense_entities.iter()
.zip(self.dense_data.iter())
.zip(self.ticks.iter())
.filter(move |((_, _), &tick)| tick == self.current_tick)
.map(|((entity, data), _)| (*entity, data))
}
}
impl<T> Default for SparseSet<T> {
@@ -127,6 +159,7 @@ pub trait ComponentStorage: Any {
fn as_any_mut(&mut self) -> &mut dyn Any;
fn remove_entity(&mut self, entity: Entity);
fn storage_len(&self) -> usize;
fn increment_tick(&mut self);
}
impl<T: 'static> ComponentStorage for SparseSet<T> {
@@ -142,6 +175,9 @@ impl<T: 'static> ComponentStorage for SparseSet<T> {
fn storage_len(&self) -> usize {
self.dense_data.len()
}
fn increment_tick(&mut self) {
self.current_tick += 1;
}
}
#[cfg(test)]
@@ -235,6 +271,74 @@ mod tests {
assert!(!set.contains(e));
}
#[test]
fn test_insert_is_changed() {
let mut set = SparseSet::<u32>::new();
let e = make_entity(0, 0);
set.insert(e, 42);
assert!(set.is_changed(e));
}
#[test]
fn test_get_mut_marks_changed() {
let mut set = SparseSet::<u32>::new();
let e = make_entity(0, 0);
set.insert(e, 42);
set.increment_tick();
assert!(!set.is_changed(e));
let _ = set.get_mut(e);
assert!(set.is_changed(e));
}
#[test]
fn test_get_not_changed() {
let mut set = SparseSet::<u32>::new();
let e = make_entity(0, 0);
set.insert(e, 42);
set.increment_tick();
let _ = set.get(e);
assert!(!set.is_changed(e));
}
#[test]
fn test_clear_resets_changed() {
let mut set = SparseSet::<u32>::new();
let e = make_entity(0, 0);
set.insert(e, 42);
assert!(set.is_changed(e));
set.increment_tick();
assert!(!set.is_changed(e));
}
#[test]
fn test_iter_changed() {
let mut set = SparseSet::<u32>::new();
let e1 = make_entity(0, 0);
let e2 = make_entity(1, 0);
let e3 = make_entity(2, 0);
set.insert(e1, 10);
set.insert(e2, 20);
set.insert(e3, 30);
set.increment_tick();
let _ = set.get_mut(e2);
let changed: Vec<_> = set.iter_changed().collect();
assert_eq!(changed.len(), 1);
assert_eq!(changed[0].0.id, 1);
}
#[test]
fn test_remove_preserves_ticks() {
let mut set = SparseSet::<u32>::new();
let e1 = make_entity(0, 0);
let e2 = make_entity(1, 0);
set.insert(e1, 10);
set.insert(e2, 20);
set.increment_tick();
let _ = set.get_mut(e2);
set.remove(e1);
assert!(set.is_changed(e2));
}
#[test]
fn test_swap_remove_correctness() {
let mut set: SparseSet<i32> = SparseSet::new();

View File

@@ -228,6 +228,23 @@ impl World {
result
}
/// Query entities whose component T was changed this tick.
pub fn query_changed<T: 'static>(&self) -> Vec<(Entity, &T)> {
if let Some(storage) = self.storages.get(&TypeId::of::<T>()) {
let set = storage.as_any().downcast_ref::<SparseSet<T>>().unwrap();
set.iter_changed().collect()
} else {
Vec::new()
}
}
/// Advance tick on all component storages (call at end of frame).
pub fn clear_changed(&mut self) {
for storage in self.storages.values_mut() {
storage.increment_tick();
}
}
pub fn has_component<T: 'static>(&self, entity: Entity) -> bool {
self.storage::<T>().map_or(false, |s| s.contains(entity))
}
@@ -526,6 +543,37 @@ mod tests {
assert_eq!(results[0].0, e1);
}
#[test]
fn test_query_changed() {
let mut world = World::new();
let e1 = world.spawn();
let e2 = world.spawn();
world.add(e1, 10u32);
world.add(e2, 20u32);
world.clear_changed();
if let Some(v) = world.get_mut::<u32>(e1) {
*v = 100;
}
let changed = world.query_changed::<u32>();
assert_eq!(changed.len(), 1);
assert_eq!(*changed[0].1, 100);
}
#[test]
fn test_clear_changed_all_storages() {
let mut world = World::new();
let e = world.spawn();
world.add(e, 42u32);
world.add(e, 3.14f32);
let changed_u32 = world.query_changed::<u32>();
assert_eq!(changed_u32.len(), 1);
world.clear_changed();
let changed_u32 = world.query_changed::<u32>();
assert_eq!(changed_u32.len(), 0);
let changed_f32 = world.query_changed::<f32>();
assert_eq!(changed_f32.len(), 0);
}
#[test]
fn test_entity_count() {
let mut world = World::new();