Replies: 3 comments 6 replies
-
Some quick ECS notes on the internals and future plans.
This is effectively accidental behavior, and is unlikely to be supported in 0.18. Instead, we're hoping to define these as distinct entities with relations, which allows for multiple observers per watched entity.
The changes in #19451 should make this much more feasible.
Yep, on board with something shaped approximately like this. I think that the existing ergonomics of pre-registering one-shot systems and having to deal with manual cleanup is both unacceptable and not that hard to fix. |
Beta Was this translation helpful? Give feedback.
-
I agree that there is overlap, but framing Bevy observers as "javascript events listeners" and the proposed Bevy callback as "javascript callbacks" in order to make the argument against observers feels "lossy". The difference between a Bevy observer and a Bevy callback is comparatively slim (as you have outlined).
I'll note that Bevy Events / observers can bubble (or not bubble) in any way that they want. They do not bubble by default, and if a custom bubbling algorithm is needed, developers can provide their own.
I agree. Bubbling shouldn't be used for those things, and doesn't need to be used for those things in the context of Bevy Events / Observers.
I've taken a stab at this scenario with #[derive(Component)]
struct ColorPicker {
color: Srgba,
}
#[derive(EntityEvent)]
struct PickedColor {
entity: Entity,
color: Srgba,
}
fn color_picker() -> impl Scene {
bsn! {
ColorPicker
Node
[
(
:red_slider // assume this builds on the headless Slider widget
on(|slider_value: On<SliderValue>, mut commands: Commands,
child_of: Com<ChildOf>, mut color_pickers: Query<&mut ColorPicker>| {
let mut color_picker = color_pickers.get(child_of.parent());
color_picker.color.r = slider_value.0;
commands.trigger(PickedColor {
entity: child_of.parent(),
color: color_picker.color,
)
})
),
// green and red sliders omitted for brevity
]
}
}
fn player_main_color_picker() -> impl Scene {
bsn! {
:color_picker
on(|picked_color: On<PickedColor>, mut player: Single<&mut Player>| {
player.main_color = picked_color.color;
})
}
}
fn player_accent_color_picker() -> impl Scene {
bsn! {
:color_picker
on(|picked_color: On<PickedColor>, mut player: Single<&mut Player>| {
player.accent_color = picked_color.color;
})
}
}
fn player_color_editor_popup() -> impl Scene {
bsn! {
Node
PositionType::Absolute
Visibility::Hidden
[
:player_accent_color_picker
:player_main_color_picker
]
}
}
fn edit_color_popup_button(popup: EntityRef) -> impl Scene {
bsn! {
:button
on(|press: On<Press>, mut query: Query<&mut Visibility>| {
let visibility = query.get_mut(#popup).unwrap();
*visibility = Visibility::Visible;
})
[
Text("Edit Color")
]
}
}
fn root_scene() -> impl Scene {
bsn! {
Node [
:edit_color_popup_button(#Popup)
(
#Popup
:player_color_editor_popup
)
]
}
} Also please forgive me for using idealized bsn! syntax + functionality in a few places:
For the sake of comparison, feel free to make similar assumptions. We should not be making future facing decisions based on the arbitrary constraints of the current state of things.
Nor should it, as that is an implementation detail from the perspecive of the consumer of the color picker.
This is "just" an additive feature. It is opt-in, and out of the user's face by default, especially after #20731, which makes "propagation" functionality statically only present on "propagating events".
Yes, but I'll note that this argument is filling the same role as some
Observer lifetimes are a solveable problem from my perspective. They will support multiple targets in a more natural way once we port them to relationships, and we can adapt their lifetimes to meet our arbitrary needs.
This doesn't make sense to me. In Bevy "things" exist as a specific entity. If you have built a color picker, it has an entity and that is the root.
The source of a color change is the one calling |
Beta Was this translation helpful? Give feedback.
-
This response will be a bit scattered and out of order, as I have a lot of thoughts:
This is the "callback in all but name" approach. I rejected this because it seems redundant: we already have a concept in Bevy (one-shot systems) that is effectively this, and I don't see the benefit of replicating it, other than branding (putting everything under the "observers" name). Unless you plan on replacing one-shots with observers entirely...
Custom bubbling isn't the right answer. The point I was trying to make earlier is that the wiring between components tends to be more bespoke as you go higher up the stack (as opposed to commodified lower down), and most of the connections at that level are one-offs. There tend to be a lot of these one-offs, and making these connections should be frictionless. Wiring together two components should require only a few lines of code; it should not require a custom propagation type.
I realized last night that the language here is a bit confusing. Strictly speaking, entities aren't either receivers or senders. The "sender" is a function which calls Diving into this metaphor more deeply, let's talk about a light bulb and a wall switch. From a command-and-control perspective, there are three components to this circuit: the lightbulb, the wall switch, and the wire that connects them. An important design point is the "need to know" principle: the lightbulb's design doesn't have any knowledge of what kind of switch is controlling it (whether it be a two-pole toggle or an electrical timer), the switch's design doesn't have any knowledge of what it is controlling (a lightbulb or a garbage disposal), and the wire doesn't have any knowledge of either. This lack of knowledge is what allows these parts to be commodified. (While I'm sure that GE and Phillips would love to be able to sell you wire and switches that only work with their branded lightbulbs, we haven't attained that degree of capitalist dystopia quite yet.) There is a fourth participant here: the electrician who decided which switch connects to which bulb. This person also has a limited need to know - they just need to know enough to be able to plug the things together correctly. Projecting this analogy back to our UI components, the orchestrator who instantiates both the color picker and the model preview shouldn't have to know or care any details about these components. My preference for callbacks is mainly philosophical in that it reduces the "need to know" for senders and receivers. The receiver of a callback doesn't care whether there is a single sender or multiple senders (since callbacks can be shared between senders). With observers, the receiver needs to call
There's a number of things about this example that I don't like, but are unrelated to the question of callbacks and observers. The first issue is the way that the observers locate the widget state using parent/child relations and queries. This is brittle: if I need to re-arrange the internal structure of the picker, such as adding an additional flexbox layer, now I have to fix all the event handlers and inject additional information to walk the ancestor chain. What BSN desperately needs here is the equivalent of React' With something like In my reactive experiments, I worked around this by pre-spawning blank entities: I would call However, this workaround is not available in the typical BSN setup because in order to spawn an entity id you need access to This setup part is frequently used for:
BSN's "no argument" style of scene functions effectively prevents all of this kind of housekeeping. Instead, we have to rely entirely on bundle effects or other post-construction or mid-construction hooks in order to get access to (*I know that I'm using the word "template" in a way that conflicts with your usage, but I don't know what else to call it. In React this would be a "component" but using that word in an ECS context just leads to confusion.) More comments as I think of them; for now I need to take a break from writing. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
One of the controversial decisions in the new standard widgets and Bevy Feathers is the use of one-shot systems as callbacks. The primary motivation behind this choice is to provide a model that would be familiar to web developers, where callbacks are the norm for frameworks like React, Solid, Vue and others.
However, others have asked "why not observers?". This is a reasonable question, and the current choice deserves some detailed analysis.
Concrete Use Case: Composite Widgets
It's hard to evaluate the merits of callbacks vs. observers in a vacuum, so I'm going to lay out a of realistic use case for discussion: a character customization screen that lets you select colors for your avatar. The page has three color pickers, one for the main color, one for the secondary detail color, and one for the accent color. (In most games there would only be a single picker, plus a switch to determine which color we were editing, but I'm deliberately making this complex for the sake of illustration).
There are three instances of the same color picker widget on the page, and each one has a number of widgets that can affect the color, such as RGB sliders, HSL sliders, a color palette / grid, possibly a 2D color plane. The picker is a composite widget whose input is the current color, and whose output is the new color. Each time the user drags a slider or clicks on the palette, a new color message is emitted. The various sliders and palettes are completely encapsulated: the external caller only sees new color events, it has no idea what kind of widgets produced those events. Making the picker a black box gives us the freedom to add new color spaces and new editing modes at any time without changing the picker interface.
Callback usage in JavaScript
Observers and one-shot callbacks in Bevy are very similar to their counterparts in JavaScript. Bevy's observers, especially with bubbling enabled, behave very much like DOM event listeners, while one-shot systems can be used like JavaScript callbacks.
Both event listeners and callbacks are used in web apps quite a lot. Event listeners tend to be more popular for low-level events like keyboard and mouse events, because this is what the native browser code generates at the lowest level.
Callbacks tend to be used more for high-level, application events like "user login" and "delete document". The reason for this is because the bubbling model - where events only flow upwards in the UI hierarchy - is often too limiting for high-level events. Bubbling works well when parent elements manage the events for their children; it doesn't work as well when messages need to be sent to other parts of the hierarchy, or outside of the hierarchy entirely (as in the case where events are being sent to an MVC model object or some external state management store such as Redux).
There are a handful of usage patterns for callbacks. The simplest is where you have a single sender and a single listener.
You also have cases of where there are multiple senders and a single listener. Take for example a listbox showing a list of clickable items, where a click on an item either takes some action or selects the item. If each of the list items has a different action, then it makes sense to have a separate callback for each row; with a single shared callback you'd need to have some switch or match statement inside the callback to take different actions based on which row was clicked. However, if the rows have all the same action, or action that is very similar (like setting the current selection to that row's id), then it doesn't make much sense to create a separate callback for every row, instead the rows can all share a single callback instance.
In the case of our color picker, each slider would have either an event listener or callback that listens for changes to the slider value. So for example, when the red slider is dragged, the
on_change
handler for the slider gets the current color, changes the red component to match the slider value, and then emits a new color event. To maintain encapsulation, external listeners don't know that the event is coming from the red slider specifically, as far as they are concerned the events are coming from the picker as a whole.Note that for every slider drag update, there are two messages that are dispatched (these might be callbacks or bubbled events): there is the
on_change
message from the slider, whose payload type isf32
, and theres theon_change
message from the picker as a whole, whose payload type isColor
. This is a common pattern in UI, where low-level events get intercepted and transformed into higher-level events as they propagate.If we are using callbacks only, then each slider would have its own internal
on_change<f32>
callback, which in turn calls the higher-levelon_change<Color>
callback. So each of the red, green, and blue sliders would have its own callback closure which knows which color channel to change.If OTOH we are using event listeners, we can take advantage of bubbling, and have a single listener on the picker root element; however this listener has to look at the event target's id or attributes to know what color channel is being edited. (And we also need other handlers on the root for other kinds of child widgets such as palettes).
Similarities and Differences between One-Shots and Observers
I have no idea how observers and one-shots are actually implemented in Bevy, but from a user standpoint they have a lot in common:
On
parameter, which is required.In
parameter, which is optional depending on the system's input type.They have some important differences as well:
This last item is actually the most significant: observers and one-shots are different in how they are created and owned. Observers (except for universal observers, which wouldn't be useful here) always belong to some entity (the
original_target
), can't exist in an unattached state, and disappear when their owner is despawned. Lifetimes of one-shot systems, OTOH, are managed by the code that registered them, and can exist even if unattached.Life without Callbacks
Let's say we wanted to implement our avatar customization page using only observers - callbacks don't exist. In order to use observers, we'd have to call
.observe()
on some entity. (A universal observer would get every color change from every picker, which would be confusing, so we won't do that.)Within the avatar page, there's some display code that responds to a change in color from the picker, and updates the materials of the 3d model shown in the preview. This code can't call
.observe()
on the individual RGB sliders, since it doesn't know they exist, how many sliders there are, or how to find them in the hierarchy. Instead, the only option for the caller is to call.observe()
on the picker's root element.(Note that this assumes that the picker has a singular root element, and not multiple roots. More broadly, it exposes the fact that the source of the color changes is coming from an entity in the hierarchy, and not some other process like a script or memory store - because if it wasn't an entity, you wouldn't be able to call
.observe()
on it. A callback, on the other hand, effectively launders the origin of the message.)Because the picker root is the only entity the external caller knows about, this kind of forces us to do all the event handling in context of the that root, discriminating events by target id. While we could have event listeners on the individual sliders too, this forces us to add an additional processing stage: now we have three levels of
on_change
handlers instead of two.Let's make the problem even more complicated: suppose instead of having a picker, we have an "edit color" button that, when clicked, displays a floating popup with a picker inside of it. The picker is hidden inside the popup menu, so now the only entity that the external listener can "see" is the popup root. This means that we need to add yet another processing stage to forward the
on_change
event from the picker to the popup.There's another approach which was suggested by @MalekiRe, which is to pre-register the observers on a blank entity and then pass in the entity as a parameter to the picker template which then fills it in as the root. (It has to be the root, because if we wanted to supply blank ids for the sliders, we wouldn't know how many to pass in.)
Let's ignore for the moment that BSN doesn't have this ability, as I assume that it eventually will. In order to pre-register the entity ids we need access to
Commands
orWorld
or suchlike - unless we want to get really clever with bundle effects or something. It also means that, once again, we are having to pull a thread out of the weave of the picker and expose it to the outside world.Note that none of the approaches I've described are absolute show-stoppers; I just think they are slightly worse from an architectural standpoint.
Current Behavior / Controlled vs. Uncontrolled
I'm assuming, BTW, that our color picker is a controlled widget (in the React sense) which means that the caller is responsible for maintaining and updating the current color. The picker does need to cache the current color input so that it can mutate one channel without affecting the others, but it never updates its own copy on input, it only accepts changes coming from outside. This gets even more interesting when you support multiple color spaces, to avoid "gimble-lock" you want to remember the previous hue and saturation as well as the previous RGB. However, this is a side issue and not central to the argument.
Callback Handles
The biggest problem with callbacks right now is that there's no way to automatically unregister them when we no longer need them.
The solution I currently like is to make one-shot system ids reference counted like asset handles. This falls under the category of "things we should be doing anyway".
Alternative Models
Callbacks and event listeners aren't the only possibilities here. One approach is regular systems, or stateful components with markers, but these are a challenge because you have to fiddle around with system run order in order to avoid 1-frame delays.
Frameworks like
iced
(and I guess, SwiftUI) use a functional approach where "state + input => action", but while I did read theiced
documentation a long time ago, I never used it in a real project and so I don't know enough to assess its suitability.@cart @alice-i-cecile
Beta Was this translation helpful? Give feedback.
All reactions