Skip to content

Commit ed2b8e0

Browse files
NthTensoralice-i-ceciletorsteingrindvikcart
authored
Minimal Bubbling Observers (#13991)
# Objective Add basic bubbling to observers, modeled off `bevy_eventlistener`. ## Solution - Introduce a new `Traversal` trait for components which point to other entities. - Provide a default `TraverseNone: Traversal` component which cannot be constructed. - Implement `Traversal` for `Parent`. - The `Event` trait now has an associated `Traversal` which defaults to `TraverseNone`. - Added a field `bubbling: &mut bool` to `Trigger` which can be used to instruct the runner to bubble the event to the entity specified by the event's traversal type. - Added an associated constant `SHOULD_BUBBLE` to `Event` which configures the default bubbling state. - Added logic to wire this all up correctly. Introducing the new associated information directly on `Event` (instead of a new `BubblingEvent` trait) lets us dispatch both bubbling and non-bubbling events through the same api. ## Testing I have added several unit tests to cover the common bugs I identified during development. Running the unit tests should be enough to validate correctness. The changes effect unsafe portions of the code, but should not change any of the safety assertions. ## Changelog Observers can now bubble up the entity hierarchy! To create a bubbling event, change your `Derive(Event)` to something like the following: ```rust #[derive(Component)] struct MyEvent; impl Event for MyEvent { type Traverse = Parent; // This event will propagate up from child to parent. const AUTO_PROPAGATE: bool = true; // This event will propagate by default. } ``` You can dispatch a bubbling event using the normal `world.trigger_targets(MyEvent, entity)`. Halting an event mid-bubble can be done using `trigger.propagate(false)`. Events with `AUTO_PROPAGATE = false` will not propagate by default, but you can enable it using `trigger.propagate(true)`. If there are multiple observers attached to a target, they will all be triggered by bubbling. They all share a bubbling state, which can be accessed mutably using `trigger.propagation_mut()` (`trigger.propagate` is just sugar for this). You can choose to implement `Traversal` for your own types, if you want to bubble along a different structure than provided by `bevy_hierarchy`. Implementers must be careful never to produce loops, because this will cause bevy to hang. ## Migration Guide + Manual implementations of `Event` should add associated type `Traverse = TraverseNone` and associated constant `AUTO_PROPAGATE = false`; + `Trigger::new` has new field `propagation: &mut Propagation` which provides the bubbling state. + `ObserverRunner` now takes the same `&mut Propagation` as a final parameter. --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com> Co-authored-by: Torstein Grindvik <52322338+torsteingrindvik@users.noreply.github.com> Co-authored-by: Carter Anderson <mcanders1@gmail.com>
1 parent d57531a commit ed2b8e0

File tree

16 files changed

+731
-53
lines changed

16 files changed

+731
-53
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,17 @@ description = "Demonstrates observers that react to events (both built-in life-c
25702570
category = "ECS (Entity Component System)"
25712571
wasm = true
25722572

2573+
[[example]]
2574+
name = "observer_propagation"
2575+
path = "examples/ecs/observer_propagation.rs"
2576+
doc-scrape-examples = true
2577+
2578+
[package.metadata.example.observer_propagation]
2579+
name = "Observer Propagation"
2580+
description = "Demonstrates event propagation with observers"
2581+
category = "ECS (Entity Component System)"
2582+
wasm = true
2583+
25732584
[[example]]
25742585
name = "3d_rotation"
25752586
path = "examples/transforms/3d_rotation.rs"

benches/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ rand_chacha = "0.3"
1212
criterion = { version = "0.3", features = ["html_reports"] }
1313
bevy_app = { path = "../crates/bevy_app" }
1414
bevy_ecs = { path = "../crates/bevy_ecs", features = ["multi_threaded"] }
15+
bevy_hierarchy = { path = "../crates/bevy_hierarchy" }
16+
bevy_internal = { path = "../crates/bevy_internal" }
17+
bevy_math = { path = "../crates/bevy_math" }
1518
bevy_reflect = { path = "../crates/bevy_reflect" }
19+
bevy_render = { path = "../crates/bevy_render" }
1620
bevy_tasks = { path = "../crates/bevy_tasks" }
1721
bevy_utils = { path = "../crates/bevy_utils" }
18-
bevy_math = { path = "../crates/bevy_math" }
19-
bevy_render = { path = "../crates/bevy_render" }
2022

2123
[profile.release]
2224
opt-level = 3

benches/benches/bevy_ecs/benches.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ use criterion::criterion_main;
33
mod components;
44
mod events;
55
mod iteration;
6+
mod observers;
67
mod scheduling;
78
mod world;
89

910
criterion_main!(
1011
components::components_benches,
1112
events::event_benches,
1213
iteration::iterations_benches,
14+
observers::observer_benches,
1315
scheduling::scheduling_benches,
1416
world::world_benches,
1517
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
use criterion::criterion_group;
2+
3+
mod propagation;
4+
use propagation::*;
5+
6+
criterion_group!(observer_benches, event_propagation);
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use bevy_app::{App, First, Startup};
2+
use bevy_ecs::{
3+
component::Component,
4+
entity::Entity,
5+
event::{Event, EventWriter},
6+
observer::Trigger,
7+
query::{Or, With, Without},
8+
system::{Commands, EntityCommands, Query},
9+
};
10+
use bevy_hierarchy::{BuildChildren, Children, Parent};
11+
use bevy_internal::MinimalPlugins;
12+
13+
use criterion::{black_box, criterion_group, criterion_main, Criterion};
14+
use rand::{seq::IteratorRandom, Rng};
15+
16+
const DENSITY: usize = 20; // percent of nodes with listeners
17+
const ENTITY_DEPTH: usize = 64;
18+
const ENTITY_WIDTH: usize = 200;
19+
const N_EVENTS: usize = 500;
20+
21+
pub fn event_propagation(criterion: &mut Criterion) {
22+
let mut group = criterion.benchmark_group("event_propagation");
23+
group.warm_up_time(std::time::Duration::from_millis(500));
24+
group.measurement_time(std::time::Duration::from_secs(4));
25+
26+
group.bench_function("baseline", |bencher| {
27+
let mut app = App::new();
28+
app.add_plugins(MinimalPlugins)
29+
.add_systems(Startup, spawn_listener_hierarchy);
30+
app.update();
31+
32+
bencher.iter(|| {
33+
black_box(app.update());
34+
});
35+
});
36+
37+
group.bench_function("single_event_type", |bencher| {
38+
let mut app = App::new();
39+
app.add_plugins(MinimalPlugins)
40+
.add_systems(
41+
Startup,
42+
(
43+
spawn_listener_hierarchy,
44+
add_listeners_to_hierarchy::<DENSITY, 1>,
45+
),
46+
)
47+
.add_systems(First, send_events::<1, N_EVENTS>);
48+
app.update();
49+
50+
bencher.iter(|| {
51+
black_box(app.update());
52+
});
53+
});
54+
55+
group.bench_function("single_event_type_no_listeners", |bencher| {
56+
let mut app = App::new();
57+
app.add_plugins(MinimalPlugins)
58+
.add_systems(
59+
Startup,
60+
(
61+
spawn_listener_hierarchy,
62+
add_listeners_to_hierarchy::<DENSITY, 1>,
63+
),
64+
)
65+
.add_systems(First, send_events::<9, N_EVENTS>);
66+
app.update();
67+
68+
bencher.iter(|| {
69+
black_box(app.update());
70+
});
71+
});
72+
73+
group.bench_function("four_event_types", |bencher| {
74+
let mut app = App::new();
75+
const FRAC_N_EVENTS_4: usize = N_EVENTS / 4;
76+
const FRAC_DENSITY_4: usize = DENSITY / 4;
77+
78+
app.add_plugins(MinimalPlugins)
79+
.add_systems(
80+
Startup,
81+
(
82+
spawn_listener_hierarchy,
83+
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 1>,
84+
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 2>,
85+
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 3>,
86+
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 4>,
87+
),
88+
)
89+
.add_systems(First, send_events::<1, FRAC_N_EVENTS_4>)
90+
.add_systems(First, send_events::<2, FRAC_N_EVENTS_4>)
91+
.add_systems(First, send_events::<3, FRAC_N_EVENTS_4>)
92+
.add_systems(First, send_events::<4, FRAC_N_EVENTS_4>);
93+
app.update();
94+
95+
bencher.iter(|| {
96+
black_box(app.update());
97+
});
98+
});
99+
100+
group.finish();
101+
}
102+
103+
#[derive(Clone, Component)]
104+
struct TestEvent<const N: usize> {}
105+
106+
impl<const N: usize> Event for TestEvent<N> {
107+
type Traversal = Parent;
108+
const AUTO_PROPAGATE: bool = true;
109+
}
110+
111+
fn send_events<const N: usize, const N_EVENTS: usize>(
112+
mut commands: Commands,
113+
entities: Query<Entity, Without<Children>>,
114+
) {
115+
let target = entities.iter().choose(&mut rand::thread_rng()).unwrap();
116+
(0..N_EVENTS).for_each(|_| {
117+
commands.trigger_targets(TestEvent::<N> {}, target);
118+
});
119+
}
120+
121+
fn spawn_listener_hierarchy(mut commands: Commands) {
122+
for _ in 0..ENTITY_WIDTH {
123+
let mut parent = commands.spawn_empty().id();
124+
for _ in 0..ENTITY_DEPTH {
125+
let child = commands.spawn_empty().id();
126+
commands.entity(parent).add_child(child);
127+
parent = child;
128+
}
129+
}
130+
}
131+
132+
fn empty_listener<const N: usize>(_trigger: Trigger<TestEvent<N>>) {}
133+
134+
fn add_listeners_to_hierarchy<const DENSITY: usize, const N: usize>(
135+
mut commands: Commands,
136+
roots_and_leaves: Query<Entity, Or<(Without<Parent>, Without<Children>)>>,
137+
nodes: Query<Entity, (With<Parent>, With<Children>)>,
138+
) {
139+
for entity in &roots_and_leaves {
140+
commands.entity(entity).observe(empty_listener::<N>);
141+
}
142+
for entity in &nodes {
143+
maybe_insert_listener::<DENSITY, N>(&mut commands.entity(entity));
144+
}
145+
}
146+
147+
fn maybe_insert_listener<const DENSITY: usize, const N: usize>(commands: &mut EntityCommands) {
148+
if rand::thread_rng().gen_bool(DENSITY as f64 / 100.0) {
149+
commands.observe(empty_listener::<N>);
150+
}
151+
}

crates/bevy_ecs/macros/src/component.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub fn derive_event(input: TokenStream) -> TokenStream {
1717

1818
TokenStream::from(quote! {
1919
impl #impl_generics #bevy_ecs_path::event::Event for #struct_name #type_generics #where_clause {
20+
type Traversal = #bevy_ecs_path::traversal::TraverseNone;
21+
const AUTO_PROPAGATE: bool = false;
2022
}
2123

2224
impl #impl_generics #bevy_ecs_path::component::Component for #struct_name #type_generics #where_clause {

crates/bevy_ecs/src/event/base.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::component::Component;
1+
use crate::{component::Component, traversal::Traversal};
22
#[cfg(feature = "bevy_reflect")]
33
use bevy_reflect::Reflect;
44
use std::{
@@ -34,7 +34,19 @@ use std::{
3434
label = "invalid `Event`",
3535
note = "consider annotating `{Self}` with `#[derive(Event)]`"
3636
)]
37-
pub trait Event: Component {}
37+
pub trait Event: Component {
38+
/// The component that describes which Entity to propagate this event to next, when [propagation] is enabled.
39+
///
40+
/// [propagation]: crate::observer::Trigger::propagate
41+
type Traversal: Traversal;
42+
43+
/// When true, this event will always attempt to propagate when [triggered], without requiring a call
44+
/// to [`Trigger::propagate`].
45+
///
46+
/// [triggered]: crate::system::Commands::trigger_targets
47+
/// [`Trigger::propagate`]: crate::observer::Trigger::propagate
48+
const AUTO_PROPAGATE: bool = false;
49+
}
3850

3951
/// An `EventId` uniquely identifies an event stored in a specific [`World`].
4052
///

crates/bevy_ecs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub mod removal_detection;
2929
pub mod schedule;
3030
pub mod storage;
3131
pub mod system;
32+
pub mod traversal;
3233
pub mod world;
3334

3435
pub use bevy_ptr as ptr;

0 commit comments

Comments
 (0)