Skip to content

Add OnMutate observer and demonstrate how to use it for UI reactivity #14520

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
Closed
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2837,6 +2837,17 @@ description = "Illustrates various features of Bevy UI"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "reactivity"
path = "examples/ui/reactivity.rs"
doc-scrape-examples = true

[package.metadata.example.reactivity]
name = "Reactivity"
description = "Demonstrates how to create reactive, automatically updating user interfaces in Bevy"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "ui_scaling"
path = "examples/ui/ui_scaling.rs"
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ Example | Description
[Font Atlas Debug](../examples/ui/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally)
[Overflow](../examples/ui/overflow.rs) | Simple example demonstrating overflow behavior
[Overflow and Clipping Debug](../examples/ui/overflow_debug.rs) | An example to debug overflow and clipping behavior
[Reactivity](../examples/ui/reactivity.rs) | Demonstrates how to create reactive, automatically updating user interfaces in Bevy
[Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
Expand Down
172 changes: 172 additions & 0 deletions examples/ui/reactivity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Reactivity is a technique that allows your UI to automatically update when the data that defines its state changes.
//!
//! This example demonstrates how to use reactivity in Bevy with observers.
//!
//! There are a few key benefits to using reactivity in your UI:
//!
//! - **Deduplication of Spawning and Updating Logic**: When you spawn an entity, you can declare what its value should be.
//! - **Automatic Updates**: When the data that defines your UI state changes, the UI will automatically update to reflect those changes.
//! - **Widget-bound Behavior**: By defining the behavior of a widget in the same place as its data, you can simply spawn the widget and let the spawned observers handle the rest.
//!
//! # Observers
//!
//! Observers are a way to listen for and respond to entity-targeted events.
//! In Bevy, they have several key properties:
//!
//! - You can access both the event and the entity that the event is targeted at.
//! - Observers can only be triggered via commands: any triggers will be deferred until the next synchronization point where exclusive world access is available.
//! - Observers occur immediately after the event is triggered.
//! - Observers can be used to trigger other events, creating a cascade of reactive updates.
//! - Observers can be set to watch for events targeting a specific entity, or for any event of that type.
//!
//! # Incrementalization
//!
//! In order to avoid recomputing the entire UI every frame, Bevy uses a technique called incrementalization.
//! This means that Bevy will only update the parts of the UI that have changed.
//!
//! The key techniques here are **change detection**, which is tracked and triggered by the `Mut` and `ResMut` smart pointers,
//! and **lifecycle hooks**, which are events emitted whenever components are added or removed (including when entities are spawned or despawned).
//!
//! This gives us a very powerful set of standardized events that we can listen for and respond to:
//!
//! - [`OnAdd`]: triggers when a matching component is added to an entity.
//! - [`OnInsert`]: triggers when a component is added to or overwritten on an entity.
//! - [`OnReplace`]: triggers when a component is removed from or overwritten on on an entity.
//! - [`OnRemove`]: triggers when a component is removed from an entity.
//!
//! Note that "overwritten" has a specific meaning here: these are only triggered if the components value is changed via a new insertion operation.
//! Ordinary mutations to the component's value will not trigger these events.
//!
//! However, we can opt into change-detection powered observers by calling `app.generate_on_mutate::<MyComponent>()`.
//! This will watch for changes to the component and trigger a [`OnMutate`] event targeting the entity whose component has changed.
//! It's important to note that mutations are observed whenever components are *added* to the entity as well,
//! ensuring that reactive behavior is triggered even when the widget is first spawned.
//!
//! In addition, arbitrary events can be defined and triggered, which is an excellent pattern for behavior that requires a more complex or specialized response.
//!
//! # This example
//!
//! To demonstrate these concepts, we're going to create a simple UI that displays a counter.
//! We'll then create a button that increments the counter when clicked.

use bevy::prelude::*;
use on_mutate::{GenOnMutate, OnMutate};

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.generate_on_mutate::<CounterValue>()
.generate_on_mutate::<Interaction>()
.add_systems(Startup, setup_ui)
.run();
}

#[derive(Component)]
struct CounterValue(u32);

fn setup_ui(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());

// Counter
let counter_entity = commands
.spawn(TextBundle { ..default() })
.insert(CounterValue(0))
.observe(
|trigger: Trigger<OnMutate<CounterValue>>,
mut query: Query<(&CounterValue, &mut Text)>| {
let (counter_value, mut text) = query.get_mut(trigger.entity()).unwrap();
*text = Text::from_section(counter_value.0.to_string(), TextStyle::default());
},
)
.id();

// Button
commands
.spawn(ButtonBundle {
style: Style {
width: Val::Px(100.),
height: Val::Px(100.),
justify_self: JustifySelf::End,
..default()
},
background_color: Color::WHITE.into(),
..default()
})
.observe(
move |trigger: Trigger<OnMutate<Interaction>>,
interaction_query: Query<&Interaction>,
mut counter_query: Query<&mut CounterValue>| {
let interaction = interaction_query.get(trigger.entity()).unwrap();
if matches!(interaction, Interaction::Pressed) {
// We can move this value into the closure that we define,
// allowing us to create custom behavior for the button.
let mut counter = counter_query.get_mut(counter_entity).unwrap();
counter.0 += 1;
}
},
);
}

