Generalised ECS reactivity with Observers
Objective
- Provide an expressive way to register dynamic behavior in response to ECS changes that is consistent with existing bevy types and traits as to provide a smooth user experience.
- Provide a mechanism for immediate changes in response to events during command application in order to facilitate improved query caching on the path to relations.
Solution
- A new fundamental ECS construct, the
Observer; inspired by flec's observers but adapted to better fit bevy's access patterns and rust's type system.
Examples
There are 2 main ways to register observers. The first is a "component observer" that looks like this:
world.observer(|observer: Observer<OnAdd, CompA>, query: Query<&CompA>| {
// ...
});
The above code will spawn a new entity representing the observer that will run it's callback whenever CompA is added to an entity. This is a system-like function that supports dependency injection for all the standard bevy types: Query, Res, Commands etc. It also has an Observer parameter that provides information about the event such as the source entity. Importantly these systems run during command application which is key for their future use to keep ECS internals up to date. There are similar events for OnInsert and OnRemove, this will be expanded with things such as ArchetypeCreated, TableEmpty etc. in follow up PRs.
The other way to register an observer is an "entity observer" that looks like this:
world.entity_mut(entity).observe(|observer: Observer<Resize>| {
// ...
});
Entity observers trigger whenever an event of their type is targeted at that specific entity. This type of observer will de-spawn itself if the entity it is observing is ever de-spawned so as to not leave dangling observers.
Entity observers can also be spawned from deferred contexts such as other observers, systems, or hooks using commands:
commands.entity(entity).observe(|observer: Observer<Resize>| {
// ...
});
Observers are not limited to in built event types, they can be used with any event type that implements Component (this could be split into it's own event type but eventually they should all become entities). This means events can also carry data:
#[derive(Component)]
struct Resize { x: u32, y: u32 }
commands.entity(entity).observe(|observer: Observer<Resize>, query: Query<&mut Size>| {
let data = observer.data();
// ...
});
// Will trigger the observer when commands are applied.
commands.event(Resize { x: 10, y: 10 }).target(entity).emit();
For more advanced use cases there is the ObserverBuilder API that allows more control over the types of events that an Observer will listen to.
world.observer_builder()
// Listen to events targeting A or B
.components::<(A, B)>()
// Add multiple event types, this prevents accessing typed event data but allows Observers
// to listen to any number of events (and still allows untyped access if required)
.on_event::<OnAdd>()
.on_event::<OnInsert>()
.run(|observer: Observer<_>| {
// Runs on add or insert of components A or B
});
Dynamic components and event types are also fully supported allowing for runtime defined event types.
Design Questions
- Currently observers are implemented as entities which I believe to be ideal in the mid to long-term, however this does mean they aren't really usable in the render world. Alternatively I could implement them with their own IDs for now but would then need to port them back to entities at a later date.
- The
ObserverSystemtrait is not strictly necessary, I'm using it currently as a marker for systems that don't expectapplyto be run on it'sSystemParamsincequeueis run for observers instead, with clear documentation I don't think that's required in the long run.
Possible Follow-ups
- Deprecate
RemovedComponents, observers should fulfill all use cases while being more flexible and performant. - Queries as entities: Swap queries to entities and begin using observers listening to archetype creation events to keep their caches in sync, this allows unification of
ObserverStateandQueryStateas well as unlocking several API improvements forQueryand the management ofQueryState. - Event bubbling: For some UI use cases in particular users are likely to want some form of event bubbling for entity observers, this is trivial to implement naively but ideally this includes an acceleration structure to cache hierarchy traversals.
- All kinds of other in-built event types.
- Optimization; in order to not bloat the complexity of the PR I have kept the implementation straightforward, there are several areas where performance can be improved. The focus for this PR is to get the behavior implemented and not incur a performance cost for users who don't use observers.
I am leaving each of these to follow up PR's in order to keep each of them reviewable as this already includes significant changes.
The generated examples/README.md is out of sync with the example metadata in Cargo.toml or the example readme template. Please run cargo run -p build-templated-pages -- update examples to update it, and commit the file change.
The generated examples/README.md is out of sync with the example metadata in Cargo.toml or the example readme template. Please run cargo run -p build-templated-pages -- update examples to update it, and commit the file change.
The generated examples/README.md is out of sync with the example metadata in Cargo.toml or the example readme template. Please run cargo run -p build-templated-pages -- update examples to update it, and commit the file change.
You added a new example but didn't add metadata for it. Please update the root Cargo.toml file.
You added a new example but didn't add metadata for it. Please update the root Cargo.toml file.
You added a new example but didn't add metadata for it. Please update the root Cargo.toml file.
Very keen on the ability to remove RemovedComponents: that API is full of footguns and quite limited.
@james-j-obrien hooks are now merged, so when you get a chance a rebase will be very helpful to ease review.
Is this ready for review? It's still marked as draft
The PR is broadly functional and the API is pretty close to what I expect the final API to look like.
Before this would be ready to be merged there are some optimizations I would like to implement as well as cleaning up some of the internal implementation. I also don't have any tests or a particularly compelling example.
I'm not sure at exactly what point it should come out of draft but I want to take at least one more cleanup pass and add a few tests.
Could we try constructing and synchronizing simple hashmap-based index as the example here? That seems like a useful application.
I have added a modest set of tests, fixed several bugs and revamped the example to be more complex.
I'm going to take this out of draft now that I am more confident in it's robustness, however there are still areas where the PR is lacking; documentation is sparse and doc tests are non-existent.
Currently observers are implemented as entities which I believe to be ideal in the mid to long-term, however this does mean they aren't really usable in the render world. Alternatively I could implement them with their own IDs for now but would then need to port them back to entities at a later date.
I think this is the right call. The render world has got by fine without observers, and using a custom ID type would be pretty direct tech debt.
The ObserverSystem trait is not strictly necessary, I'm using it currently as a marker for systems that don't expect apply to be run on it's SystemParam since queue is run for observers instead, with clear documentation I don't think that's required in the long run.
IMO this should be cut. I don't think the complexity is warranted and I worry about forgetting about this distinction in future feature work.
Okay, so full review thoughts:
-
ObserverSystemshould be cut for complexity. - The "ECS events" terminology used by observers needs to change to something that doesn't conflict with
Event. - We should strongly consider adding a marker trait beyond simply "Component" to use for the observer events. I think it will make the actual patterns much clearer and avoid directly exposing users to the implementation details.
- Documentation needs to be significantly enhanced. I can help with that, but I still don't fully grok the patterns and structures here yet.
Fully agree with all that feedback, as far as the terminology is concerned we could use something like Trigger since it communicates the fact that it is immediate compared to the buffered Event.
I'm struggling to come up with other alternative naming, open to suggestions.
Could just use EntityEvent like bevy_mod_picking. Would also mean people that have used mod picking can just reuse their understanding from that. Alternatively we could rename our current Event.
How about:
- Keep observer events as
Event - Rename our current
EventtoSignal
?
Signal as a term has become associated with UI reactivity signals more so than something buffered like the current Event.
Based on the discussion in the relations working group Trigger seems to be the most popular option.
I believe if there's gonna be reactivity in Bevy it should follow the already established nomenclature that people are already familiar with. The word trigger indicates something that definitely triggers a concrete something, but with Observable-s, it's not guaranteed that anyone listens, and that's indicated in the name.
An Observer is a system that may or may not be part of N Subscriptions An Observable (Trigger) is a source of events that can Notify Subscribed Observers A Subscription (ObserverDescriptor?) is the active relationship between Observable and Observer
I'd use Observable instead of Trigger, and Subscription instead of ObserverDescriptor, and instead of triggering something, you'd emit, and internally you'd notify subscribers.
But apart from bikeshedding:
- Are subscriptions cancellable? What is/will be the mechanism to stop an observer from observing? A single subscription should be identifiable (SubscriptionId?) to allow cancellation.
- It seems like there only one global event source for each event type, can a many-to-many relationship be established between observers and observables of the same type? I think Observables should be able to be entities (It makes sense not to be entities for truly global events coming from the World).
Here's a game example (that is not the best for this usecase but simple enough to demonstrate the capability needed for a feature like this):
2D RTS Game. There are fixed beacons on the map that heal stuff in a fixed range around them every 2 seconds, and little robots scuttle around them, as they get in the range of one, they subscribe to its healing effect and as they leave they unsubscribe from it.
In your mine examples this would enable separating the "what should happen" and the "to whom should it happen"
I disagree on the naming in the sense that Observable is typically something used to designate the subject being observed not the type of the state change being observed. In that sense all entities are Observable and so is the world since you don't need to have a Trigger target a specific entity at all. Even in your link they refer to events being sent between the Observable and the Observer, that is what we are designating as a Trigger.
An Observer is an entity so you can despawn it and it will no longer trigger, you can also check any state you want in the observer callback to determine if logic should run. You can observe triggers targeting specific entities/components or listen for all of that type of trigger so many-to-many relationships are allowed.
For your example I don't think observers are the right tool in an ECS. I would model that as a system that runs each time the beacons should heal and immediately update it's nearby entities rather than add indirection through an observer. If you wanted indirection through events you can just use the buffered events that already exist, it would likely be more performant than using an observer if you have a non-trivial amount of units.
Observers are more useful when you need to communicate a change that you want to happen as soon as commands are next applied, for example maintaining an index that is required to uphold other invariants or an infrequent update that you want to apply as soon as it happens within a single frame, most game logic can still be modelled with systems and events as they exist today.
My #1 question is a philosophical one. Observers are effectively entities, that contain a component, that is a system. This feels like its really bluring the edges of the boundaries of ECS.
Why is this better than peaking (not consuming) events in a regular system? What problem does this solve?
Why is this better than peaking (not consuming) events in a regular system? What problem does this solve?
- Systems have to be somewhere in a schedule that is directly after where the event is produced. Observers can react to ~~events~~ triggers in any sync point.
- There's lots of knock on effects to them being entities including satisfying cleanup invariants when we add relations. There's a lot of forward planning here.
It's not wrong to conceptualize an observer as a system represented by an entity that runs when triggered, but there a few advantages this implementation offers compared to what's currently possible that is key in the way they are intended to be used.
- Observers are not buffered in any way, an
OnAddobserver is triggered during the application of the command that added that component, this places some restrictions on what you can do in the attached system, but it also allows you to keep data in sync in a way that is currently not expressible. - Triggers are matched with observers on more than just type, they also can listen to triggers targeting certain components (e.g.
OnAddforMyComponent) or specific entities. This is important for query caches, as a motivating example, who want to listen to all events targeting the components they are interested in.
For now I'm back on team "internal observer cache storage" in the interest of moving forward. I do think its a solid design given the constraints, and the current impl is good. Thanks for waiting for me to fully process the situation. I do still have suspicions that we could remove the specialized storage, but that is low priority / superficial / maybe not worth it.
Just pushed some changes for consideration:
- Make ObserverSystemComponent directly spawnable. This involved reworking how ObserverSystemComponent and ObserverComponent relate to each other. ObserverSystemComponent "drives" the construction of ObserverComponent. ObserverComponent uses a hook to register itself when it is added.
- Removed the need to manually initialize observer components by reworking the lifecycle.
- Reworked the trigger api to use a TriggerTarget trait, which is implemented for types such as ComponentId, Entity, Vec<ComponentId>, etc. This removes the need for builders and "emit" calls. There are now two variants:
world.trigger(X)(global) andworld.trigger_targets(X, targets). This feels much cleaner to me. - Removed TriggerBuilder and ObserverBuilder in favor of direct construction. If we decide we need ergonomic T->ComponentId builder patterns for the more nonstandard use cases, we can implement a builder for something that implements TriggerTarget. I suspect that most of these use cases will be internal.
- Added App helper methods (and ported the example to use them)
That resolves my major hangups with the impl.
I think we should consider a few more changes:
- I think we should try to unify Event and Trigger with the intent to (ultimately) make normal "buffered" events triggerable.
- We should rename the current
Observer<T>system param to something likeTrigger<T>. The param is not an observer, it is data about the trigger that happened. - We should rename the
ObserverSystemComponentandObserverComponentto something without the Component suffix, given that these are now front and center in the api.
Just pushed a unification of Events + Triggers, as discussed above, as well as relevant renames. (open to discussion ... this is just for consideration)
Just pushed some more changes:
- Renamed
ObserverSystemComponenttoObserver - Renamed
ObserverComponenttoObserverState - Implemented new logic to despawn Observers when all of their target entities have been despawned. This still the
ObservedBycomponent, but the way it behaves has changed. - Removed the AttachObserver command, as it is no longer used.
I think this is in a pretty good spot now. I just tried resolving merge conflicts, but it resulted in Explode not being triggered in the observers.rs example. I wonder if this somehow relates to #13249
The problem appears to be that Commands added in observers are not run.
"trigger" is printed but "Commands ran" is not.
This certainly feels like #13249 could relate.
The version before that pr was looping until the world's command queue is empty. Not 100% sure, but couldn't find the place we are doing that in the current code. https://github.com/bevyengine/bevy/pull/13249/files#diff-badf600e116f81aa58524486dea99ee4bb337143cca99a57339dda74f4661bc4L191
Bawhaha ok I've solved the first problem, but that revealed a new one :)
The issue was that on main we've gone from a derived SystemParam for Commands to a manually provided one. The manually implemented Commands didn't implement SystemParam::queue (which has a default "empty" impl). The fix was to add a queue impl. This does result in the commands being triggered.
However we now have a new problem: we now Explode in an infinite loop for some reason.
I'll push my changes so we're all looking at the same code: the merge and the Commands::queue fix.