Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Observers overhaul: statically-typed multi-event-listening and safer dynamic-event-listening #14674

Open
wants to merge 35 commits into
base: main
Choose a base branch
from

Conversation

ItsDoot
Copy link
Contributor

@ItsDoot ItsDoot commented Aug 9, 2024

Objective

Closes #14649.

Solution

The following presented solution is:

  • Safe: The previous (unsafe required) solution to statically-unknown event types now has a 100% safe alternative.
  • Ergonomic: Simply upgrade your observers by going from Trigger<Foo> to Trigger<(Foo, Bar)>.
  • Zero-abstraction overhead: Single-event observers remain as fast as they were prior, and multi-event observers have little or no additional overhead.
  • Backwards-compatible: All observers written for 0.14 will continue to function as expected without any code changes.

Event Sets

We introduce a new trait named EventSet that performs a role similar to WorldQuery, although with a much smaller API surface. An EventSet can be / is implemented by:

  • All normal Event types.
  • A tuple of normal Event types (up to arity =15). Supports nesting tuples inside tuples.
  • A DynamicEvent type that allows raw-pointer-style observation.
  • A SemiDynamicEvent type that allows you to first match on statically-known event types, and if none match operates like a DynamicEvent.

The most notable operations that an EventSet provides are:

  • unchecked_cast: Casts the raw pointer into the type of the output item.
  • matches: Returns true if the event set contains the provided event component ID.
  • init_components: Initializes all required event types to have component IDs.

Statically-known multiple-event observers

Your observers can now be upgraded from single-event observers to multiple-event observers with the simple change to a tuple for your Trigger generic parameter. If a single event type is specified in the trigger, then the returned item type remains a &Event or &mut Event. If a tuple of event types are specified in the trigger, then an enum with the same arity is the returned item type, for example: enum Or2<EventA, EventB> { A(EventA), B(EventB) }.

#[derive(Event)]
struct Foo(i32);
#[derive(Event)]
struct Bar(bool);
#[derive(Event)]
struct Baz(String);

// Also works with Commands!
world.observe(|trigger: Trigger<(Foo, Bar, Baz)> {
    match trigger.event() {
        Or3::A(Foo(v)) => { assert_eq!(5, *v); }
        Or3::B(Bar(v)) => { assert!(*v); }
        Or3::C(Baz(v)) => { assert_eq!("Hello world!", *v); }
    }
});
world.trigger(Foo(5));
world.trigger(Bar(true));
world.trigger(Baz("Hello world!".into()));

Dynamically-known event observers

The DynamicEvent type does not perform any pointer casting or type matching logic, and instead returns true when matching on ANY event type. This is most useful when you want to listen for event types that are not known at compile time. The returned items accessible with Trigger::event() and Trigger::event_mut() are Ptr and PtrMut, respectively, which are type-erased but lifetime'd pointers.

// Get our event component IDs somehow. They don't need to be statically known!
let foo_id = world.init_component::<Foo>();
let bar_id = world.init_component::<Bar>();

world.spawn(
    Observer::new(|trigger: Trigger<DynamicEvent> {
        // Called for foo and bar events
    })
    .with_event(foo_id) // This method was previously unsafe because it could result in UB.
    .with_event(bar_id) // But we don't do any casting automatically here, so we're safe! (It's on the user's side to do that)
);
// Trigger our runtime-known events dynamically:
unsafe {
    EmitDynamicTrigger::new_with_id(foo_id, Foo(10), ()).apply(&mut world);
    EmitDynamicTrigger::new_with_id(bar_id, Bar(false), ()).apply(&mut world);
}

Semi-dynamically-known event observers

For complex scenarios, we can also combine both statically-known and runtime-known event types in a single observer, using SemiDynamicEvent<Static>. This means all event types that don't match the specified statically-known event types will be returned as a Ptr/PtrMut. The returned item type for SemiDynamicEvent is Result<StaticallyKnownItems, PtrMut>.

#[derive(Event)]
struct Foo(i32);
#[derive(Event)]
struct Bar(bool);

// Get our event component IDs that are only runtime-known
let baz_id = world.init_component::<Baz>();

world.spawn(
    Observer::new(|trigger: Trigger<SemiDynamicEvent<(Foo, Bar)>>| {
        match trigger.event() {
            Ok(Or2::A(Foo(v))) => { assert_eq!(6, *v); },
            Ok(Or2::B(Bar(v))) => { assert!(*v); },
            Err(ptr) => { /* do stuff with our `Ptr` */ }
        }
    })
    .with_event(baz_id)
);

