Replies: 8 comments 3 replies
-
Some notes on feasibility for various ECS-related topics:
|
Beta Was this translation helpful? Give feedback.
-
I've been leaning towards approach 4 as I explore some reactivity ideas. While polling and memoizing may not be perfectly efficient, I feel that it integrates well with the ECS without introducing too many new concepts. In my opinion, this aspect is fundamental; we should be able to leverage the full power of the ECS. But signals are nice, so I'm working on a bit of a hybrid design. Here's the start of a UI component that's tracking some "model" entity. fn object(model: Entity, mut commands: Commands) -> impl Bundle {
let position: Signal<Option<Rect>> =
commands.derive(move |q: Query<&Rect>| q.get(model).ok().cloned());
// ...
} As you can see, my derived signal is just a system, much like However, not all systems have untrackable inputs. A system that only takes resources is trivially skippable. fn object(model: Entity, mut commands: Commands) -> impl Bundle {
// ...
let selected =
commands.derive(move |selection: Res<Selection>| selection.contains(&model));
// ...
} @chescock's proposed query events ( So far we've defined signals, but let's consider how they might be read. fn object(model: Entity, mut commands: Commands) -> impl Bundle {
// ...
let style = commands.derive(move |mut signals: Signals| {
let selected = selected.get(&mut signals);
let position = position.get(&mut signals);
// calculate style...
Style::new(calculated_style)
});
// ...
} If we want signals to be Reading signals would give the reactivity system even more information to work with. In this case, the Finally, a fun idea I had is that signals which return bundles can themselves be bundles. fn object(model: Entity, mut commands: Commands) -> impl Bundle {
// ...
let style = commands.derive(move |mut signals: Signals| {
// calculate style...
Style::new(calculated_style)
});
(
Div,
// This inserts the value of the `style` signal whenever
// it changes.
style,
)
} Full snippetfn object(model: Entity, mut commands: Commands) -> impl Bundle {
let position = commands.derive(move |q: Query<&Rect>| q.get(model).ok().cloned());
let selected =
commands.derive(move |selection: Res<Selection>| selection.contains(&model));
let style = commands.derive(move |mut signals: Signals| {
let selected = selected.get(&mut signals);
let position = position.get(&mut signals);
// calculate style...
Style::new(calculated_style)
});
(
Div,
style,
)
} The basics of this system are implemented, although I don't currently do any system skipping or change detection. The primary flaw is that signals are effectively leaked. Without an obvious context or reference counting, the lifetime of these signals is a little unclear. A secondary issue is that changes can't be propagated immediately; we need an explicit polling phase once per frame. While this implementation may not shake out, I hope it's raised some useful ideas! |
Beta Was this translation helpful? Give feedback.
-
Something I didn't talk about is homogenous vs heterogenous reactions - which you might also think of as "wholesale" vs. "retail" reactions. Here's what I mean: at the bottom of the entity hierarchy, you have lots of entities that have essentially identical reactions - different state data, but basically the same code. In the JS world, there's no aggregation of these, each button or checkbox has its own closures (because closures are cheap and running them costs nothing). ECS systems are generally optimized for this kind of wholesale operation, much like the oft-quoted SQL database query metaphor. They aren't really optimized for swarms of thousands of micro-reactions, each of which is a separate system. So from a performance standpoint, you might want to pick something that looks more like a system than an observer, one that can loop over a bunch of entities with cache coherency. However, as we get higher and higher in the hierarchy, we get into the realm of entities whose job is orchestration, and these tend to be one-offs: "retail" reactions that are unique in structure and code. Now the performance calculus changes: the single massive system that loops over a bunch of entities isn't so efficient when your query count is always 1. For these kinds of entities, you might want something that looks a bit more observer-like. |
Beta Was this translation helpful? Give feedback.
-
In bevy_dioxus, I handled reactions as follows:
I also tried some other prototypes with explicit signals and dependencies, but neither one was satisfying: I really don't like explicit memorization APIs (approach 4), as I feel that you give up a lot of the ergonomic improvements of reactivity. |
Beta Was this translation helpful? Give feedback.
-
Imo, approach 1 or 2 is the best. Bevy users are already used to treating resources and queries as explicit types / dependencies. The best ergonomics are either of these two approaches, where the user just specifies what types/dependencies they want, and then some BSN output, and Bevy takes care of making that work. Whether inputs are specified as function parameters (2) or via context method calls (1) is imo minor, and will basically boil down to whichever we can get working best / find the most intuitive. I imagine 2 would be more popular if we could get it to work ( |
Beta Was this translation helpful? Give feedback.
-
The case for and against signals, and alternativesI wanted to point out (because this was brought up on the Discord channel) that there is an argument to be made against signals:
(I also need to be clear about the term "signal" because that word is used to mean different things. I'm using the definition from the futures-signals crate: it's a reference to a reactive input source, but it is not the actual input source itself. It is not a reactive variable.) When you first learn React, Solid, or some other reactive framework, the examples you are shown typically feature purely local state management: the state is both produced and consumed within the same UI component, or by a pair of tightly-coupled components. This happens because the authors want to keep the examples small. However, for local state management you really don't need signals. While you can certainly use signals for purely local logic, I think the reason that signals were invented was to solve a bigger problem, which is writing large, complex apps at scale. I think that signals get more valuable as the app grows in size; unfortunately, this makes them hard to talk about from a standpoint of tradeoffs, since only by writing a large, complex app can you validate the worth of them. This might be easier to understand if I provide a contrast: an example of a reactive framework that doesn't use signals. The one I will use is First, let's give a quick overview of how Redux works. You may want to refer to the basic concepts documentation on the Redux website. Redux maintains an immutable data store. There is only one global store, and each update replaces the entire state of the store with a new state; however Redux leans heavily on "structural sharing", meaning that the new state points to parts of the old state, so copying is minimized. For example, if you have an array of 100 employees, and you want to update one of them, what you get is a new array where 99 elements point to the data from the previous array, and one element is new. (This is standard stuff for people who are used to working in pure functional languages like Haskell or OCaml). Because of this, Redux can keep a history of changes simply by keeping around a list of pointers to previous stores. This enables a feature called "time-travel debugging" in which you can rewind the store to a previous state just by swapping a pointer. One major benefit of the immutability and structural sharing approach is that Redux can use "reference equality": two objects can be compared for equality simply by comparing their pointers. There's rarely a need to go field-by-field doing a deep comparison, since it's not possible to modify an object without producing a new object. UI components aren't allowed to modify the store directly, but instead must dispatch "actions" where an action represents some change to the data. This is run through a series of "reducers" which accept the old state plus an action and produce a new state. You can use the Redux store as a general signaling mechanism: any app state which you might want to share between components can be given a dedicated home somewhere in the Redux tree. It's basically just a big struct. UI components subscribe to the store using "selectors": a selector is a function which returns a "slice" of the global store. A "slice" can be any derivation of the global store, but in most cases it's a pointer to a subtree. Because of referential equality, it's very cheap to detect whether a slice has been updated, meaning that a UI component only needs to re-draw itself when the output of the selectors change. So far this sounds a lot like Bevy queries, right? Instead of a global Redux store, we have a The first difference, which we have already talked about previously, is that UI components which depend on selectors only run when the output of those selectors change. However, a second important difference is that, unlike Bevy queries, selectors can be parameterized at runtime: that is, the choice of which "slice" you are looking at can be controlled by runtime parameters. Consider a "contact card" UI component that displays profile information for a user. This displays the user's name, avatar, and online status. We want this data to be updated reactively: if the user goes offline, we want the online status display to update. Similarly, if the user decides to change their avatar, it would be nice if that change were visible immediately. Now let's say we want to use this same UI component to display contact information for multiple users. Perhaps the users are in a list. Or perhaps the contact cart is a hover card, which appears when you hover over that user's icon. This means that we need to pass in the identity of the user as a parameter. Bevy's queries don't permit this: the query filter is defined statically, as part of the type. The best you can do, in Bevy, is to query all users, and then lookup the user you want by entity id, ignoring all the others. Note that even Redux is not quite as flexible as signals, although it's good enough for most purposes. Let's say that in our user contact example, we pass in the user id as a parameter to the contact card component, which then looks up the user with a selector expression such as With a signal, you could abstract the method of access entirely - the signal would just return a user profile object. All that being said, the examples I have given are highly artificial, and unlikely in practice. There are only a small number of cases where the difference matters. |
Beta Was this translation helpful? Give feedback.
-
This is not a fundamental limitation of ECS queries. In flecs (not sure about Bevy) this is possible: auto q = world.query<Name, Avatar, OnlineStatus>();
q.set_var(0, user_entity).each([](Name& n, Avatar& a, OnlineStatus& s) {
// ...
});
You could also do this in a signal-less approach. Following example is for flecs script (html tags are made up, not part of the lang): template ContactCard {
prop user = entity
div() {
span() { Text: { user[Name] } }
span() { Text: { user[Avatar] } }
span() { Text: { user[OnlineStatus] } }
}
}
some_user {
Name: {"bob"}
Avatar: {"bunny"}
OnlineStatus: {Away}
}
ContactCard(some_user) |
Beta Was this translation helpful? Give feedback.
-
The other point I want to bring up is that we don't need a reaction system as long as we have an incrementialization framework. E.g. if you can diff two entity trees and reconcile them, then you can just rerun the template system to produce a new entity tree every frame, diff it with the current tree, and apply the mutations. I think it would be performant enough for most cases, and we could add some coarse optimizations like archetype-level change detection later on, or provide lower-level hooks for times when performance matters. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
I've spilled a lot of ink on the topic of reactivity; although it may seem that not much is happening on the surface, my thoughts continue to churn in the background.
I want to put aside for now high-level issues of incrementalization, virtual DOMs, and updating the hierarchy. Instead, I just want to focus on the most primitve aspect: reactions.
What is a reaction?
In the simplest terms, a reaction is a block of code that is executed once, and then re-executed whenever its dependencies change. In practice, the "reaction" includes not only this function, but also some data structure that tracks when the function needs to be re-run.
For most reactive frameworks, the set of dependencies for each reaction is implicit: either built at runtime as a side-effect of accessing data, or via some static analysis at compile time. What's important is that there's no manual "subscribe / unsubscribe" step.
Data dependencies can be of various kinds:
Reactions may or may not produce an output value. Reactions which produce a value are sometimes called derivations (because the output value is derived from it's inputs), while the ones that do not are often called effects (because their purpose is to produce side effects).
One key challenge in reactive systems is avoiding over-reacting: running the reaction function more often than is needed. In particular, there is something called "the diamond problem", where a reaction may depend on the same dependency multiple times through different paths (D depends on C and B, both of which depend on A - if A changes, we don't want D to react twice). This isn't just a matter of performance, you can actually get incorrect results. Different frameworks solve this in different ways, there is a bunch of literature on this.
What is a signal?
Many of the reactive frameworks provide a "signal" concept, but it's not always called that. There are many different ways to explain signals, but the one I want to focus on is this: signals are a data-access abstraction built on top of reactions.
In a way, signals are the inverse of type erasure: they erase everything but the type! That is, when you create a signal, you are hiding the details of where the data is stored and how it's updated. The only thing that is preserved is the type of the output. Signals are a bit like Futures in this respect, almost like a Future that can produce multiple successive values.
The beauty of signals is that you can pass them around as parameters to code that wants to consume reactive input data without caring where the data came from. A classic example is a "color swatch widget", which accepts a signal representing a
Color
and displays that color. The widget doesn't care how the color is produced or where it comes from, it just wants to consume the value and redraw itself.If we didn't have signals, if we just passed in a plain-old
Color
parameter, then we'd have to also write code to update the widget whenever the color changes - extra work, especially when you have lots of widgets.Clearly, we need to pick a foundation for reactions that supports building a signal abstraction on top.
Reactions in JavaScript
I highly recomment Ryan Carniato's series on How to build a reactive framework from scratch.
In JavaScript, reactions are modeled using the most convenient data types for that language. Because JavaScript has garbage collection, we can model dependencies using a bidirectional relation, where each end of the relation is a
Set
. Each data dependency contains the set of all subscribers, while each subscriber contains the set of all dependencies. The actual members of the set are references to objects.For frameworks like Solid and MobX, these sets are constructed fresh each time the reaction reacts: that is, once we know that a reaction is going to be run, we clear the set of dependencies, and then run the reaction code. Reading data automatically adds to the dependency set, so by the time the reaction is finished, the dependency set is rebuilt.
Other frameworks determine what the dependencies are using static analysis of the reaction function. This is often done using a plugin for a bundler such as Vite or Webpack.
Reactions in Bevy
Unfortunately, the JavaScript data model doesn't fit well in Rust or Bevy. Heterogenous graphs are one of the hardest things to do in Rust, and the "set of subscribers" model is not very ECS-like.
Note that popular reactive solutions in Rust, such as Dioxus and Leptos, internally perform heroic coding tricks to replicate the experience of JavaScript with it's automatic memory management; this means that they require all kinds of invasive shenanigans with how you model your data. This produces a bifurcated architecture: you have a world of reactions, managed by the reactive framework, and a world of entities, managed by Bevy, with some amount of glue connecting them.
Ideally, what we want is a single unified space in which both ECS-like operations and reactions are supported in an integrated way.
In particular, it's pretty clear that reactions should be entities. In this respect, they are not too different from observers: a reaction is a "satellite entity", which is sort of like an invisible child of the entity that it affects. The list of dependences, if any, and the reaction function can be stored as components on that entity.
There are a number of approaches that can work, here's a list of the ones I have been able to identify so far:
Approach 1: TrackingContexts and the Context Param
This is the approach I used in
quill
andbevy_reactor
:TrackingScope
), which is an ECS component.cx
. The methods on this object are connected to the current tracking context.cx
. Accessing data this way automatically adds a subscription.A typical example:
In this example, the reaction creates a dependency on two resources and a component of the given entity. The methods on
cx
are somewhat similar to the methods onWorld
.The benefit of this approach is that data dependencies can be fine-grained, without over- or under-reacting. The run-to-convergence algorithm ensures that there are no 1-frame delays caused by reactions happening out of order.
Another benefit is that it's possible to implement signals. In this case, a "signal" is just a type-erased wrapper around some ids needed to access input data from the world. However, this access also requires a reference to the current
cx
, so the code ends up looking something like this:Downsides:
cx
requires a separate borrow of&World
, which is fine as long as you don't try mutate anything. I considered implementing something likeSystemParam
forcx
to help mitigate the borrowing problems, but I wasn't confident in my ability to do this.My earliest attempts mixed both mutable and non-mutable operations in a single reaction function, inspired by common code patterns seen in Solid and MobX. However, this quickly descended into borrowing hell, which required lots of temporary copies and clones to satisfy the borrow checker.
A later version divided the reactions into two phases, the "read" phase which only read data from the world, and an "effect" phase, which did all the mutations. Each phase had its own closure, with data being passed between them.
Approach 1A: The same but with a universal observer
Rather than polling for changes, we can imagine something like an observer which notifies us when the data dependencies change. However, unlike current observers, we'd ideally like these events to be debounced, so that we don't end up reacting multiple times per frame.
Unfortunately, this idea isn't implementable at the current time:
Approach 1B: Hidden context
Because JavaScript doesn't have threads in the normal sense, it's pretty easy to avoid passing the context parameter everywhere: just make it a global variable. When you call
use_state()
or other hook functions in React, it's actually referencing a hidden context state which is setup immediately before calling your code, and removed immediately after.To do this in Rust we'd have to store the hidden context in a thread-local variable. Unfortunately, using thread-locals in Rust is tricky, the APIs don't just let you access the data however you want, there are lots of safety and borrowing puzzles that you have to work around. Note that Dioxus and some other systems have solved this, but the solution is a bit hard for me to follow.
Nevertheless, it would be great if you could, for example, dereference signals without having to pass the context parameter everywhere, so that instead of having to say
checked.get(cx)
you could just say*checked
.Approach 2: Using Bevy's dependency injection
The idiomatic way to access world data in Bevy is via dependency injection, which is available for regular systems, one-shot systems, and observers. This enables dependencies to be statically analyzed at compile time, similar to what some reactive frameworks do in JavaScript.
Can we use this to track reactive dependencies? The idea seems promising, but there are significant problems.
Consider a hypothetical "reactive query" which has a Bevy-style change detection bit: that is, it tells us if the set of query results is in any way different than the last time we looked at the query.
This would be fine if we were actually using all of the query results: that is, we were iterating over all of the returned entities. In this case, of course we would want to know if anything changed.
However, that is not how queries are used in Bevy a lot of the time. Consider the following code:
This is an observer that is handling a click event for buttons. Note that it's querying for every button in the world, but only using the query data for one of them - all the rest are ignored.
This pattern gets used in Bevy all the time: because queries are the primary way we get information about entities and components, we normally "over-query": that is, we cast a broad net and then pick out just the data we want.
If we were to try and make this query reactive, we would be massively over-reacting: clicking any button would trigger a reactive update for every button.
What about modifying the query API to allow queries that are narrower? Ones that only return information for the entities we care about?
The problem with that is that the entity id is a runtime value, and as such cannot be expressed as a parameter type that can be statically analyzed. To put it another way: query filters aren't dynamic.
There's been discussion about associating particular entity ids with types so that you can query a single entity: for example, the target entity of an observer event can rightfully claim to be special, so it would make sense to implement a "single-entity" query (like
Single
) that specifically selects the event target. Even better, you could make it fallible, so that if the query didn't match, the whole observer would be skipped.While that may work for observers, it's hard to generalize this. Reactive widgets often have one or more "special" entities - for example, the thumb of a slider. Most often, these entity ids are known at construction time (the result of
spawn()
), and can be captured in the various closures attached to the widget. Each of these would need to be associated with a type, and there likely would be edge cases that aren't covered.A second problem here is that it's hard to envision how reactive queries could be used as the basis for signals. This would, at minimum, require persistent "queries as entities".
Approach 3: Using special reactive-friendly data types
Another approach which has been worked on (by others, not me) is to define an alternate set of data types (vectors, maps and so on) which natively understand reactivity. I'm not going to say too much about this.
Approach 4: Poll and memoize
A final approach, which I used in my
thorium_ui
experiments, was to go with a brute-force solution and give up on change detection entirely - well, almost entirely.Like the earlier experiments, this divides the reaction into two phases, one for reading and one for effects. However, the reading phase (which is now a one-shot system) now runs every frame, computing an output value. This value is memoized: it's stored in a hidden component, and whenever the value is different than the previous frame, the second phase is run. This second phase, which is an ordinary
Fn
(not a system) accepts the output of the read phase along with anEntityWorldMut
. It can then update the entity however it likes, using the value from the reading phase.How well this performs really depends on how much work you are doing in the reading phase. Even change detection isn't free, so just accessing a resource or component (plus whatever overhead is entailed in injecting them) shouldn't be too bad. But consider that there's no aggregation here: every widget has it's own separate one-shot system (or more likely multiple systems), so if you have a thousand buttons, well...
Note that without change detection, the concept of "signal" almost entirely withers away: it's just a handle for reading a value, as there are no subscriptions to be tracked.
This approach has a number of benefits:
PartialEq
), which is entirely up to the developer writing the reaction. This assumes that the output's equality comparison only produces afalse
result (a cache miss) when it matters.However, there are some downsides:
Beta Was this translation helpful? Give feedback.
All reactions