Skip to content

feat(bevy_ecs): Allow attaching/spawning observers as a Bundle via Bundle Effects #19613

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

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions crates/bevy_ecs/src/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ pub trait BundleEffect {
fn apply(self, entity: &mut EntityWorldMut);
}

/// BundleEffect can be implemented directly or using the implementation for `FnOnce(&mut EntityWorldMut)`.
impl<E> BundleEffect for E
where
E: FnOnce(&mut EntityWorldMut) + Send + Sync + 'static,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is Send + Sync + 'static necessary here? They aren't required by the BundleEffect trait itself.

Suggested change
E: FnOnce(&mut EntityWorldMut) + Send + Sync + 'static,
E: FnOnce(&mut EntityWorldMut),

{
fn apply(self, entity: &mut EntityWorldMut) {
(self)(entity);
}
}

// SAFETY:
// - `Bundle::component_ids` calls `ids` for C's component id (and nothing else)
// - `Bundle::get_components` is called exactly once for C and passes the component's storage type based on its associated constant.
Expand Down Expand Up @@ -2118,6 +2128,65 @@ fn sorted_remove<T: Eq + Ord + Copy>(source: &mut Vec<T>, remove: &[T]) {
});
}

/// [`Patch`] wraps a [`BundleEffect`] into a [`Bundle`] allowing
/// you to leverage the power of a [`BundleEffect`] without having
/// to implement your own unsafe implementation of the Bundle trait.
/// [`BundleEffect`]s get access to [`EntityWorldMut`] on insert allowing for
/// powerful modifications to the world. [`BundleEffect`]s also power other Bevy
/// helper bundles like [`crate::spawn::SpawnIter`], [`crate::spawn::SpawnWith`], and [`crate::observer::Observer::as_bundle`].
/// You can implement [`BundleEffect`] directly or use the implementation of [`BundleEffect`]
/// for `FnOnce(&mut EntityWorldMut)`.
///
/// Note: [`crate::component::Component`] Hooks are almost as powerful as
Copy link
Member

Choose a reason for hiding this comment

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

I think this advice needs workshopping. You can do structural changes inside of hooks. The real tradeoff to me IMO is "always needed" vs "optionally needed" behavior. Perf might also be impacted, but I would really really want to see benchmarks before comparing.

/// [`BundleEffect`]s and should be used instead where possible. [`BundleEffect`]s
/// should only be used where structural ECS changes are required. For more details
/// see [`crate::world::DeferredWorld`]
///
/// Example, using [`Patch`] to add a [`crate::resource::Resource`] to the world
/// this is not possible with `[crate::component::Component]` Hooks because it is
/// a structural modification
///
/// ```rust
Copy link
Member

Choose a reason for hiding this comment

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

This doc test is going to need imports to pass CI :)

/// commands.spawn((
/// Node::default(),
/// Patch(|entity: &mut EntityWorldMut| {
/// // SAFETY: We don't modify the entity's position so this is safe
/// let world = unsafe { entity.world_mut() };
///
/// world.insert_resource(SomeResource);
/// }),
/// ))
/// ```
pub struct Patch<E>(pub E)
where
E: BundleEffect;

