Skip to content

Commit

Permalink
Minimal Bubbling Observers (#13991)
Browse files Browse the repository at this point in the history
# 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 <[email protected]>
Co-authored-by: Torstein Grindvik <[email protected]>
Co-authored-by: Carter Anderson <[email protected]>
  • Loading branch information
4 people authored Jul 15, 2024
1 parent d57531a commit ed2b8e0
Show file tree
Hide file tree
Showing 16 changed files with 731 additions and 53 deletions.
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2570,6 +2570,17 @@ description = "Demonstrates observers that react to events (both built-in life-c
category = "ECS (Entity Component System)"
wasm = true

[[example]]
name = "observer_propagation"
path = "examples/ecs/observer_propagation.rs"
doc-scrape-examples = true

[package.metadata.example.observer_propagation]
name = "Observer Propagation"
description = "Demonstrates event propagation with observers"
category = "ECS (Entity Component System)"
wasm = true

[[example]]
name = "3d_rotation"
path = "examples/transforms/3d_rotation.rs"
Expand Down
6 changes: 4 additions & 2 deletions benches/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ rand_chacha = "0.3"
criterion = { version = "0.3", features = ["html_reports"] }
bevy_app = { path = "../crates/bevy_app" }
bevy_ecs = { path = "../crates/bevy_ecs", features = ["multi_threaded"] }
bevy_hierarchy = { path = "../crates/bevy_hierarchy" }
bevy_internal = { path = "../crates/bevy_internal" }
bevy_math = { path = "../crates/bevy_math" }
bevy_reflect = { path = "../crates/bevy_reflect" }
bevy_render = { path = "../crates/bevy_render" }
bevy_tasks = { path = "../crates/bevy_tasks" }
bevy_utils = { path = "../crates/bevy_utils" }
bevy_math = { path = "../crates/bevy_math" }
bevy_render = { path = "../crates/bevy_render" }

[profile.release]
opt-level = 3
Expand Down
2 changes: 2 additions & 0 deletions benches/benches/bevy_ecs/benches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ use criterion::criterion_main;
mod components;
mod events;
mod iteration;
mod observers;
mod scheduling;
mod world;

criterion_main!(
components::components_benches,
events::event_benches,
iteration::iterations_benches,
observers::observer_benches,
scheduling::scheduling_benches,
world::world_benches,
);
6 changes: 6 additions & 0 deletions benches/benches/bevy_ecs/observers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
use criterion::criterion_group;

mod propagation;
use propagation::*;

criterion_group!(observer_benches, event_propagation);
151 changes: 151 additions & 0 deletions benches/benches/bevy_ecs/observers/propagation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use bevy_app::{App, First, Startup};
use bevy_ecs::{
component::Component,
entity::Entity,
event::{Event, EventWriter},
observer::Trigger,
query::{Or, With, Without},
system::{Commands, EntityCommands, Query},
};
use bevy_hierarchy::{BuildChildren, Children, Parent};
use bevy_internal::MinimalPlugins;

use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rand::{seq::IteratorRandom, Rng};

const DENSITY: usize = 20; // percent of nodes with listeners
const ENTITY_DEPTH: usize = 64;
const ENTITY_WIDTH: usize = 200;
const N_EVENTS: usize = 500;

pub fn event_propagation(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("event_propagation");
group.warm_up_time(std::time::Duration::from_millis(500));
group.measurement_time(std::time::Duration::from_secs(4));

group.bench_function("baseline", |bencher| {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_systems(Startup, spawn_listener_hierarchy);
app.update();

bencher.iter(|| {
black_box(app.update());
});
});

group.bench_function("single_event_type", |bencher| {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_systems(
Startup,
(
spawn_listener_hierarchy,
add_listeners_to_hierarchy::<DENSITY, 1>,
),
)
.add_systems(First, send_events::<1, N_EVENTS>);
app.update();

bencher.iter(|| {
black_box(app.update());
});
});

group.bench_function("single_event_type_no_listeners", |bencher| {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_systems(
Startup,
(
spawn_listener_hierarchy,
add_listeners_to_hierarchy::<DENSITY, 1>,
),
)
.add_systems(First, send_events::<9, N_EVENTS>);
app.update();

bencher.iter(|| {
black_box(app.update());
});
});

group.bench_function("four_event_types", |bencher| {
let mut app = App::new();
const FRAC_N_EVENTS_4: usize = N_EVENTS / 4;
const FRAC_DENSITY_4: usize = DENSITY / 4;

app.add_plugins(MinimalPlugins)
.add_systems(
Startup,
(
spawn_listener_hierarchy,
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 1>,
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 2>,
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 3>,
add_listeners_to_hierarchy::<FRAC_DENSITY_4, 4>,
),
)
.add_systems(First, send_events::<1, FRAC_N_EVENTS_4>)
.add_systems(First, send_events::<2, FRAC_N_EVENTS_4>)
.add_systems(First, send_events::<3, FRAC_N_EVENTS_4>)
.add_systems(First, send_events::<4, FRAC_N_EVENTS_4>);
app.update();

bencher.iter(|| {
black_box(app.update());
});
});

group.finish();
}

#[derive(Clone, Component)]
struct TestEvent<const N: usize> {}

impl<const N: usize> Event for TestEvent<N> {
type Traversal = Parent;
const AUTO_PROPAGATE: bool = true;
}

fn send_events<const N: usize, const N_EVENTS: usize>(
mut commands: Commands,
entities: Query<Entity, Without<Children>>,
) {
let target = entities.iter().choose(&mut rand::thread_rng()).unwrap();
(0..N_EVENTS).for_each(|_| {
commands.trigger_targets(TestEvent::<N> {}, target);
});
}

fn spawn_listener_hierarchy(mut commands: Commands) {
for _ in 0..ENTITY_WIDTH {
let mut parent = commands.spawn_empty().id();
for _ in 0..ENTITY_DEPTH {
let child = commands.spawn_empty().id();
commands.entity(parent).add_child(child);
parent = child;
}
}
}

fn empty_listener<const N: usize>(_trigger: Trigger<TestEvent<N>>) {}

fn add_listeners_to_hierarchy<const DENSITY: usize, const N: usize>(
mut commands: Commands,
roots_and_leaves: Query<Entity, Or<(Without<Parent>, Without<Children>)>>,
nodes: Query<Entity, (With<Parent>, With<Children>)>,
) {
for entity in &roots_and_leaves {
commands.entity(entity).observe(empty_listener::<N>);
}
for entity in &nodes {
maybe_insert_listener::<DENSITY, N>(&mut commands.entity(entity));
}
}

fn maybe_insert_listener<const DENSITY: usize, const N: usize>(commands: &mut EntityCommands) {
if rand::thread_rng().gen_bool(DENSITY as f64 / 100.0) {
commands.observe(empty_listener::<N>);
}
}
2 changes: 2 additions & 0 deletions crates/bevy_ecs/macros/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub fn derive_event(input: TokenStream) -> TokenStream {

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

impl #impl_generics #bevy_ecs_path::component::Component for #struct_name #type_generics #where_clause {
Expand Down
16 changes: 14 additions & 2 deletions crates/bevy_ecs/src/event/base.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::component::Component;
use crate::{component::Component, traversal::Traversal};
#[cfg(feature = "bevy_reflect")]
use bevy_reflect::Reflect;
use std::{
Expand Down Expand Up @@ -34,7 +34,19 @@ use std::{
label = "invalid `Event`",
note = "consider annotating `{Self}` with `#[derive(Event)]`"
)]
pub trait Event: Component {}
pub trait Event: Component {
/// The component that describes which Entity to propagate this event to next, when [propagation] is enabled.
///
/// [propagation]: crate::observer::Trigger::propagate
type Traversal: Traversal;

/// When true, this event will always attempt to propagate when [triggered], without requiring a call
/// to [`Trigger::propagate`].
///
/// [triggered]: crate::system::Commands::trigger_targets
/// [`Trigger::propagate`]: crate::observer::Trigger::propagate
const AUTO_PROPAGATE: bool = false;
}

/// An `EventId` uniquely identifies an event stored in a specific [`World`].
///
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_ecs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod removal_detection;
pub mod schedule;
pub mod storage;
pub mod system;
pub mod traversal;
pub mod world;

pub use bevy_ptr as ptr;
Expand Down
Loading

0 comments on commit ed2b8e0

Please sign in to comment.