bevyengine / bevy

A refreshingly simple data-driven game engine built in Rust
https://bevyengine.org
Apache License 2.0
35.14k stars 3.45k forks source link

Portable Callback objects #10582

Closed viridia closed 6 months ago

viridia commented 10 months ago

What problem does this solve or what need does it fill?

Building a hierarchical UI is much easier with callbacks. Having to poll the state of each individual widget makes it difficult to create components that are truly modular.

For example, suppose you have something like an audio preferences dialog with a volume slider. The slider doesn't know anything about audio, it's just a generic slider. It's possible to build such a dialog by polling the slider state directly, but that requires injecting the internal state of the slider into the parent dialog, breaking encapsulation. This gets even more difficult if the dialog itself is a generic widget, which means it can no longer depend on dependency injection, but must receive context from its own caller. If the slider can accept a callback parameter to notify its parent whenever the slider value changes, however, then its relatively straightforward to modify the state of the world in response to the call.

In older UI frameworks (JavaSwing, Gtk) this was done with events rather than callbacks. The problem with events is that the parent has to examine each event and decide which widget it game from. Modern frameworks like React/Solid/Svelte are organized around passing callbacks to each child widget, which produces code that is simpler and more modular. It has a better architectural separation of concerns.

However, passing closures around in Rust is problematic for a number of reasons. First, there is the problem of closure variable lifetimes - a button that has a .click() method is probably going to live longer than the setup function that creates it. (Move semantics can help, but often you will have several closures that all want to capture the same variable).

Second, in a complex UI, callbacks are often passed down through multiple layers of the widget hierarchy, which means that every widget now has to be generic on the type of the closures being passed through it.

Third, in a reactive framework, callbacks are often used as dependencies to other derivations, such as "computed" or "effects". But this leads to a lot of extra churn unless the callbacks are memoized. Memoization requires both equality-comparison and a way to copy the value, neither of which are supported by bare closures.

Finally, it would be nice for callbacks to participate in dependency injection the way that systems do. Dependency injection can, in some cases, substitute for closure variables in a callback. For example, in the case of the audio preferences dialog, the callback which is attached to the volume slider could get a handle to the audio volume resource by capturing it from the parent, but alternatively it could inject it directly with something like Res<AudioSettings>.

What solution would you like?

I propose some trait, Callback<In> which represents a generic callback that accepts some parameter, much like a one-shot system. However, Callback is actually a wrapper which gives us a number of advantages over one-shot systems:

Internally, Callbacks might be implemented as one-shot systems, or they might be implemented some other way. The actual closure is wrapped in an Arc, allowing the wrapper to be cloned without duplicating the closure.

The equals comparison can be very simple, just a pointer comparison: we don't actually care if two callbacks have the same closure values, all we care about is whether the callback was constructed via a distinct call. Two separate calls to create_callback should always compare as unequal.

Callback objects can be passed around freely as parameters to systems, widgets, event handlers and so on. For example:

pub struct SettingsProps {
    on_change_volume: Callback<f32>,
}

pub fn settings_dialog(cx: Cx<SettingsProps>) {
    Slider::new("Hello World").on_change(cx.props.on_change_volume, value);
}

The Callback trait has one method, .call(world, args). Yes, callbacks run exclusively like one-shot systems, but so do most event handlers (look at bevy_mod_picking). There's a discussion around this somewhere, but I generally believe that it's OK to have low-frequency "command and control" code run in an exclusive system, so long as the high-frequency code, the stuff that's CPU intensive, is non-exclusive.

Memoization is outside of the scope of this proposal, as it would be handled by the third-party UI framework (or any other framework using callbacks). A framework can provide a create_callback_memo(func, deps), for example, which always returns the same object as long as deps is the same every time. Other APIs are possible, but it's up to the framework to decide how that should work.

Callbacks are meant to be synchronous - there's no delay between the time the callback is sent and the time it's received. For asynchronous communication, use other methods.

What alternative(s) have you considered?

A bunch, too many to list here.

If we jettison the idea that Callback supports dependency injection, then the implementation of Callback is simpler since it no longer requires a World to call it. However, the downside is that you now have to rely much more heavily on capturing closure variables. Dependency injection can substitute for variable capture in some cases (where the value being captured is also available as an injection) but not others (such as when the value being captured is locally scoped). Unfortunately, closure captures introduce a lot of complexity around lifetime bounds, which the person defining the callback will have to deal with.

Additional context

