From 3dc6a07d27e132ea81985809d0636da230cde320 Mon Sep 17 00:00:00 2001 From: robtfm <50659922+robtfm@users.noreply.github.com> Date: Fri, 6 Jun 2025 02:02:02 +0200 Subject: [PATCH] generic component propagation (#17575) # Objective add functionality to allow propagating components to children. requested originally for `RenderLayers` but can be useful more generally. ## Solution - add `HierarchyPropagatePlugin` which schedules systems to propagate components through entities matching `F` - add `Propagate` which will cause `C` to be added to all children more niche features: - add `PropagateStop` which stops the propagation at this entity - add `PropagateOver` which allows the propagation to continue to children, but doesn't add/remove/modify a `C` on this entity itself ## Testing see tests inline ## Notes - could happily be an out-of-repo plugin - not sure where it lives: ideally it would be in `bevy_ecs` but it requires a `Plugin` so I put it in `bevy_app`, doesn't really belong there though. - i'm not totally up-to-date on triggers and observers so possibly this could be done more cleanly, would be very happy to take review comments - perf: this is pretty cheap except for `update_reparented` which has to check the parent of every moved entity. since the entirety is opt-in i think it's acceptable but i could possibly use `(Changed, With>)` instead if it's a concern --- crates/bevy_app/src/lib.rs | 2 + crates/bevy_app/src/propagate.rs | 551 +++++++++++++++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 crates/bevy_app/src/propagate.rs diff --git a/crates/bevy_app/src/lib.rs b/crates/bevy_app/src/lib.rs index bca966d7cb..188ba957f6 100644 --- a/crates/bevy_app/src/lib.rs +++ b/crates/bevy_app/src/lib.rs @@ -28,6 +28,7 @@ mod main_schedule; mod panic_handler; mod plugin; mod plugin_group; +mod propagate; mod schedule_runner; mod sub_app; mod task_pool_plugin; @@ -42,6 +43,7 @@ pub use main_schedule::*; pub use panic_handler::*; pub use plugin::*; pub use plugin_group::*; +pub use propagate::*; pub use schedule_runner::*; pub use sub_app::*; pub use task_pool_plugin::*; diff --git a/crates/bevy_app/src/propagate.rs b/crates/bevy_app/src/propagate.rs new file mode 100644 index 0000000000..5d766a626c --- /dev/null +++ b/crates/bevy_app/src/propagate.rs @@ -0,0 +1,551 @@ +use alloc::vec::Vec; +use core::marker::PhantomData; + +use crate::{App, Plugin, Update}; +use bevy_ecs::{ + component::Component, + entity::Entity, + hierarchy::ChildOf, + query::{Changed, Or, QueryFilter, With, Without}, + relationship::{Relationship, RelationshipTarget}, + removal_detection::RemovedComponents, + schedule::{IntoScheduleConfigs, SystemSet}, + system::{Commands, Local, Query}, +}; + +/// Plugin to automatically propagate a component value to all direct and transient relationship +/// targets (e.g. [`bevy_ecs::hierarchy::Children`]) of entities with a [`Propagate`] component. +/// +/// The plugin Will maintain the target component over hierarchy changes, adding or removing +/// `C` when a relationship `R` (e.g. [`ChildOf`]) is added to or removed from a +/// relationship tree with a [`Propagate`] source, or if the [`Propagate`] component +/// is added, changed or removed. +/// +/// Optionally you can include a query filter `F` to restrict the entities that are updated. +/// Note that the filter is not rechecked dynamically: changes to the filter state will not be +/// picked up until the [`Propagate`] component is touched, or the hierarchy is changed. +/// All members of the tree between source and target must match the filter for propagation +/// to reach a given target. +/// Individual entities can be skipped or terminate the propagation with the [`PropagateOver`] +/// and [`PropagateStop`] components. +pub struct HierarchyPropagatePlugin< + C: Component + Clone + PartialEq, + F: QueryFilter = (), + R: Relationship = ChildOf, +>(PhantomData (C, F, R)>); + +/// Causes the inner component to be added to this entity and all direct and transient relationship +/// targets. A target with a [`Propagate`] component of its own will override propagation from +/// that point in the tree. +#[derive(Component, Clone, PartialEq)] +pub struct Propagate(pub C); + +/// Stops the output component being added to this entity. +/// Relationship targets will still inherit the component from this entity or its parents. +#[derive(Component)] +pub struct PropagateOver(PhantomData C>); + +/// Stops the propagation at this entity. Children will not inherit the component. +#[derive(Component)] +pub struct PropagateStop(PhantomData C>); + +/// The set in which propagation systems are added. You can schedule your logic relative to this set. +#[derive(SystemSet, Clone, PartialEq, PartialOrd, Ord)] +pub struct PropagateSet { + _p: PhantomData C>, +} + +/// Internal struct for managing propagation +#[derive(Component, Clone, PartialEq)] +pub struct Inherited(pub C); + +impl Default + for HierarchyPropagatePlugin +{ + fn default() -> Self { + Self(Default::default()) + } +} + +impl Default for PropagateOver { + fn default() -> Self { + Self(Default::default()) + } +} + +impl Default for PropagateStop { + fn default() -> Self { + Self(Default::default()) + } +} + +impl core::fmt::Debug for PropagateSet { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PropagateSet") + .field("_p", &self._p) + .finish() + } +} + +impl Eq for PropagateSet {} +impl core::hash::Hash for PropagateSet { + fn hash(&self, state: &mut H) { + self._p.hash(state); + } +} + +impl Default for PropagateSet { + fn default() -> Self { + Self { + _p: Default::default(), + } + } +} + +impl Plugin + for HierarchyPropagatePlugin +{ + fn build(&self, app: &mut App) { + app.add_systems( + Update, + ( + update_source::, + update_stopped::, + update_reparented::, + propagate_inherited::, + propagate_output::, + ) + .chain() + .in_set(PropagateSet::::default()), + ); + } +} + +/// add/remove `Inherited::` and `C` for entities with a direct `Propagate::` +pub fn update_source( + mut commands: Commands, + changed: Query< + (Entity, &Propagate), + ( + Or<(Changed>, Without>)>, + Without>, + ), + >, + mut removed: RemovedComponents>, +) { + for (entity, source) in &changed { + commands + .entity(entity) + .try_insert(Inherited(source.0.clone())); + } + + for removed in removed.read() { + if let Ok(mut commands) = commands.get_entity(removed) { + commands.remove::<(Inherited, C)>(); + } + } +} + +/// remove `Inherited::` and `C` for entities with a `PropagateStop::` +pub fn update_stopped( + mut commands: Commands, + q: Query>, With>, F)>, +) { + for entity in q.iter() { + let mut cmds = commands.entity(entity); + cmds.remove::<(Inherited, C)>(); + } +} + +/// add/remove `Inherited::` and `C` for entities which have changed relationship +pub fn update_reparented( + mut commands: Commands, + moved: Query< + (Entity, &R, Option<&Inherited>), + ( + Changed, + Without>, + Without>, + F, + ), + >, + relations: Query<&Inherited>, + orphaned: Query>, Without>, Without, F)>, +) { + for (entity, relation, maybe_inherited) in &moved { + if let Ok(inherited) = relations.get(relation.get()) { + commands.entity(entity).try_insert(inherited.clone()); + } else if maybe_inherited.is_some() { + commands.entity(entity).remove::<(Inherited, C)>(); + } + } + + for orphan in &orphaned { + commands.entity(orphan).remove::<(Inherited, C)>(); + } +} + +/// add/remove `Inherited::` for targets of entities with modified `Inherited::` +pub fn propagate_inherited( + mut commands: Commands, + changed: Query< + (&Inherited, &R::RelationshipTarget), + (Changed>, Without>, F), + >, + recurse: Query< + (Option<&R::RelationshipTarget>, Option<&Inherited>), + (Without>, Without>, F), + >, + mut removed: RemovedComponents>, + mut to_process: Local>)>>, +) { + // gather changed + for (inherited, targets) in &changed { + to_process.extend( + targets + .iter() + .map(|target| (target, Some(inherited.clone()))), + ); + } + + // and removed + for entity in removed.read() { + if let Ok((Some(targets), _)) = recurse.get(entity) { + to_process.extend(targets.iter().map(|target| (target, None))); + } + } + + // propagate + while let Some((entity, maybe_inherited)) = (*to_process).pop() { + let Ok((maybe_targets, maybe_current)) = recurse.get(entity) else { + continue; + }; + + if maybe_current == maybe_inherited.as_ref() { + continue; + } + + if let Some(targets) = maybe_targets { + to_process.extend( + targets + .iter() + .map(|target| (target, maybe_inherited.clone())), + ); + } + + if let Some(inherited) = maybe_inherited { + commands.entity(entity).try_insert(inherited.clone()); + } else { + commands.entity(entity).remove::<(Inherited, C)>(); + } + } +} + +/// add `C` to entities with `Inherited::` +pub fn propagate_output( + mut commands: Commands, + changed: Query< + (Entity, &Inherited, Option<&C>), + (Changed>, Without>, F), + >, +) { + for (entity, inherited, maybe_current) in &changed { + if maybe_current.is_some_and(|c| &inherited.0 == c) { + continue; + } + + commands.entity(entity).try_insert(inherited.0.clone()); + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::schedule::Schedule; + + use crate::{App, Update}; + + use super::*; + + #[derive(Component, Clone, PartialEq, Debug)] + struct TestValue(u32); + + #[test] + fn test_simple_propagate() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let intermediate = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(intermediate)) + .id(); + + app.update(); + + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_ok()); + } + + #[test] + fn test_reparented() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + + app.update(); + + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_ok()); + } + + #[test] + fn test_reparented_with_prior() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator_a = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagator_b = app.world_mut().spawn(Propagate(TestValue(2))).id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator_a)) + .id(); + + app.update(); + assert_eq!( + app.world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee), + Ok(&TestValue(1)) + ); + app.world_mut() + .commands() + .entity(propagatee) + .insert(ChildOf(propagator_b)); + app.update(); + assert_eq!( + app.world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee), + Ok(&TestValue(2)) + ); + } + + #[test] + fn test_remove_orphan() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_ok()); + app.world_mut() + .commands() + .entity(propagatee) + .remove::(); + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_err()); + } + + #[test] + fn test_remove_propagated() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_ok()); + app.world_mut() + .commands() + .entity(propagator) + .remove::>(); + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_err()); + } + + #[test] + fn test_propagate_over() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagate_over = app + .world_mut() + .spawn(TestValue(2)) + .insert(ChildOf(propagator)) + .id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagate_over)) + .id(); + + app.update(); + assert_eq!( + app.world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee), + Ok(&TestValue(1)) + ); + } + + #[test] + fn test_propagate_stop() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagate_stop = app + .world_mut() + .spawn(PropagateStop::::default()) + .insert(ChildOf(propagator)) + .id(); + let no_propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagate_stop)) + .id(); + + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), no_propagatee) + .is_err()); + } + + #[test] + fn test_intermediate_override() { + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let intermediate = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(intermediate)) + .id(); + + app.update(); + assert_eq!( + app.world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee), + Ok(&TestValue(1)) + ); + + app.world_mut() + .entity_mut(intermediate) + .insert(Propagate(TestValue(2))); + app.update(); + assert_eq!( + app.world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee), + Ok(&TestValue(2)) + ); + } + + #[test] + fn test_filter() { + #[derive(Component)] + struct Marker; + + let mut app = App::new(); + app.add_schedule(Schedule::new(Update)); + app.add_plugins(HierarchyPropagatePlugin::>::default()); + + let propagator = app.world_mut().spawn(Propagate(TestValue(1))).id(); + let propagatee = app + .world_mut() + .spawn_empty() + .insert(ChildOf(propagator)) + .id(); + + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_err()); + + // NOTE: changes to the filter condition are not rechecked + app.world_mut().entity_mut(propagator).insert(Marker); + app.world_mut().entity_mut(propagatee).insert(Marker); + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_err()); + + app.world_mut() + .entity_mut(propagator) + .insert(Propagate(TestValue(1))); + app.update(); + assert!(app + .world_mut() + .query::<&TestValue>() + .get(app.world(), propagatee) + .is_ok()); + } +}