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
Generalised ECS reactivity with Observers #10839
base: main
Are you sure you want to change the base?
Conversation
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. |
# Objective - Provide a reliable and performant mechanism to allows users to keep components synchronized with external sources: closing/opening sockets, updating indexes, debugging etc. - Implement a generic mechanism to provide mutable access to the world without allowing structural changes; this will not only be used here but is a foundational piece for observers, which are key for a performant implementation of relations. ## Solution - Implement a new type `DeferredWorld` (naming is not important, `StaticWorld` is also suitable) that wraps a world pointer and prevents user code from making any structural changes to the ECS; spawning entities, creating components, initializing resources etc. - Add component lifecycle hooks `on_add`, `on_insert` and `on_remove` that can be assigned callbacks in user code. --- ## Changelog - Add new `DeferredWorld` type. - Add new world methods: `register_component::<T>` and `register_component_with_descriptor`. These differ from `init_component` in that they provide mutable access to the created `ComponentInfo` but will panic if the component is already in any archetypes. These restrictions serve two purposes: 1. Prevent users from defining hooks for components that may already have associated hooks provided in another plugin. (a use case better served by observers) 2. Ensure that when an `Archetype` is created it gets the appropriate flags to early-out when triggering hooks. - Add methods to `ComponentInfo`: `on_add`, `on_insert` and `on_remove` to be used to register hooks of the form `fn(DeferredWorld, Entity, ComponentId)` - Modify `BundleInserter`, `BundleSpawner` and `EntityWorldMut` to trigger component hooks when appropriate. - Add bit flags to `Archetype` indicating whether or not any contained components have each type of hook, this can be expanded for other flags as needed. - Add `component_hooks` example to illustrate usage. Try it out! It's fun to mash keys. ## Safety The changes to component insertion, removal and deletion involve a large amount of unsafe code and it's fair for that to raise some concern. I have attempted to document it as clearly as possible and have confirmed that all the hooks examples are accepted by `cargo miri` as not causing any undefined behavior. The largest issue is in ensuring there are no outstanding references when passing a `DeferredWorld` to the hooks which requires some use of raw pointers (as was already happening to some degree in those places) and I have taken some time to ensure that is the case but feel free to let me know if I've missed anything. ## Performance These changes come with a small but measurable performance cost of between 1-5% on `add_remove` benchmarks and between 1-3% on `insert` benchmarks. One consideration to be made is the existence of the current `RemovedComponents` which is on average more costly than the addition of `on_remove` hooks due to the early-out, however hooks doesn't completely remove the need for `RemovedComponents` as there is a chance you want to respond to the removal of a component that already has an `on_remove` hook defined in another plugin, so I have not removed it here. I do intend to deprecate it with the introduction of observers in a follow up PR. ## Discussion Questions - Currently `DeferredWorld` implements `Deref` to `&World` which makes sense conceptually, however it does cause some issues with rust-analyzer providing autocomplete for `&mut World` references which is annoying. There are alternative implementations that may address this but involve more code churn so I have attempted them here. The other alternative is to not implement `Deref` at all but that leads to a large amount of API duplication. - `DeferredWorld`, `StaticWorld`, something else? - In adding support for hooks to `EntityWorldMut` I encountered some unfortunate difficulties with my desired API. If commands are flushed after each call i.e. `world.spawn() // flush commands .insert(A) // flush commands` the entity may be despawned while `EntityWorldMut` still exists which is invalid. An alternative was then to add `self.world.flush_commands()` to the drop implementation for `EntityWorldMut` but that runs into other problems for implementing functions like `into_unsafe_entity_cell`. For now I have implemented a `.flush()` which will flush the commands and consume `EntityWorldMut` or users can manually run `world.flush_commands()` after using `EntityWorldMut`. - In order to allowing querying on a deferred world we need implementations of `WorldQuery` to not break our guarantees of no structural changes through their `UnsafeWorldCell`. All our implementations do this, but there isn't currently any safety documentation specifying what is or isn't allowed for an implementation, just for the caller, (they also shouldn't be aliasing components they didn't specify access for etc.) is that something we should start doing? (see 10752) Please check out the example `component_hooks` or the tests in `bundle.rs` for usage examples. I will continue to expand this description as I go. See bevyengine#10839 for a more ergonomic API built on top of this one that isn't subject to the same restrictions and supports `SystemParam` dependency injection.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked through the example and some user-sided code, I'll try to review the rest soon
let Some(mut entity) = commands.get_entity(source) else { | ||
return; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the targeted observer runs, shouldn't this entity be guaranteed to exist?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had an entity validity check in the original dispatch code, but it got removed in a refactor, happy to add it back if we want to ensure that all event sources are alive. Currently two different mines can fire explode events at a specific entity and without this check it will try and create commands for itself after it's been despawned.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see this as more of a symptom of the way we are currently applying commands generated by hooks/observers, if we changed it to only apply new commands after the entire queue is flushed we wouldn't have this issue and the state would generally be more consistent. I think this is a discussion we should have before shipping this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix here: #13249
} | ||
|
||
/// Add [`ComponentId`] in `T` to the list of components listened to by this observer. | ||
pub fn components<B: Bundle>(&mut self) -> &mut Self { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unclear from this documentation whether components listened to by the observer behave in a similar way to events where you lose access to the data if you listen to multiple. Especially because events are components.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't get access to any data about the component other than it's ID so the component set is mostly internal. If you want to actually access the component you need to do it via a world reference or a query (providing a pointer gets gnarly for aliasing), but you're right this should be more clear, I think there are many places where the docs need fleshing out. There's also currently no way to get all the component ids if you have an observer that listens for OnAdd
on multiple components and then all of them are added simultaneously (after the first match for an event observers aren't fired again).
The whole events being components thing is a bit unfortunate as it's confusing to communicate when the "component" isn't actually ever attached to any entity, but building out an entirely new API for events when both are just "type registered to the ECS" seems overkill. We could hide this better like we do for resources, it just bloats out the PR more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not fully clear to me how components
work, I haven't ran into any direct explanation.
If I understand correctly, components
allows us to target OnAdd
, OnRemove
events on specific components (A or B or C...), but how do they work with custom events?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When OnAdd
is triggered it is triggered for a specific component on a specific entity i.e. entity X has component A added. So when creating an event you need some way to choose which components you are targeting (if any), that's what .components
does.
A given custom event doesn't need to be targeted at components, you can just target entities and any observers that are listening for that event will trigger, unless they are only listening for events that target a specific component.
This is a deviation from the flecs API in that flecs always requires you to target a component and all observers have to be listening to events targeting a specific set of components, we could do that as well in order to enforce the consistency of always having component targets, but on the other hand many users won't care about the component they are targeting and just want to send an event at an entity.
This is to avoid mixing terminology with Bevy Observers: bevyengine/bevy#10839
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looked through most of the code. I will need to revisit the observer
folder again, I didn't grasp how it all works yet.
crates/bevy_ecs/src/bundle.rs
Outdated
unsafe { | ||
deferred_world.trigger_on_add(archetype, entity, bundle_info.iter_components()); | ||
if archetype.has_add_observer() { | ||
deferred_world.trigger_observers( | ||
ON_ADD, | ||
Some(entity), | ||
bundle_info.iter_components(), | ||
); | ||
} | ||
deferred_world.trigger_on_insert(archetype, entity, bundle_info.iter_components()); | ||
if archetype.has_insert_observer() { | ||
deferred_world.trigger_observers( | ||
ON_INSERT, | ||
Some(entity), | ||
bundle_info.iter_components(), | ||
); | ||
} | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate unsafe code, the only difference is bundle_info
access in "add" section instead of add_bundle
access.
Probably worth separating it so we only maintain one unsafe section.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On experimentation I remembered why I didn't factor this out in the first place. You can't just pass the bundle_info down since sometimes you are using the add_bundle instead and you can't just pass the iterator down because you need to consume it twice to trigger hooks and then observers. It's a bit annoying.
flags: ArchetypeFlags, | ||
set: bool, | ||
) { | ||
// TODO: Refactor component index to speed this up. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are all TODOs here meant for followup PRs?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The component index is going to be another significant PR so I don't have it included here.
} | ||
|
||
/// Add [`ComponentId`] in `T` to the list of components listened to by this observer. | ||
pub fn components<B: Bundle>(&mut self) -> &mut Self { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not fully clear to me how components
work, I haven't ran into any direct explanation.
If I understand correctly, components
allows us to target OnAdd
, OnRemove
events on specific components (A or B or C...), but how do they work with custom events?
#[derive(Default, Debug)] | ||
pub struct Observers { | ||
// Cached ECS observers to save a lookup most common events. | ||
on_add: CachedObservers, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are not CachedComponentObservers
because the OnAdd
event can look at multiple components at once?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered putting CachedComponentObservers
here given that registering an observer to listen to all OnAdd
events is a pretty big performance footgun. Could still do that and just panic if you try and register that kind of observer.
world.init_component::<A>(); | ||
world.init_component::<B>(); | ||
|
||
world.observer(|_: Observer<OnAdd, (A, B)>, mut res: ResMut<R>| res.0 += 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this listens for events targeting A or B.
Is it possible to observe events where both A and B are added to an entity at the same time?
i.e. if the entire bundle gets added to the entity?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No there's no current way to listen to only events where they are both added, if you wanted that behavior you can just listen to one and check it against a query.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean listen to Observer<OnAdd, A>
and then in the observer system run a query such as Query<Entity, Added<B>>
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You wouldn't be able to ensure they were both just added, and I don't think that would generally be a desirable use case given that whether you add 2 components at the same time or not doesn't seem like it should matter.
I am considering "merging" queries and observers again like I had in the initial implementation to make expressing this easier such that you could at least be sure that the entity had both components.
The problem is the of existence of user implementations of WorldQuery make it really unclear which components you are listening to based on a QueryData
function signature. I feel like I'll have to leave that until after a hypothetical query v2.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How come I wouldn't be able to ensure that they were both just added? The Added
query doesn't work in side the observer system?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I keep replying before your message shows up for me which strangely interleaves the conversation and looks like I'm trying to respond to your questions. Added
theoretically could work in an observer system but that would only signify that the component was added since the last time the observer was run, not that the component was added as part of this event.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see; thanks.
It doesn't have to be part of the v1, but reacting on the Insertion of multiple components together is definitely something that could be useful.
I was actually thinking of switching to it in lightyear; instead of creating an event ReceivedComponent<C>
for each component that got replicated over the network, I could let users directly add observers Observers<OnAdd, (Replicate, C)>
or even Observers<OnAdd, (Replicate, C1, C2, ...)>
to be sure to target the correct entity
Objective
Solution
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:
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 anObserver
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 forOnInsert
andOnRemove
, this will be expanded with things such asArchetypeCreated
,TableEmpty
etc. in follow up PRs.The other way to register an observer is an "entity observer" that looks like this:
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:
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:For more advanced use cases there is the
ObserverBuilder
API that allows more control over the types of events that anObserver
will listen to.Dynamic components and event types are also fully supported allowing for runtime defined event types.
Design Questions
ObserverSystem
trait is not strictly necessary, I'm using it currently as a marker for systems that don't expectapply
to be run on it'sSystemParam
sincequeue
is run for observers instead, with clear documentation I don't think that's required in the long run.Possible Follow-ups
RemovedComponents
, observers should fulfill all use cases while being more flexible and performant.ObserverState
andQueryState
as well as unlocking several API improvements forQuery
and the management ofQueryState
.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.