This idea is part of a general research project to see how well we can adapt ideas of reactivity in an ECS world. One of the tensions is that UIs are inherently hierarchical, not just in structure but in execution scope, and ECS architectures tend to atomize hierarchies and flatten everything. The idea of callbacks is to try and bridge those two paradigms.

Calling a Callback requires a World, because otherwise dependency injection doesn't work. (You can't store a World inside the callback object because of lifetime issues). My assumption is that most uses of callbacks will have a world available at the point where it's called, such as an event handler. For callbacks which call other callbacks (a fairly common case in UI code, often widgets elevate the level of abstraction when forwarding events), the callback will need to inject a World.

I know that a lot of folks will object to the fact that Callbacks only make sense in an exclusive context. However, many kinds of "command and control" logic only make sense in an exclusive context. UI hierarchies are often quite deep, with multiple layers of widgets, and it's not uncommon in UI code for a message originating at the bottom of the stack to proceed upward in multiple "hops", with each hop transforming the message to a higher-level, more abstract form. It would be unfortunate if each hop incurred a one-frame delay.

UkoeHB commented 10 months ago

Running systems manually was recently merged. Wouldn't your Callback just need to be a wrapper around SystemId<I, O>?

viridia commented 10 months ago

Running systems manually was recently merged. Wouldn't your Callback just need to be a wrapper around SystemId<I, O>?

Yeah, that would probably work.

viridia commented 10 months ago

@UkoeHB One problem with using registered systems is ownership and dropping. A closure used as a one-shot system can be dropped at any point, and can be wrapped in an Arc. A registered system can only be destructed with a reference to World, and you can't keep a World reference around because of lifetimes.

This means that a manually-run system has to have an "owner" that is responsible for de-registering it from the world. This defeats the purpose of Arc, which is to have a more flexible, distributed ownership. In web apps, callbacks are often threaded through multiple levels of UI hierarchy, both up and down the visual hierarchy as well as connecting to and from asynchronous data stores. This means that there is no clear ownership - something easy to do in a GC language.

Part of what I am looking for here, is the decoupling of UI widgets: being able to build widgets that can be composed and re-used in different contexts. In the current architecture, this is already true on the rendering side - you can build a sub-tree of child entities and attach them to a parent entity without the parent having any special knowledge about the nature of its children or vice versa. But that's the easy part - once you start defining the command-and-control aspects, where signals and/or events are being transmitted back and forth between entities, that loose coupling goes away, and widgets now need to have intimate knowledge of the lifetimes of the widgets they are connected to, and the lifetimes of the data being passed back and forth. This results in an insurmountable reduction in the degree of modularity and reusability.

UkoeHB commented 10 months ago

One problem with using registered systems is ownership and dropping. A closure used as a one-shot system can be dropped at any point, and can be wrapped in an Arc. A registered system can only be destructed with a reference to World, and you can't keep a World reference around because of lifetimes.

Yep this is a problem. My solution, which I am right now in the middle of implementing, is to add a custom entity garbage collector. 1) Store callbacks in entities (that's what #10380 does). 2) When you spawn a callback entity, create an RAII handle around the entity id and an MPSC sender (AutoDespawnSignal). The sender points at a receiver in an AutoDespawn resource. 3) When the last clone of an AutoDespawnSignal is dropped, the entity will be sent to the AutoDespawn resource. 4) When the AutoDespawn resource is added to an app via a plugin, it adds a system to the Last schedule that polls for despawnable entities, and then despawns them.

The inconvenience here is you need to the AutoDespawn resource around in order to create AutoDespawnSignals. I am actually using an MPMC channel, which means I can either A) clone the resource and pass it by value, B) access it directly as Res<AutoDespawn>, C) access it indirectly as a member of a custom SystemParam (this is what I will do for UI - I will just add it to my UiBuilder).

asafigan commented 9 months ago

I have implemented Callbacks in my own project. It runs the system manually rather than using SystemId. I implemented it two different ways. First by using an internal Arc and Mutex, this makes it really easy to construct and clone. My second implementation was to disallow cloning but to allow sharing callbacks through Handles. For both of these implementations I added extension traits to both Commands and World to make them easy to call.

Advantages of not using SystemId:

Advantages of using and internal Arc and Mutex:

Advantages of using Handles:

alice-i-cecile commented 6 months ago

Closing in favor of the observer pattern in #10839 as our "reactivity" solution. We can revisit patterns here in the future as we see how those play out.