Replies: 6 comments 6 replies
-
|
Some observations to check my understanding. I don't have much experience with these frameworks, so please grade my answers and correct me!
|
Beta Was this translation helpful? Give feedback.
-
|
I have some implementation thoughts on this, from an ECS perspective:
There are a number of ways we could handle this, if we wanted to :) Ultimately, this is a question of "we need to be able to reason about components, based on the properties of that component type". Some options:
In most cases, we would want some custom mechanism for "how should this be diffed", to allow for flexibility in terms of which fields we care about for example. The easiest way to do that would be to use the same strategy as Unsurprisingly, the "component metadata" and "trait query" approaches lead to the same solution here. Trait queries are effectively a convenient conceptual framework to allow for the querying and usage of metadata about each component type. I think that trait queries are the best approach here (they're more principled than just "more metadata"), and think that using Reflection to do this is likely to be both slow and leak implementation details down into the stack: bevy_reflect should not have to care about these problems, and frankly is the wrong tool for this entirely. Relying on |
Beta Was this translation helpful? Give feedback.
-
Dioxus also does this. It's a key reason for using a macro. The macro inspects the syntax tree and marks anything constant (e.g. string literals - which are pretty common for things like style properties) as not needing to be diffed. |
Beta Was this translation helpful? Give feedback.
-
|
Incremental computation has a very simple definition, not limited to UIs: https://en.wikipedia.org/wiki/Incremental_computing also see https://arxiv.org/pdf/2312.07946#page=5.20 Different people build different incremental solutions for different domains:
They are all very different and built only for the problem domain. There are no unified framework that is efficient for all problem domains. As a very wild example why you should use frameworks purposely build for the probolem domain, someone tried to build a incremental ray tracer by trying to use a the Ocaml library Incremental: https://www.peterstefek.me/incr-ray-tracer . The problem is it can only allow very specific changes of the input. You cannot make changes of the object position. In some sense ReSTIR is a effictive incremental version of ray tracing.
The most traditional way is just don't build incremental/reactivity into BSN/Bevy, and just treat Bevy UI as a retained UI framework, and add a reactive UI layer on top. If you build ECS incrementality with reactive UI in mind, you ends up only with features that only useful for "reactive UI". But ECS is a general computation framework. |
Beta Was this translation helpful? Give feedback.
-
|
I'm a big fan of approaches like Svelte and Dioxus that compile down to the minimal necessary computations. I would think that with a simple enough framework the Rust compiler would be able to do that for us though. But I guess Dioxus wouldn't have done it manually if that was the case? I haven't checked but I would think that egui code compiles down about as small as Dioxus code... So if I can try to simplify here, once per frame we need to know the layout in order to draw the ui. This layout depends on various forms of meaningful state. We can also store various derived state if it helps us compute the layout faster. Because this is Bevy, we're going to store all of our state in the ECS. If we define the layout as a Rust function of the meaningful state, ie a system, then the Rust compiler should give us a fairly optimal conversion from SystemParams to draw calls. The only way we could improve on this is if there was some derived state that was "lower order" than the meaningful state in some sense. And afaict, that's not generally possible. The derived state that these frameworks cache is generally of the same order in size or larger, and so querying the ECS for that derived state to ultimately issue draw calls wouldn't even be cheaper. The reason it's beneficial on the web is because you can only draw through a stateful API. And also because on the web you only redraw when something changes. The above analysis breaks if we're not drawing every frame. But I guess that's not something we're considering? Sorry if I have no idea what I'm talking about, it's 1am here and I'm not thinking straight 😅 |
Beta Was this translation helpful? Give feedback.
-
|
Reactive UI is incrementalisation by construction. In a reactive system (simplified) you define a signal (something that changes over time, something that you read) and you define an effect (when the signal changes). These are nodes in a graph - signals are sources, effects are sinks. If an effect depends on the value of a signal, that means there is an edge from the signal to the effect. An analogy for this is a simple circuit: switch, light (and power source). You can define the relationship between them something like This is what we want to achieve, and this is what "immediate mode" frameworks achieve "for free" because they just blow away the previous state (effectively). Returning to the analogy, these elements exist and are continuously operating - they are "live" or "retained" - just like the elements in our UI, or entities and resources in an ECS - to make changes, you have to mutate the world. Based on the assumption that making changes to the retained world is "expensive", here is where a virtual DOM would come in - you compute a "shadow world", you compare shadows between iterations (diff) and make the minimum changes to the world to synchronise them (patch). Notably a patch expands into many more operations on the actual world and is typically much more costly. Unclear if that's the case in Bevy - how much cheaper could you possibly make the "shadow world" (virtual dom) than the real world? Returning to the analogy, if you flick the switch (you change the signal), you cut power to the light - this is an "imperative" view. If you change the switch signal to off, the effect is that the light switches off. Reactivity is "automatic" but "imperative". Reactivity is a general purpose tool that runs effects when their dependent signals change - that's essentially the sum total of it. It's usually used with the "UI as a function of state" paradigm simply because it's a perfect match: optimally performant, excellent mental model - but it doesn't have to be. An example of this is an effect that tracks a signal but logs the value of that signal - in that case the output is not a function of the state. What typical reactive libraries that target UI do which is what gives them a level of convenience that matches "immediate mode" or "virtual dom" is "dynamic dependency tracking": every time a signal is read while running an effect, you create an edge from the signal to the effect. But explicit tracking is also possible (usually these are exposed as their own tools called In summary:
I hope I've convinced you that reactivity is the right paradigm for Bevy: ECS is not immediate mode so we can ignore that, and a virtual DOM / shadow world buys you very little. Reactivity is superior because it's more efficient, but also it assumes statefulness unlike the other two paradigms which have to bolt statefulness on. In terms of implementation, for Bevy, this means you have to decompose changes into a set of primitives, or general purpose effects:
Notably, these primitives already exist, so it's just a matter of wrapping them in effects when required. For example, a desugared (after macro expansion or whatever mechanism is used to construct this, perhaps Templates) counter application would look something like: let value = commands.create_signal(0);
let target = commands.spawn(Button);
commands.create_effect(|world| {
let value = *value.get(world);
world.entity(target).insert(Text(value.to_string());
});
target.observe(|_: On<Pointer<Click>>, world: &mut World| { value.get_mut(world) += 1; } )The actual code is a little bit more verbose than this, but it works today. I spawn an entity for every signal, and the effects simply wrap pre-registered SystemIds, and I use change detection on the signal data to determine what effects need to run. One thing to note is that the change can be even more fine-grained and efficient than this, for example, you could just clear and write to the String. The "shape" of the desugared code doesn't change much, just the specificity of the effect. However, I think Component-level may be a good enough starting point. let value = commands.create_signal(0);
let target = commands.spawn(Button, Text::default());
commands.create_effect(|world| {
let value = *value.get(world);
world.entity(target).get_mut::<Text>(target).0.clear();
write!(world.get_mut::<Text>(target).0, "{value}").ok();
});
target.observe(|_: On<Pointer<Click>>, world: &mut World| { value.get_mut(world) += 1; } )Having said all this, I think there are probably places in the engine that could benefit from greater incrementalisation but I don't believe that should be conflated or mixed up with providing a good API for users to write UIs declaratively just because the shape of the problem seems similar. For more on reactivity, this is an excellent summary of how modern reactive libraries that target UI work: https://book.leptos.dev/appendix_reactive_graph.html |
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.
-
In my previous post #20821, I tried to narrow the discussion to just reactions, avoiding any discussion of incrementalization. However, since @JMS55 brought up incrementalization, I figured I would write a post that explores this topic.
What is incrementalization?
I first heard the phrase, "Every modern UI framework contains an incrementalization strategy" in one of Raph Levien's talks, although he didn't originate the saying.
In a nutshell, incrementalization refers to a way of dynamically updating a UI hierarchy (or any display hierarchy for that matter) via patching:
In effect, incrementalization is a way to implement the following formula:
old_tree+new_parameters=>new_tree, such thatnew_treere-uses the parts ofold_treethat didn't change.A key requirement is that incremental updates should produce results that are indistinguishable from rebuilding everything from scratch: that is, given the same input parameter values, it shouldn't matter whether this is an incremental update or an initial construction. It should look like a "pure function" even when it isn't.
As pointed out in the comment, you can have incrementalization without reactivity: it just means that you have to change the input parameters manually.
Various Strategies
The display tree - what the user actually sees - is the output of an algorithm, however we need more than just the output in order to construct that tree. We need some kind of template or blueprint: a data structure that describes the output we want to generate.
We also need to store some kind of parameter state: the value of the parameters from the previous update, along with any intermediate values derived from them. There might also be local state.
Different UI frameworks approach this problem in different ways. Some of the ones I have seen are:
Long-lived scaffolding that exist separately from the output tree but are connected to it (React, Dioxus). In this approach, the user constructs some alternate tree (the "component hierarchy") that is not the output itself, but rather a scaffolding for building the output. This tree has roughly the same shape as the output.
The user usually doesn't reference the output directly - that is, the user doesn't keep around a reference to the DOM node or entity, instead they hold on to a reference to the scaffolding (an instantiated template), which in turn holds a reference to the output roots. Updating the parameters of this template causes a regeneration of the output.
In this approach, the parameter state is typically stored as properties within the scaffolding nodes.
Temporary scaffolding is a tree that is newly constructed each time (Xylem). In this approach, we regenerate the scaffolding every time from code, throwing it away as soon as we are done. In order for this approach to work, we need three for things to be true:
Scaffolding embedded within the output as hidden metadata. In the case of Bevy, this can often be done by hidden components (hidden in the sense that they have no effect on the entity's appearance). However, we frequently run into cases where there's no unambiguous single entity that should "own" the scaffolding: examples are templates with multiple roots, or conditional blocks with multiple children. In these cases, we need to insert additional entities, such as ghost nodes, into the tree to hang on to this data.
Differencing
All of the solutions mentioned use some form of "diffing", in the sense that there's some code that compares the old state to the new. However, this may or may not involve actually walking the entire output tree and comparing every property.
In fact, all diffing solutions are selective to some extent: just blindly iterating over all properties (via reflection) doesn't work, because there are some properties that need to be able to update on a different schedule. This is true in React as well: popular animation libraries such as
react-springandframer-motiondirectly update animatable properties in the DOM, bypassing the diffing mechanism, as this is the only way they can achieve high frame rates cheaply.This is one of the reasons why React uses a VDOM (Virtual DOM). Not only does it avoid slow DOM operations like appending a node, but the VDOM only contains properties that are meant to be diffed, and doesn't include properties that we want to mutate directly.
For a VDOM, it's pretty simple: if the user mentions a property in the template, then it's meant to be diffed; otherwise leave it alone. This requires a data structure that supports a sparse representation of properties, which entities and components certainly do not.
This has consequences for Bevy: it means that a naive approach which attempts to diff components using reflection won't work, since the diff algorithm would have no way to know which properties are suitable for diffing and which are not (particularly because this decision is highly context-sensitive). Instead, you'd have to build some separate AST-like structure which could then be transformed into entities. This is effectively how Dioxus works, if I understand things correctly.
Some frameworks (like Svelte and Solid) know at compile time which parts of the output hierarchy are static and which are dynamic (they can look for dynamic control-flow elements in the template), and avoid diffing the static parts.
You can take this even further and apply diffing at the parameter state level instead of diffing the output tree, at which point your incremental strategy becomes a kind of memoization of sub-trees rather than a classic "diff". This also avoids wasting time rebuilding parts of the tree that don't change, and which would be discarded after discovering that the diff detected no changes.
Is BSN incremental?
BSN, as currently prototyped, does involve something called a "patch". However, the patch mechanism in BSN is designed to facilitate composition, not dynamic updates. It allows you to bring together multiple sources during construction. It's not designed to modify entities once construction is completed.
Working with BSN macros right now is a little bit like the "temporary scaffolding" approach: you produce an
impl Scene, this is used to construct an entity tree, and then theSceneis disposed. It doesn't have any long-lived parameter state, which would require a much different API.There are a couple of ways that BSN could be extended to support incrementalization:
The second approach is the one I am currently experimenting with. The basic idea is that you have components whose job it is to perform dynamic updates on the tree. As far as BSN knows, these are just components like any other, there's no special support for them. The downside is that, without any kind of special syntactic sugar for dynamism, the syntax for defining these components is a bit boilerplatey. My approach also continues to rely on the ghost nodes feature.
Beta Was this translation helpful? Give feedback.
All reactions