/// This temporary module prototypes a user-space implementation of the [`OnMutate`] event.
///
/// This comes with two key caveats:
///
/// 1. Rather than being continually generated on every change between observers,
/// the list of [`OnMutate`] events is generated once at the start of the frame.
/// This restricts our ability to react indefinitely within a single frame, but is a good starting point.
/// 2. [`OnMutate`] will not have a generic parameter: instead, that will be handled via the second [`Trigger`] generic
/// and a static component ID, like the rest of the lifecycle events. This is just cosmetic.
///
/// To make this pattern hold up in practice, we likely need:
///
/// 0. Deep integration for the [`OnMutate`] event, so we can check for it in the same way as the other lifecycle events.
/// 1. Resource equivalents to all of the lifecycle hooks.
/// 2. Asset equivalents to all of the lifecycle hooks.
/// 3. Asset change detection.
///
/// As follow-up, we definitely want:
///
/// 1. Archetype-level change tracking.
/// 2. A way to automatically detect whether or not change detection triggers are needed.
/// 3. Better tools to gracefully exit observers when standard operations fail.
/// 4. Relations to make defining entity-links more robust and simpler.
/// 5. Nicer picking events to avoid having to use the naive OnMutate<Interaction> pattern.
///
/// We might also want:
///
/// 1. Syntax sugar to fetch matching components from the triggered entity in observers
mod on_mutate {
use super::*;
use std::marker::PhantomData;

/// A trigger emitted when a component is mutated on an entity.
///
/// This must be explicitly generated using [`GenOnMutate::generate_on_mutate`].
#[derive(Event, Debug, Clone, Copy)]
pub struct OnMutate<C: Component>(PhantomData<C>);

impl<C: Component> Default for OnMutate<C> {
fn default() -> Self {
Self(PhantomData)
}
}

/// A temporary extension trait used to prototype this functionality.
pub trait GenOnMutate {
fn generate_on_mutate<C: Component>(&mut self) -> &mut Self;
}

impl GenOnMutate for App {
fn generate_on_mutate<C: Component>(&mut self) -> &mut Self {
self.add_systems(First, watch_for_mutations::<C>);

self
}
}

fn watch_for_mutations<C: Component>(mut commands: Commands, query: Query<Entity, Changed<C>>) {
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 be using / encouraging this approach. Doing this as a normal "single pass over changed" system means that we are driving reactions to completion over the course of (possibly) many frames. The number of frames it will take to resolve a single propagation of changes is significantly expanded by a number of factors:

  1. Each mutation that triggers another mutation within a given component type will have a frame of delay if that entity is "behind" this entity in the change query iteration.
  2. Each mutation that triggers another mutation across component types will have a frame of delay if the watch_for_mutations system of the triggered mutations runs before the watch_for_mutations of the originating mutation's type.
  3. We only check if a component has changed once per frame, which means if it changes again, that will not be observed until the next frame.

Given that changes propagate in roughly hierarchy order many levels deep across many types, I anticipate resolving a single propagation tree to take many frames (on the order of seconds of clock time).

The "perceived jank cost" of allowing people to do this is too high I think.

Copy link
Member

@cart cart Jul 31, 2024

Choose a reason for hiding this comment

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

This would not be a full solve, but if we did these checks in topo-sorted hierarchy order and checked changed components for each relevant type at each entity, that would allow most UI-related changes to propagate in a single frame via a single pass. From there, you could loop multiple times over the hierarchy until all changes have been fully propagated (which would be a "full" solve, at the cost of being overly expensive due to searching the whole hierarchy an unnecessarily high number of times per frame).

Copy link
Contributor

Choose a reason for hiding this comment

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

Quill uses a "run to convergence" strategy: reactions are run in a loop within a single frame, but are required to converge to quiescence within a set number of iterations: https://github.com/viridia/quill/blob/main/crates/bevy_quill_core/src/view.rs#L508

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense!

// Note that this is a linear time check, even when no mutations have occurred.
// To accelerate this properly, we need to implement archetype-level change tracking.
commands.trigger_targets(OnMutate::<C>::default(), query.iter().collect::<Vec<_>>());
Copy link
Member

Choose a reason for hiding this comment

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

Collecting into a Vec seems like an arbitrary constraint here. Necessary in the context of commands.trigger_targets for obvious reasons, but I think this is worth optimizing via direct world access. Makes me want something like:

let mut resumable_iter = query_state.resumable_iter();

// notice the `world` argument in `next(world)`, which might (?!?) allows us to have full world access
// for each iteration of an Entity-only query
while let Some(entity) = resumable_iter.next(world) {
  world.trigger_targets(OnMutate::<C>::default(), entity);
}

Which would would allow us to cut out constructing the allocated vec with safe code.

}
}
Loading