impl<E> DynamicBundle for Patch<E>
where
E: BundleEffect,
{
type Effect = E;

fn get_components(self, _func: &mut impl FnMut(StorageType, OwningPtr<'_>)) -> Self::Effect {
self.0
}
}

unsafe impl<F> Bundle for Patch<F>
where
F: FnOnce(&mut EntityWorldMut) + Send + Sync + 'static,
Copy link
Contributor

Choose a reason for hiding this comment

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

The DynamicBundle impl uses the general BundleEffect while this uses the more specific FnOnce bound. They should probably be the same.

Suggested change
F: FnOnce(&mut EntityWorldMut) + Send + Sync + 'static,
F: BundleEffect + Send + Sync + 'static,

Note that SpawnRelatedBundle and SpawnOneRelated have type Effect = Self, which means this will make Patch(Children::spawn_one(things)) compile. I think that's harmless, though?

{
fn component_ids(_components: &mut ComponentsRegistrator, _ids: &mut impl FnMut(ComponentId)) {}

fn get_component_ids(_components: &Components, _ids: &mut impl FnMut(Option<ComponentId>)) {}

fn register_required_components(
_components: &mut ComponentsRegistrator,
_required_components: &mut RequiredComponents,
) {
}
}

#[cfg(test)]
mod tests {
use crate::{
Expand Down
28 changes: 28 additions & 0 deletions crates/bevy_ecs/src/observer/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use alloc::{boxed::Box, vec};
use core::any::Any;

use crate::{
bundle::Patch,
component::{ComponentId, Mutable, StorageType},
error::{ErrorContext, ErrorHandler},
lifecycle::{ComponentHook, HookContext},
Expand Down Expand Up @@ -296,6 +297,33 @@ impl Observer {
pub fn descriptor(&self) -> &ObserverDescriptor {
&self.descriptor
}

/// Creates a [`Bundle`] that will spawn a new [`Observer`] (its own Entity)
/// observing this entity it's spawned on.
///
/// Example
/// ```rust
/// commands.spawn(
/// Node::default(),
/// Text::from("Not clicked"),
/// Observer::as_bundle(
/// |trigger: Trigger<Pointer<Click>>,
/// mut text: Query<&mut Text>| {
/// **text.get_mut(trigger.target).unwrap() = "Clicked!".to_string();
/// })
/// )
/// ```
pub fn as_bundle<E, B, M, O>(observer: O) -> impl Bundle
Copy link
Member

Choose a reason for hiding this comment

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

I don't like this name: it leaks too many implementation details and implies that the observer component is added to the same entity. Something like Observe::watch_this (uh, please bikeshed) would much more clearly communicate the intent in this code snippet.

Copy link

@greym0uth greym0uth Jun 13, 2025

Choose a reason for hiding this comment

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

Would this have any overlap with an On(|trigger: Trigger<...>| {}) BundleEffect like the on in the BSN prototype (https://github.com/bevyengine/bevy_editor_prototypes/blob/5dcfd5e19177f236379147f855383bfc75f761f1/crates/bevy_proto_bsn/src/bsn_helpers.rs#L41-L77)? I, personally, feel like this is a little weird to have implemented on the Observer struct and maybe would make more sense as it's own thing.

Copy link
Member

@cart cart Jun 13, 2025

Choose a reason for hiding this comment

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

I see the appeal and value of this PR, but it sadly violates some "key principles" that I would like to retain in the Bundle API:

  1. Things that are not components on entities should not appear to be components on entities. Put another way: bundles should always be thought of as collections of components added to the current entity. The Observer being added in the example above is in the "component position". Someone without intimate knowledge of the inner working of this would very likely assume that they are adding an Observer component to the entity in the Bundle. This is not what is happening. By enabling this sugar, we are making the system significantly harder to reason about. Notably, the Children::spawn() API (which motivated the bundle effect API) returns a bundle that inserts the Children component on the entity, so it does not violate that principle.
  2. BundleEffects should be written by people who are more likely to enforce (1): In Improved Spawn APIs and Bundle Effects #17521 I intentionally did not add sugar to make it easier for users to define BundleEffects for custom bundles because it would be easy (and desirable) for the community to violate this principle. By adding Patch, we're encouraging the community violate this principle (which they will want to do, as evidenced by this PR).

Again, I understand why people want this. But this is attempting to turn Bundle into an "arbitrary templating abstraction", which blurs the conceptual model (specifically, how hierarchies are formed) in a way that I think will be detrimental to peoples' understanding of the system. I'm even shooting for BSN, which is Bevy's planned "arbitrary templating abstraction", to do its best to keep the "perceived data model" intact.

For this specific case, we already have plans for how to make observers better:

  1. Make inline-component-driven observers viable by implying observer targets via relationships:
commands.spawn((
    Node::default(),
    Text::from("Not clicked"),
    // This adds the Observers component to the current entity and spawns the given Observers
    // as related entities
    observers![|trigger: On<Pointer<Click>>, mut text: Query<&mut Text>| {
         **text.get_mut(trigger.target).unwrap() = "Clicked!".to_string();
    }]
))

This builds on Bevy's existing data model without introducing ambiguities or confusion.

  1. Use BSN to further improve this experience:
bsn! {
    Node,
    Text("Not clicked"),
    Observers [|trigger: On<Pointer<Click>>, mut text: Query<&mut Text>| {
         **text.get_mut(trigger.target).unwrap() = "Clicked!".to_string();
    }]
}

And if for some reason we decide that we want inline "arbitrary effect templates" (such as "spawn an observer and relate it to the current entity), I think we should use a specific syntax in BSN to ensure the integrity of the conceptual model:

bsn! {
    Node,
    Text("Not clicked"),
    @On(|trigger: Trigger<Pointer<Click>>, mut text: Query<&mut Text>| {
      **text.get_mut(trigger.target).unwrap() = "Clicked!".to_string();
    })
}

Copy link
Member

Choose a reason for hiding this comment

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

Once BSN lands part of me wants to remove BundleEffect entirely in favor of BSN templates (in the interest of preserving the principles of the system / preventing the community from accidentally violating them). However being able to define hierarchies without BSN templates is nice, so I'm not currently planning of advocating for that.

where
E: Event,
B: Bundle,
M: Send + Sync + 'static,
O: IntoObserverSystem<E, B, M> + Send + Sync + 'static,
{
Patch(move |entity: &mut EntityWorldMut| {
entity.observe(observer);
})
}
}

impl Component for Observer {
Expand Down
Loading