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

Conversation

DylanRJohnston
Copy link

@DylanRJohnston DylanRJohnston commented Jun 13, 2025

Objective

Solution

Introduced for SpawnIter and SpawnWith, Bundle Effects are a powerful tool for manipulating the ECS world when a bundle is inserted. This is currently used in the aforementioned children spawning helper methods and has also be extensively used by the community for spawning observers inline. This helps simplify building complex UI by allowing the use of React style components of fn(props) -> impl Bundle. Without it, the relevant Bundle must be spawned so its entity ID can be obtained and then the observer added.

Testing

  • Several people including me made heavy use of this pattern during the most recent Bevy Jam 6 and it's a widely spread approach through the community with it being referenced a lot on Discord.
  • I'm unclear if the unsafe impl of Bundle is correct.

Showcase

Example Usage for a tooltip bundle that can be mixed into other UI nodes
pub fn tooltip(text: impl Into<String>) -> impl Bundle {
    (
        Observer::bundle(tooltip_hover_start(text.into())),
        Observer::bundle(tooltip_hover),
        Observer::bundle(tooltip_hover_end),
        Observer::bundle(tooltip_touch_end),
    )
}

#[derive(Debug, Clone, Copy, Component)]
pub struct Tooltip;

fn tooltip_bundle(text: String, position: Vec2) -> impl Bundle {
    (
        Tooltip,
        Node {
            padding: UiRect::all(Val::Px(8.0)),
            top: Val::Px(position.y),
            left: Val::Px(position.x),
            ..default()
        },
        GlobalZIndex(100),
        Pickable::IGNORE,
        BackgroundColor(Color::BLACK.with_alpha(0.8)),
        BorderRadius::all(Val::Px(8.0)),
        children![(
            Text::new(text),
            TextFont::from_font_size(18.0),
            Pickable::IGNORE
        )],
    )
}

fn tooltip_hover_start(text: String) -> impl Fn(Trigger<Pointer<Over>>, Commands, Res<UiScale>) {
    move |trigger, mut commands, ui_scale| {
        commands.spawn(tooltip_bundle(
            text.clone(),
            trigger.pointer_location.position / **ui_scale,
        ));
    }
}

fn tooltip_hover(
    trigger: Trigger<Pointer<Move>>,
    mut tooltips: Query<&mut Node, With<Tooltip>>,
    ui_scale: Res<UiScale>,
) {
    tooltips.iter_mut().for_each(|mut tooltip| {
        tooltip.top = Val::Px(trigger.pointer_location.position.y / **ui_scale);
        tooltip.left = Val::Px(trigger.pointer_location.position.x / **ui_scale);
    });
}

fn tooltip_hover_end(
    _: Trigger<Pointer<Out>>,
    tooltips: Query<Entity, With<Tooltip>>,
    mut commands: Commands,
) {
    tooltips
        .iter()
        .for_each(|tooltip| commands.entity(tooltip).despawn());
}

fn tooltip_touch_end(
    _: Trigger<Pointer<Click>>,
    tooltips: Query<Entity, With<Tooltip>>,
    mut commands: Commands,
) {
    tooltips
        .iter()
        .for_each(|tooltip| commands.entity(tooltip).despawn());
}

Copy link
Contributor

Welcome, new contributor!

Please make sure you've read our contributing guide and we look forward to reviewing your pull request shortly ✨

@alice-i-cecile alice-i-cecile added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events M-Needs-Release-Note Work that should be called out in the blog due to impact X-Uncontroversial This work is generally agreed upon S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Jun 13, 2025
@alice-i-cecile alice-i-cecile requested a review from chescock June 13, 2025 04:19
Copy link
Contributor

It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note.

Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes.

@alice-i-cecile alice-i-cecile added the D-Unsafe Touches with unsafe code in some way label Jun 13, 2025
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
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.

Wow, very cool.

Requests:

  1. Add this to the observer_overhaul.md release note.
  2. Add this to the docs for Observer and the observer module.
  3. Mention Observer::bundle in the BundleEffectFn docs.
  4. Demo / explain in Observer::bundle why this is useful.

@alice-i-cecile
Copy link
Member

@SkiFire13 can I get your eyes on the unsafe bits here?

@alice-i-cecile alice-i-cecile added this to the 0.17 milestone Jun 13, 2025
@SkiFire13
Copy link
Contributor

The unsafe bits looks fine to me, as the safety requirements are trivially satisfied because the bundle includes no components and hence never calls the closures given to it.

However I'd like to see a more flexible version of BundleEffectFn. Currently it is only usable with closures, meaning its type ends up being unnameable and you won't be able to include it in e.g. structs deriving Bundle (although that currently doesn't support effects, but it should be a trivial change in the derive macro)

/// powerful modifications to the world. Bundle Effects also power other Bevy
/// helper bundles like [`crate::spawn::SpawnIter`] and [`crate::spawn::SpawnWith`]
pub struct BundleEffectFn<F>(pub F)
/// 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.

/// // SAFETY: We don't modify the entity's position so this is safe
/// let world = unsafe { entity.world_mut() };
///
/// // This is not idempotent
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 think we should demo this in a doc test unless we can do it right.

///
/// Example, using a [`BundleEffect`] to add a system to the [`crate::schedule::PostUpdate`]
///
/// ```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 :)

/// })
/// )
/// ```
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.

@alice-i-cecile alice-i-cecile added X-Contentious There are nontrivial implications that should be thought through S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed X-Uncontroversial This work is generally agreed upon S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Jun 13, 2025
/// 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),


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?

/// })
/// )
/// ```
pub fn as_bundle<E, B, M, O>(observer: O) -> impl Bundle
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.

@cart
Copy link
Member

cart commented Jun 13, 2025

Closing this based on my comment here: #19613 (comment)

@cart cart closed this Jun 13, 2025
@alice-i-cecile alice-i-cecile moved this from Observer overhaul to Observers - Completed in Alice's Work Planning Jun 15, 2025
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-Unsafe Touches with unsafe code in some way M-Needs-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged X-Contentious There are nontrivial implications that should be thought through
Projects
Status: Observers - Completed
Development

Successfully merging this pull request may close these issues.

Observers as part of bundle
6 participants