// Trigger our statically known event types
world.trigger(Foo(6));
world.trigger(Bar(true));
// Trigger our runtime-known events dynamically:
unsafe {
    EmitDynamicTrigger::new_with_id(baz_id, Baz("Hello world!".into()), ()).apply(&mut world);
}

TODO

  • Add a lot more docs.
  • Open a subsequent issue for adding a derive macro for custom EventSet types.

Testing

  • I've added 5 new tests:
    • observer_multiple_events_static: Tests functionality of reading event data from two different statically-known event types.
    • observer_multiple_events_dynamic: Tests DynamicEvent's behavior of passing pointers as-is.
    • observer_multiple_events_semidynamic: Tests SemiDynamicEvent's behavior of first trying to match on statically-known event types, and falling back to DynamicEvent if none match.
    • observer_propagating_multi_event_between: Tests that event sets do not interfere with current propagation code.
    • observer_propagating_multi_event_all: Same as above
  • I've added 3 new benchmarks:
    • observe_multievent: Tests the matching speed of tuple event sets from size 1 to 15 (No observed slowdowns).
      • The (A,) (size=1) tuple implementation simply forwards to A, which is equivalent to current observer code.
    • observe_dynamic: Tests the matching speed of runtime known event types with DynamicEvent (No observed slowdowns).
    • observe_semidynamic: Tests the matching speed of combined statically-known and runtime-known event types with SemiDynamicEvent (No observed slowdowns).

Migration Guide

Observer::with_event was renamed to Observer::with_event_unchecked. If you were using this function, consider using a new one that has been added under the old name, which isn't marked unsafe but requires your Trigger<E>'s E type to be either DynamicEvent or SemiDynamicEvent.

@ItsDoot ItsDoot added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events D-Unsafe Touches with unsafe code in some way labels Aug 9, 2024
@ItsDoot ItsDoot added the D-Complex Quite challenging from either a design or technical perspective. Ask for help! label Aug 9, 2024
@viridia
Copy link
Contributor

viridia commented Aug 9, 2024

Don't know if this helps, but here's an example of a macro that does tuple expansion: https://github.com/viridia/quill/blob/main/crates/bevy_mod_stylebuilder/src/lib.rs#L80

@ItsDoot ItsDoot changed the title Provide a safe and ergonomic way to observe multiple different event types Multi-Event-Type Observers Aug 10, 2024
@ItsDoot ItsDoot added the M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide label Aug 10, 2024
Copy link
Contributor

It looks like your PR is a breaking change, but you didn't provide a migration guide.

Could you add some context on what users should update when this change get released in a new version of Bevy?
It will be used to help writing the migration guide for the version. Putting it after a ## Migration Guide will help it get automatically picked up by our tooling.

@ItsDoot ItsDoot changed the title Multi-Event-Type Observers Observers overhaul: statically-typed multi-event-listening and safer dynamic-event-listening Aug 11, 2024
@ItsDoot ItsDoot marked this pull request as ready for review August 11, 2024 10:44
@ItsDoot ItsDoot added the S-Needs-Review Needs reviewer attention (from anyone!) to move forward label Aug 11, 2024
Copy link
Contributor

@cBournhonesque cBournhonesque left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I only looked at the tests)
I like the idea, and it seems to be a big amount of work, kudos!
I wonder if there would be a way to resolve some of the ergonomics:

  • introducing SemiDynamicEvent and DynamicEvent is a bit confusing
  • the user needing to use Or2, Or3, etc.
  • using Err as a match branch for dynamic events

