Replies: 5 comments
-
|
I think it would be helpful to benchmark what the performance of system-like and observer-like reactions might look like. |
Beta Was this translation helpful? Give feedback.
-
|
I'm honestly fine with either. Whichever leads to better ergonomics, I'm not worried about performance. System-driven is how I did it in bevy_dioxus, and it worked fine. You can also always let people re-add the system to different schedules if they want. Observers are also an option, especially if bsn macro can implicitly add them. The difficulty is then cleaning them up when needed. |
Beta Was this translation helpful? Give feedback.
-
|
I feel like system based reactivity is how you would solve the diamond problem anyways. Polling every reactive data source every frame is no worse than any other Changed<> query, right? So that feels like a design decision that we've already made. |
Beta Was this translation helpful? Give feedback.
-
|
@viridia re: the diamond problem from the link you shared, i don’t think this is actually a fundamental issue for mutation observers if and only if we’re doing the observer layer through async reactivity. the important difference is that, in async reactivity, we can know exactly when the async ui computations have reached quiescence. once node b and node c have both finished their async observer work, we can immediately propagate the mutation observer event for node d, with no frame delay. if node d then causes more recomputation, that’s still fine: it just becomes another propagation wave in the same async sync-point loop. so the diamond case would look roughly like: the key is that node d’s observer should not fire eagerly after only b updates, because c may still be in flight. instead, mutation observers would fire only after the current async observer wave has drained. there are two possible ways to model this:
i’m leaning toward option 2, because it makes mutation observers automatic rather than requiring users to manually identify diamond-shaped dependency graphs. this fits naturally with the async bridge. right now the sync point exits once there is no immediate bridge work left: pub fn async_world_sync_point<SyncPoint: 'static>(world: &mut World) {
let sync_point = async_world_sync_point::<SyncPoint>
.into_system_set()
.intern();
let async_world = world.get_resource::<StrongAsyncWorld>().unwrap().clone();
let max_ticks = world.get_resource::<AsyncTickBudget>().unwrap().0;
for _ in 0..max_ticks {
if async_world.0.tick_sync_point(sync_point, world) == TickResult::NoWork {
#[cfg(feature = "bevy_tasks")]
bevy_tasks::cfg::web! {
if {} else {
bevy_tasks::tick_global_task_pools_on_main_thread();
}
}
if async_world.0.tick_sync_point(sync_point, world) == TickResult::NoWork {
return;
}
}
}
}for ui, we could have a specialized version of this bridge, or a small hook in this one, where instead of immediately returning after no more async work is available, we first flush any queued async mutation observers. if those mutation observers cause more async work, the sync point keeps flowing until it reaches quiescence again. pub fn async_ui_sync_point<SyncPoint: 'static>(world: &mut World) {
let sync_point = async_world_sync_point::<SyncPoint>
.into_system_set()
.intern();
let async_world = world.resource::<StrongAsyncWorld>().clone();
let max_ticks = world.resource::<AsyncTickBudget>().0;
for _ in 0..max_ticks {
let async_work_ran = async_world.0.tick_sync_point(sync_point, world);
// after each async wave, flush mutation observers whose watched
// components changed during that wave.
let observer_work_ran = flush_async_mutation_observers(world);
if async_work_ran == TickResult::NoWork && !observer_work_ran {
#[cfg(feature = "bevy_tasks")]
bevy_tasks::tick_global_task_pools_on_main_thread();
let async_work_ran = async_world.0.tick_sync_point(sync_point, world);
let observer_work_ran = flush_async_mutation_observers(world);
if async_work_ran == TickResult::NoWork && !observer_work_ran {
return;
}
}
}
}then mutation observers become much less scary, because we can track them automatically. for example, whenever an async mutation observer for component #[derive(Resource)]
pub struct MutationTracker<C: Component> {
active_observer_count: AtomicUsize,
_marker: PhantomData<C>,
}then, after each async observer wave, if fn flush_mutations<C: Component>(
tracker: Res<MutationTracker<C>>,
changed: Query<Entity, Changed<C>>,
mut commands: Commands,
) {
if tracker.active_observer_count.load(Ordering::Relaxed) == 0 {
return;
}
for entity in &changed {
commands.trigger_targets(AsyncMutation::<C>, entity);
}
}for very common components like #[derive(Resource)]
pub struct MutationTracker<C: Component> {
watched_entities: Vec<Entity>,
_marker: PhantomData<C>,
}then the flush only checks the watched entities rather than every changed the reason i think this works particularly well here is that rust async gives us a useful property: our async computations are pollable state machines, and the bridge can know when the current wave of async ui work is idle. The instant the async wave is idle, we can propagate mutation observers. i don’t think javascript exposes the same kind of explicit quiescence point, because the event loop/microtask model doesn’t give user code the same direct view into whether all relevant async reactive computations have drained. that means this model could never have been explored in javascript, but we have an oppertunity to exploit it here in rust. so the proposal is:
i think this makes mutation observers not just manageable, but fully automatic and opt-in in async land, with no special casing or registering. A proof of concept of async mutation observers that also solve the diamond problem is here: https://github.com/MalekiRe/bevy_malek_async/blob/master/examples/button_mutation.rs |
Beta Was this translation helpful? Give feedback.
-
|
I suggest both paths, backed by the Push mechanism of Mutation Events that uses a This gives you Push-style:Add a Mutate Event that fires from This prevents diamond problem for Push-style:In This gives you Pull-style, via Push-style:Add a Entity Component Trigger that supports Notes:Query will want to only be Instead of a ParallelCommandQueue from QueryState, we could maybe borrow it from the SystemState? or perhaps even better to change World's commands to Parallel, then borrow from World? |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
I've previously written a lot about Reactivity in Bevy, and I've been comparing my approach with the other third-party solutions out there.
For first-party reactivity, there's a fundamental design choice which, I think, has to be decided before anything else: Do we want the timing of reactions to be system-like or observer-like?
The question has to do with when reactions get run. It has nothing to do with fine-grained vs. coarse-grained, diffing vs. micro-reactions, and any of the other design choices we have discussed which can be decided later.
By "system-like" I mean that reactions run at a specific point in the Bevy schedule - generally somewhere in
PostUpdate. All of my reactive PoCs use this approach, because it's the only approach I can figure out how to actually build.By "observer-like" I mean that reactions are either immediate, or happen at the next command flush - that is, reactions happen all throughout the schedule in response to mutations, much like the way observers are triggered.
Both of these approaches have significant trade-offs:
System-like reactions:
Observer-like reactions:
What I'd like to get from this thread is arguments in favor or opposed to either of these approaches.
@cart @alice-i-cecile @JMS55
Beta Was this translation helpful? Give feedback.
All reactions