Skip to content

Next Generation Bevy Scenes #20158

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ default = [
"bevy_picking",
"bevy_render",
"bevy_scene",
"bevy_scene2",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ValorZard I'm converting your top-level comment into a thread (please see the PR description):

(Not related to this comment at alll)
so this is probably out of scope from this PR, but how exactly will .bsn as a file format will work?
My original perception was that .bsn was basically JSX from React fused with the way Godot does scenes, and that seems to mostly be the case.
However, in the example you posted of the bsn! Macro having an observer callback, that’s just a regular rust function.
bsn! { Player on(|jump: On| { info!("Player jumped"); }) }
Will a .bsn file just have arbitrary rust code that runs during the game?

The idea is that .bsn will be the subset of bsn! that can be represented in a static file. In the immediate short term, that will not include things like the on function, as we cannot include arbitrary Rust code in asset files.

The primary goal of .bsn will be to represent static component values editable in the visual Bevy Editor. In the future we might be able to support more dynamic / script-like scenarios. But that is unlikely to happen in the short term.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drat. I thought I did the comment thing correctly.

but otherwise that makes sense. So .bsn and bsn! Aren’t fully equivalent. Interesting.

I guess Bevy could support something like Godot’s callables in the future maybe, and have some sort of id sorted in the .bsn file that could be converted to a function? Idk

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, function reflection + a registry should make stringly-typed callbacks defined in assets very feasible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@3xau1o I'm converting your top level comment to a thread (please read the PR description)

can read mentions to Diffing for BSN

If the framework cannot accommodate a given approach in its current form (ex: coarse diffing)

Explore dynamic field-based lists for patches, which would BSN diffing better / more granular

Does it mean that BSN is using a slower v-dom like diffing reactivity approach in the spirit of react instead of a faster fine-grained reactivity system in the spirit of svelte5/solidjs/vue-vapor?

Is it possible to see the reasoning behind this when there is a tendency of moving towars fine-grained updated instead of brute diffing?

examples of recent libraries UI rewritten/moved from partial diffing to full fine grained reactivity include svelte 5 and vue vapor

a good reference is solidjs design

BSN does not currently support reactivity (please read the PR description). There have been many investigations into both fine grained and coarse reactive solutions (both diff-ing and signal-based) / we are well versed in the space at this point. The goal for this phase is to (if possible) build BSN in such a way that it can support both paradigms. Then we can iterate / have an ecosystem (potentially even cross-compatible) where ideas can compete. From there if a winner arises, we can bless it as the "go-to" / default solution.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can confirm there's multiple people in the community who I know are planning to experiment with different approaches. I'm looking forward to building a coarse-grained diffing system, and I'll bet @viridia will build a fine-grained solution.

I'm really pleased with this reactivity-agnostic approach, let's us avoid a big bikeshed and do more incremental exploratory work.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand:

build BSN in such a way that it can support both paradigms ... where ideas can compete

On the other hand:

We want to define scenes as patches of Templates.

Can a diff-based system be the foundation of an efficient implementation of fine-grained reactivity? The suggestion seems to be that this is supposed to be a neutral foundation, but I'd argue that it is not. You can't build a (true) reactive system on top of a diff-based system without many compromises.

Copy link
Contributor

@NthTensor NthTensor Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you may be confusing two different things: Inheritance and reactivity. Patches are just for inheritance. Personally, I don't see how they relate to reactivity (or incrementalization) at all.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NthTensor Uhh... Gotcha. They're more like layers then, right? Maybe the confusing "patch" terminology could be avoided.

Copy link

@3xau1o 3xau1o Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build BSN in such a way that it can support both paradigms ... where ideas can compete

this is only possible if BSN is only a DSL syntax like JSX which is used in both Reactjs and Solidjs
that will leave BSN as a tree description tool rather that a composable component system

there is also the need to realize that

  • fine grained reactivity is mostly compile time
  • diffing is mostly runtime

they're so different, mostly opposite, that's why Vue.js vapor was mostly a rewrite instead of an upgrade from Vue.js 3 vdom, Vue Vapor and Vue3 are not compatible, they only use the same vue syntax, same as React and Solidjs use JSX

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine grained reactivity is mostly compile time

I think you might be mixing up unrelated things. The compilation you're speaking of in e.g. SolidJS is compiling away the framework into plain JS functions. This has no relevance to the Bevy / Rust case...

"bevy_sprite",
"bevy_sprite_picking_backend",
"bevy_state",
Expand All @@ -167,6 +168,7 @@ default = [
"x11",
"debug",
"zstd_rust",
"experimental_bevy_feathers",
]

# Recommended defaults for no_std applications
Expand Down Expand Up @@ -249,6 +251,9 @@ bevy_render = ["bevy_internal/bevy_render", "bevy_color"]
# Provides scene functionality
bevy_scene = ["bevy_internal/bevy_scene", "bevy_asset"]

# Provides scene functionality
bevy_scene2 = ["bevy_internal/bevy_scene2", "bevy_asset"]

# Provides raytraced lighting (experimental)
bevy_solari = [
"bevy_internal/bevy_solari",
Expand Down Expand Up @@ -598,8 +603,9 @@ flate2 = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.140"
bytemuck = "1"
bevy_render = { path = "crates/bevy_render", version = "0.17.0-dev", default-features = false }
# The following explicit dependencies are needed for proc macros to work inside of examples as they are part of the bevy crate itself.
bevy_render = { path = "crates/bevy_render", version = "0.17.0-dev", default-features = false }
bevy_scene2 = { path = "crates/bevy_scene2", version = "0.17.0-dev", default-features = false }
bevy_ecs = { path = "crates/bevy_ecs", version = "0.17.0-dev", default-features = false }
bevy_state = { path = "crates/bevy_state", version = "0.17.0-dev", default-features = false }
bevy_asset = { path = "crates/bevy_asset", version = "0.17.0-dev", default-features = false }
Expand All @@ -619,6 +625,7 @@ anyhow = "1"
macro_rules_attribute = "0.2"
accesskit = "0.19"
nonmax = "0.5"
variadics_please = "1"

[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
smol = "2"
Expand Down Expand Up @@ -2788,6 +2795,14 @@ description = "Demonstrates loading from and saving scenes to files"
category = "Scene"
wasm = false

[[example]]
name = "bsn"
path = "examples/scene/bsn.rs"

[[example]]
name = "ui_scene"
path = "examples/scene/ui_scene.rs"

# Shaders
[[package.metadata.example_category]]
name = "Shaders"
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_a11y/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl ManageAccessibilityUpdates {
///
/// If the entity doesn't have a parent, or if the immediate parent doesn't have
/// an `AccessibilityNode`, its node will be an immediate child of the primary window.
#[derive(Component, Clone, Deref, DerefMut)]
#[derive(Component, Clone, Deref, DerefMut, Default)]
#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
pub struct AccessibilityNode(pub Node);

Expand Down
8 changes: 7 additions & 1 deletion crates/bevy_animation/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,16 @@ pub struct AnimationGraph {
}

/// A [`Handle`] to the [`AnimationGraph`] to be used by the [`AnimationPlayer`](crate::AnimationPlayer) on the same entity.
#[derive(Component, Clone, Debug, Default, Deref, DerefMut, Reflect, PartialEq, Eq, From)]
#[derive(Component, Clone, Debug, Deref, DerefMut, Reflect, PartialEq, Eq, From)]
#[reflect(Component, Default, Clone)]
pub struct AnimationGraphHandle(pub Handle<AnimationGraph>);

impl Default for AnimationGraphHandle {
fn default() -> Self {
Self(Handle::default())
}
}

impl From<AnimationGraphHandle> for AssetId<AnimationGraph> {
fn from(handle: AnimationGraphHandle) -> Self {
handle.id()
Expand Down
48 changes: 42 additions & 6 deletions crates/bevy_asset/src/handle.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
use crate::{
meta::MetaTransform, Asset, AssetId, AssetIndexAllocator, AssetPath, InternalAssetId,
UntypedAssetId,
meta::MetaTransform, Asset, AssetId, AssetIndexAllocator, AssetPath, AssetServer,
InternalAssetId, UntypedAssetId,
};
use alloc::sync::Arc;
use bevy_reflect::{std_traits::ReflectDefault, Reflect, TypePath};
use bevy_ecs::{
error::Result,
template::{GetTemplate, Template},
world::EntityWorldMut,
};
use bevy_reflect::{Reflect, TypePath};
use core::{
any::TypeId,
hash::{Hash, Hasher},
Expand Down Expand Up @@ -130,7 +135,7 @@ impl core::fmt::Debug for StrongHandle {
///
/// [`Handle::Strong`], via [`StrongHandle`] also provides access to useful [`Asset`] metadata, such as the [`AssetPath`] (if it exists).
#[derive(Reflect)]
#[reflect(Default, Debug, Hash, PartialEq, Clone)]
#[reflect(Debug, Hash, PartialEq, Clone)]
pub enum Handle<A: Asset> {
/// A "strong" reference to a live (or loading) [`Asset`]. If a [`Handle`] is [`Handle::Strong`], the [`Asset`] will be kept
/// alive until the [`Handle`] is dropped. Strong handles also provide access to additional asset metadata.
Expand All @@ -150,6 +155,9 @@ impl<T: Asset> Clone for Handle<T> {
}

impl<A: Asset> Handle<A> {
pub fn default() -> Self {
Handle::Uuid(AssetId::<A>::DEFAULT_UUID, PhantomData)
}
/// Returns the [`AssetId`] of this [`Asset`].
#[inline]
pub fn id(&self) -> AssetId<A> {
Expand Down Expand Up @@ -189,9 +197,37 @@ impl<A: Asset> Handle<A> {
}
}

impl<A: Asset> Default for Handle<A> {
impl<T: Asset> GetTemplate for Handle<T> {
type Template = HandleTemplate<T>;
}

pub struct HandleTemplate<T> {
path: AssetPath<'static>,
marker: PhantomData<T>,
}

impl<T> Default for HandleTemplate<T> {
fn default() -> Self {
Handle::Uuid(AssetId::<A>::DEFAULT_UUID, PhantomData)
Self {
path: Default::default(),
marker: Default::default(),
}
}
}

impl<I: Into<AssetPath<'static>>, T> From<I> for HandleTemplate<T> {
fn from(value: I) -> Self {
Self {
path: value.into(),
marker: PhantomData,
}
}
}

impl<T: Asset> Template for HandleTemplate<T> {
type Output = Handle<T>;
fn build(&mut self, entity: &mut EntityWorldMut) -> Result<Handle<T>> {
Ok(entity.resource::<AssetServer>().load(&self.path))
}
}

Expand Down
14 changes: 10 additions & 4 deletions crates/bevy_asset/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,12 @@ impl<A: Asset> VisitAssetDependencies for Option<Handle<A>> {
}
}

impl VisitAssetDependencies for UntypedAssetId {
fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) {
visit(*self);
}
}

impl VisitAssetDependencies for UntypedHandle {
fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) {
visit(self.id());
Expand All @@ -486,18 +492,18 @@ impl VisitAssetDependencies for Option<UntypedHandle> {
}
}

impl<A: Asset> VisitAssetDependencies for Vec<Handle<A>> {
impl<V: VisitAssetDependencies> VisitAssetDependencies for Vec<V> {
fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) {
for dependency in self {
visit(dependency.id().untyped());
dependency.visit_dependencies(visit);
}
}
}

impl VisitAssetDependencies for Vec<UntypedHandle> {
impl<A: Asset, S> VisitAssetDependencies for HashSet<Handle<A>, S> {
fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) {
for dependency in self {
visit(dependency.id());
visit(dependency.id().untyped());
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions crates/bevy_asset/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,20 @@ impl AssetServer {
self.load_asset(LoadedAsset::new_with_dependencies(asset))
}

// TODO: this is a hack: this allows the asset to pretend to be from the path, but this will cause issues in practice
#[must_use = "not using the returned strong handle may result in the unexpected release of the asset"]
pub fn load_with_path<'a, A: Asset>(
&self,
path: impl Into<AssetPath<'a>>,
asset: A,
) -> Handle<A> {
let loaded_asset: LoadedAsset<A> = asset.into();
let erased_loaded_asset: ErasedLoadedAsset = loaded_asset.into();
let path: AssetPath = path.into();
self.load_asset_untyped(Some(path.into_owned()), erased_loaded_asset)
.typed_debug_checked()
}

pub(crate) fn load_asset<A: Asset>(&self, asset: impl Into<LoadedAsset<A>>) -> Handle<A> {
let loaded_asset: LoadedAsset<A> = asset.into();
let erased_loaded_asset: ErasedLoadedAsset = loaded_asset.into();
Expand Down
5 changes: 2 additions & 3 deletions crates/bevy_core_pipeline/src/auto_exposure/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use bevy_ecs::{prelude::Component, reflect::ReflectComponent};
use bevy_image::Image;
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_render::{extract_component::ExtractComponent, view::Hdr};
use bevy_utils::default;

/// Component that enables auto exposure for an HDR-enabled 2d or 3d camera.
///
Expand Down Expand Up @@ -97,8 +96,8 @@ impl Default for AutoExposure {
speed_brighten: 3.0,
speed_darken: 1.0,
exponential_transition_distance: 1.5,
metering_mask: default(),
compensation_curve: default(),
metering_mask: Handle::default(),
compensation_curve: Handle::default(),
}
}
}
110 changes: 108 additions & 2 deletions crates/bevy_core_widgets/src/callback.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use bevy_ecs::system::{Commands, SystemId, SystemInput};
use bevy_ecs::system::{Commands, IntoSystem, SystemId, SystemInput};
use bevy_ecs::template::{GetTemplate, Template};
use bevy_ecs::world::{DeferredWorld, World};
use std::marker::PhantomData;

/// A callback defines how we want to be notified when a widget changes state. Unlike an event
/// or observer, callbacks are intended for "point-to-point" communication that cuts across the
Expand Down Expand Up @@ -27,15 +29,119 @@ use bevy_ecs::world::{DeferredWorld, World};
/// // Later, when we want to execute the callback:
/// app.world_mut().commands().notify(&callback);
/// ```
#[derive(Default, Debug)]
#[derive(Debug)]
pub enum Callback<I: SystemInput = ()> {
/// Invoke a one-shot system
System(SystemId<I>),
/// Ignore this notification
Ignore,
}

impl<I: SystemInput> Copy for Callback<I> {}
impl<I: SystemInput> Clone for Callback<I> {
fn clone(&self) -> Self {
match self {
Self::System(arg0) => Self::System(arg0.clone()),
Self::Ignore => Self::Ignore,
}
}
}

impl<In: SystemInput + 'static> GetTemplate for Callback<In> {
type Template = CallbackTemplate<In>;
}

#[derive(Default)]
pub enum CallbackTemplate<In: SystemInput = ()> {
System(Box<dyn RegisterSystem<In>>),
SystemId(SystemId<In>),
#[default]
Ignore,
}

impl<In: SystemInput + 'static> CallbackTemplate<In> {
pub fn clone(&self) -> CallbackTemplate<In> {
match self {
CallbackTemplate::System(register_system) => {
CallbackTemplate::System(register_system.box_clone())
}
CallbackTemplate::SystemId(system_id) => CallbackTemplate::SystemId(*system_id),
CallbackTemplate::Ignore => CallbackTemplate::Ignore,
}
}
}

pub trait RegisterSystem<In: SystemInput>: Send + Sync + 'static {
fn register_system(&mut self, world: &mut World) -> SystemId<In>;
fn box_clone(&self) -> Box<dyn RegisterSystem<In>>;
}

pub struct IntoWrapper<I, In, Marker> {
into_system: Option<I>,
marker: PhantomData<fn() -> (In, Marker)>,
}

pub fn callback<
I: IntoSystem<In, (), Marker> + Send + Sync + Clone + 'static,
In: SystemInput + 'static,
Marker: 'static,
>(
system: I,
) -> CallbackTemplate<In> {
CallbackTemplate::from(IntoWrapper {
into_system: Some(system),
marker: PhantomData,
})
}

impl<
I: IntoSystem<In, (), Marker> + Clone + Send + Sync + 'static,
In: SystemInput + 'static,
Marker: 'static,
> RegisterSystem<In> for IntoWrapper<I, In, Marker>
{
fn register_system(&mut self, world: &mut World) -> SystemId<In> {
world.register_system(self.into_system.take().unwrap())
Copy link
Contributor

@viridia viridia Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this leak? We need a way to unregister the callback when the owner is despawned.

This is why I had proposed using cached one-shots for inline callbacks, but we have to solve the type-erasure problem.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does leak in its current form. This style of "shared resource" cleanup feels like it should be using RAII, as we discussed previously.

I'm not sure how the a cached one-shot would solve this problem, as it would still leak in that context as it doesn't use RAII?

From my perspective the only difference between this approach and register_system_cached is where the cache lives (inside the template, which is shared across instances, or inside world).

world.register_system_cached can be directly swapped in here for world.register_system if you think it would be better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because cached one-shots eventually get removed automatically, the Callback instance can be safely dropped without needing a destructor.

For the SystemId variant, we will need destruction, which I was attempting to solve with an ownership relation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I understand the disconnect. We wouldn't use register_system_cached, that just returns an id. We'd use run_system_cached. That means that Callback retains ownership of the closure and is responsible for dropping it.

}

fn box_clone(&self) -> Box<dyn RegisterSystem<In>> {
Box::new(IntoWrapper {
into_system: self.into_system.clone(),
marker: PhantomData,
})
}
}

impl<
I: IntoSystem<In, (), Marker> + Clone + Send + Sync + 'static,
In: SystemInput + 'static,
Marker: 'static,
> From<IntoWrapper<I, In, Marker>> for CallbackTemplate<In>
{
fn from(value: IntoWrapper<I, In, Marker>) -> Self {
CallbackTemplate::System(Box::new(value))
}
}

impl<In: SystemInput + 'static> Template for CallbackTemplate<In> {
type Output = Callback<In>;

fn build(
&mut self,
entity: &mut bevy_ecs::world::EntityWorldMut,
) -> bevy_ecs::error::Result<Self::Output> {
Ok(match self {
CallbackTemplate::System(register) => {
let id = entity.world_scope(move |world| register.register_system(world));
*self = CallbackTemplate::SystemId(id);
Callback::System(id)
}
CallbackTemplate::SystemId(id) => Callback::System(*id),
CallbackTemplate::Ignore => Callback::Ignore,
})
}
}

/// Trait used to invoke a [`Callback`], unifying the API across callers.
pub trait Notify {
/// Invoke the callback with no arguments.
Expand Down
3 changes: 2 additions & 1 deletion crates/bevy_core_widgets/src/core_button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
use bevy_ui::{InteractionDisabled, Pressed};

use crate::{Activate, Callback, Notify};
use bevy_ecs::template::GetTemplate;

/// Headless button widget. This widget maintains a "pressed" state, which is used to
/// indicate whether the button is currently being pressed by the user. It emits a `ButtonClicked`
/// event when the button is un-pressed.
#[derive(Component, Default, Debug)]
#[derive(Component, Clone, Debug, GetTemplate)]
#[require(AccessibilityNode(accesskit::Node::new(Role::Button)))]
pub struct CoreButton {
/// Callback to invoke when the button is clicked, or when the `Enter` or `Space` key
Expand Down
Loading