world.observe(|t: Trigger<(Foo, Bar)>, mut res: ResMut<R>| {
res.0 += 1;
match t.event() {
Or2::A(Foo(v)) => assert_eq!(5, *v),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like what the PR enables, but I think there are some ergonomic papercuts that should be resolved. It's not great that the user has to remember to use Or2, Or3, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, unfortunately I don't think we're getting anything like anonymous enums anytime soon. Suggestions welcome on how to improve it!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t.event::<Foo>() returning an Option would be more ergonomic IMO and would also work for dynamic events by trying to downcast to Foo.

Probably wouldn't be as cheap as an enum for static events though (although I haven't looked at the code). If that's important, I suppose you could have the user define a named enum with a #[derive] to make it work in a Trigger. Verbose but easy to understand and use.

Observer::new(|_: Trigger<OnAdd, A>, mut res: ResMut<R>| res.0 += 1)
.with_event(on_remove)
},
Observer::new(|_: Trigger<DynamicEvent, A>, mut res: ResMut<R>| res.0 += 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at the tests to understand the new things you added; and I noticed that this test doesn't access the dynamic event? I think it would be nice for to showcase how to access the event from the observer

|trigger: Trigger<SemiDynamicEvent<OnAdd>, A>, mut res: ResMut<R>| {
match trigger.event() {
Ok(_onadd) => res.assert_order(0),
Err(_ptr) => res.assert_order(1),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Err is used to match on the dynamic event? That seems a bit unintuitive, since I would associate it with an error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I viewed it as "the statically known types failed to match", so returning as a pointer is a kind of failure condition. Would you recommend swapping it to Or2, instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO I would use a custom enum here, with Static and Dynamic arms.

crates/bevy_ecs/src/observer/mod.rs Outdated Show resolved Hide resolved
@ItsDoot
Copy link
Contributor Author

ItsDoot commented Aug 12, 2024

Removed the auto-registration feature of DynamicEvent and SemiDynamicEvent as it would only act as a source of confusion. I'm interested in combining both of the aforementioned types, but I want a new name for the combined form.

@cart cart self-assigned this Aug 12, 2024
Comment on lines +1088 to +1091
world.flush();
assert_eq!(3, world.resource::<R>().0);
world.trigger_targets(OtherPropagating, child);
world.flush();
Copy link
Contributor

@ramon-bernardo ramon-bernardo Aug 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also remove the last two flushes and enclose the result in a new context. See:

let grandparent = {};
let parent = {};
let child = {
    world
        .spawn(Parent(parent))
        .observe(
            |_: Trigger<(EventPropagating, OtherPropagating)>, mut res: ResMut<R>| {
                res.0 += 1
            },
        )
        .id()
};

// TODO: ideally this flush is not necessary, but right now observe() returns WorldEntityMut
// and therefore does not automatically flush.
world.flush();

world.trigger_targets(EventPropagating, child);
assert_eq!(3, world.resource::<R>().0);

world.trigger_targets(OtherPropagating, child);
assert_eq!(6, world.resource::<R>().0);

@13ros27
Copy link
Contributor

13ros27 commented Sep 1, 2024

As a note about the concerns for Or2, Or3 etc., while this is absolutely not a suggestion for this PR and isn't currently stable, when min_exhaustive_patterns is available on stable I believe we should be able to do this all with one enum, filling in all the unused slots with an uninhabited type (such as enum Void {}), which would then allow them to be ignored in match statements. See this example with eight variants, only three of which are inhabited. Obviously it couldn't then be called Or because that would clash with the query-access Or but they could all use the same type (with 15 generics).

EDIT: I didn't actually realise quite how close min_exhaustive_patterns is, looks like it should be in 1.82, so you don't actually need the feature flag in the playground example

Copy link
Member

@alice-i-cecile alice-i-cecile left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry: I didn't see this at all. I like the general motivation here, but I'm not fully sold on the ergonomics and API. My gut feeling was that this should be handled in a way akin to AnyOf, where the user gets a tuple of options back, defining which events were triggered, but I suppose that doesn't quite make sense here: you're not checking for all of the events at once 🤔 I'm very tempted to wait for min_exhaustive_patterns and see if it improves things.

More importantly, I'd like to see this work unified with #15287 and the conjunction behavior in #15327. #15325 is also related. That doesn't have to be this PR, but I'd like to see a plan for how this all fits together, and ideally get it all out in a single cycle.

The benchmarks are great, but docs are missing, making this a lot harder for users and reviewers to understand.

In summary:

  1. Yes, I want to do something like this.
  2. I want to see how this fits into broader reworks for hooks, observers and conjunction behavior and ship that all in a single cycle.
  3. Needs docs!

@iiYese
Copy link
Contributor

iiYese commented Sep 23, 2024

My gut feeling was that this should be handled in a way akin to AnyOf

AnyOf would make more sense if triggers were row polymorphic like entities.

cmds.entity(e).trigger((Damage(5), Poison))

Which would make Trigger function like Query. But that would be very beyond the scope of this. I don't think flecs even has this & I'm not convinced on the usefulness of it in the first place.

@alice-i-cecile
Copy link
Member

Yep, I think the AnyOf approach is wrong!

@alice-i-cecile alice-i-cecile added S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged S-Needs-Design-Doc This issue or PR is particularly complex, and needs an approved design doc before it can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! D-Unsafe Touches with unsafe code in some way M-Needs-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide S-Needs-Design-Doc This issue or PR is particularly complex, and needs an approved design doc before it can be merged S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged
Projects
Status: Respond (With Priority)
Development

Successfully merging this pull request may close these issues.

Observers that can observe multiple different event types
9 participants