From 05b79c229a070224c007994e5c2be8b67439d7d9 Mon Sep 17 00:00:00 2001 From: Tim Blackbird Date: Sun, 10 Aug 2025 04:35:08 +0200 Subject: [PATCH 01/14] Fix `scene_spawn` in template (#42) --- crates/bevy_scene2/src/lib.rs | 4 ++- crates/bevy_scene2/src/spawn.rs | 56 ++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/bevy_scene2/src/lib.rs b/crates/bevy_scene2/src/lib.rs index cb8ed3e0c7de2..5116eca6dcfff 100644 --- a/crates/bevy_scene2/src/lib.rs +++ b/crates/bevy_scene2/src/lib.rs @@ -32,8 +32,10 @@ pub struct ScenePlugin; impl Plugin for ScenePlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_asset::() - .add_systems(Update, (resolve_scene_patches, spawn_queued).chain()); + .add_systems(Update, (resolve_scene_patches, spawn_queued).chain()) + .add_observer(on_add_scene_patch_instance); } } diff --git a/crates/bevy_scene2/src/spawn.rs b/crates/bevy_scene2/src/spawn.rs index 70aa5ff5f91f9..82e16320cb38b 100644 --- a/crates/bevy_scene2/src/spawn.rs +++ b/crates/bevy_scene2/src/spawn.rs @@ -62,39 +62,51 @@ pub struct QueuedScenes { waiting_entities: HashMap, Vec>, } +#[derive(Resource, Default)] +pub struct NewScenes { + entities: Vec, +} + +pub fn on_add_scene_patch_instance( + trigger: On, + mut new_scenes: ResMut, +) { + new_scenes.entities.push(trigger.target()); +} + pub fn spawn_queued( world: &mut World, - handles: &mut QueryState<(Entity, &ScenePatchInstance), Added>, + handles: &mut QueryState<&ScenePatchInstance>, mut reader: Local>>, ) { world.resource_scope(|world, mut patches: Mut>| { world.resource_scope(|world, mut queued: Mut| { world.resource_scope(|world, events: Mut>>| { - for (entity, id) in handles - .iter(world) - .map(|(e, h)| (e, h.id())) - .collect::>() - { - if let Some(scene) = patches.get_mut(id).and_then(|p| p.resolved.as_mut()) { - let mut entity_mut = world.get_entity_mut(entity).unwrap(); - scene.spawn(&mut entity_mut).unwrap(); - } else { - let entities = queued.waiting_entities.entry(id).or_default(); - entities.push(entity); + loop { + let mut new_scenes = world.resource_mut::(); + if new_scenes.entities.is_empty() { + break; + } + for entity in core::mem::take(&mut new_scenes.entities) { + if let Ok(id) = handles.get(world, entity).map(|h| h.id()) { + if let Some(scene) = + patches.get_mut(id).and_then(|p| p.resolved.as_mut()) + { + let mut entity_mut = world.get_entity_mut(entity).unwrap(); + scene.spawn(&mut entity_mut).unwrap(); + } else { + let entities = queued.waiting_entities.entry(id).or_default(); + entities.push(entity); + } + } } } for event in reader.read(&events) { - if let AssetEvent::LoadedWithDependencies { id } = event { - let Some(scene) = patches.get_mut(*id).and_then(|p| p.resolved.as_mut()) - else { - continue; - }; - - let Some(entities) = queued.waiting_entities.remove(id) else { - continue; - }; - + if let AssetEvent::LoadedWithDependencies { id } = event + && let Some(scene) = patches.get_mut(*id).and_then(|p| p.resolved.as_mut()) + && let Some(entities) = queued.waiting_entities.remove(id) + { for entity in entities { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { scene.spawn(&mut entity_mut).unwrap(); From a0c8df36affcaf923bbc147237da5a8aebe39ba8 Mon Sep 17 00:00:00 2001 From: villor Date: Wed, 23 Jul 2025 20:53:18 +0200 Subject: [PATCH 02/14] Add `register_bundle()` to `ErasedTemplate` --- crates/bevy_ecs/src/template.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/bevy_ecs/src/template.rs b/crates/bevy_ecs/src/template.rs index c9f04b63c295f..fbf9afda05225 100644 --- a/crates/bevy_ecs/src/template.rs +++ b/crates/bevy_ecs/src/template.rs @@ -3,10 +3,10 @@ pub use bevy_ecs_macros::GetTemplate; use crate::{ - bundle::Bundle, + bundle::{Bundle, BundleInfo}, entity::{Entity, EntityPath}, error::{BevyError, Result}, - world::EntityWorldMut, + world::{EntityWorldMut, World}, }; use alloc::{boxed::Box, vec, vec::Vec}; use bevy_platform::collections::hash_map::Entry; @@ -98,6 +98,13 @@ impl GetTemplate for T { pub trait ErasedTemplate: Downcast + Send + Sync { /// Applies this template to the given `entity`. fn apply(&mut self, entity: &mut EntityWorldMut) -> Result<(), BevyError>; + + /// Registers the output bundle of this template with the given `world`. + /// + /// Returns the [`BundleInfo`] of the registered bundle, or `None` if the template does not output a bundle. + fn register_bundle<'a>(&self, _: &'a mut World) -> Option<&'a BundleInfo> { + None + } } impl_downcast!(ErasedTemplate); @@ -108,6 +115,10 @@ impl + Send + Sync + 'static> ErasedTemplate for T { entity.insert(bundle); Ok(()) } + + fn register_bundle<'a>(&self, world: &'a mut World) -> Option<&'a BundleInfo> { + Some(world.register_bundle::()) + } } // TODO: Consider cutting this From 8bdb979764c168a3b79f3747033b307176dbf9e5 Mon Sep 17 00:00:00 2001 From: villor Date: Fri, 25 Jul 2025 11:28:32 +0200 Subject: [PATCH 03/14] Add scene reconciliation --- crates/bevy_scene2/src/lib.rs | 7 +- crates/bevy_scene2/src/reconcile.rs | 312 ++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 crates/bevy_scene2/src/reconcile.rs diff --git a/crates/bevy_scene2/src/lib.rs b/crates/bevy_scene2/src/lib.rs index 5116eca6dcfff..8d04f91f89912 100644 --- a/crates/bevy_scene2/src/lib.rs +++ b/crates/bevy_scene2/src/lib.rs @@ -2,11 +2,13 @@ pub mod prelude { pub use crate::{ - bsn, bsn_list, on, CommandsSpawnScene, LoadScene, PatchGetTemplate, PatchTemplate, Scene, - SceneList, ScenePatchInstance, SpawnScene, + bsn, bsn_list, on, CommandsSpawnScene, EntityCommandsReconcileScene, LoadScene, + PatchGetTemplate, PatchTemplate, ReconcileScene, Scene, SceneList, ScenePatchInstance, + SpawnScene, }; } +mod reconcile; mod resolved_scene; mod scene; mod scene_list; @@ -15,6 +17,7 @@ mod spawn; pub use bevy_scene2_macros::*; +pub use reconcile::*; pub use resolved_scene::*; pub use scene::*; pub use scene_list::*; diff --git a/crates/bevy_scene2/src/reconcile.rs b/crates/bevy_scene2/src/reconcile.rs new file mode 100644 index 0000000000000..bbdb8b9141f6c --- /dev/null +++ b/crates/bevy_scene2/src/reconcile.rs @@ -0,0 +1,312 @@ +//! Reconciliation is the process of incrementally updating entities, components, +//! and relationships from a [`Scene`] by storing state from previous reconciliations. +//! +//! When a scene is reconciled on an entity, it will: +//! 1. Build the templates from the scene. +//! 2. Remove components that were previously inserted during reconciliation but should no longer be present. +//! - This includes components that were present in the previous bundle but absent in the new one +//! - and components that were explicit in the previous bundle but implicit (required components) in the new one. +//! 3. Insert the new components onto the entity. +//! - *Note*: There is no diffing of values involved here, components are re-inserted on every reconciliation. +//! 4. Map related entities to previously reconciled entities by their +//! [`ReconcileAnchor`]s or otherwise spawn new entities +//! 5. Despawn any leftover orphans from outdated relationships. +//! 6. Recursively reconcile related entities (1). +//! 7. Store the state of the reconciliation in a [`ReconcileReceipt`] component on the entity. +//! +//! # Caveats +//! - Currently does not play well with observers added using [`crate::on`]/[`crate::OnTemplate`]. +//! - Currently not integrated with the deferred/async scene systems, all dependencies must be loaded before reconciliation. +//! +//! # Example +//! ``` +//! use bevy_ecs::prelude::*; +//! use bevy_scene2::prelude::*; +//! +//! fn update_ui_system(ui_root: Single>, mut commands: Commands) { +//! commands.entity(*ui_root).reconcile_scene(bsn! { +//! Node [ +//! #NamedEntity Text("This child will be mapped to/recycle the same entity (among siblings) on every reconciliation"), +//! Text("This child will recycle an existing unnamed entity if it exists") +//! ], +//! }); +//! } +//! ``` +use core::any::TypeId; + +use bevy_asset::{AssetServer, Assets}; +use bevy_ecs::{ + bundle::BundleId, + component::{Component, ComponentId}, + entity::Entity, + error::{warn, Result}, + name::Name, + system::EntityCommands, + world::{EntityWorldMut, Mut, World}, +}; +use bevy_platform::collections::{HashMap, HashSet}; +use bevy_utils::TypeIdMap; + +use crate::{ResolvedRelatedScenes, ResolvedScene, Scene, ScenePatch}; + +/// An identifier for related entities during scene reconciliation. +#[derive(Hash, Eq, PartialEq, Clone, Debug)] +pub enum ReconcileAnchor { + /// The entity uses an automatic incrementing ID. + Auto(u64), + /// The entity has been explicitly keyed with a [`Name`]. + Named(Name), +} + +/// Holds state for scene reconciliation, tracking the previous bundle and related entities. +#[derive(Component, Default, Clone, Debug)] +pub struct ReconcileReceipt { + /// Id of the bundle that was inserted on the previous reconciliation. + pub bundle_id: Option, + /// The anchors of the related entities on the previous reconciliation. + pub anchors: TypeIdMap>, +} + +pub trait EntityCommandsReconcileScene { + /// Reconciles a scene on the entity. + /// + /// This will emit a warning if any templates fail to be applied. + /// + /// See [`crate::reconcile`] for more details on reconciliation. + /// + /// # Note + /// - This is a synchronous operation and any dependencies + /// of the scene must be loaded before calling this method. + /// - Unlike [`crate::SpawnScene::spawn_scene`], this method will not insert a [`crate::ScenePatchInstance`] component + /// + fn reconcile_scene(&mut self, scene: S) -> &mut Self; +} + +impl EntityCommandsReconcileScene for EntityCommands<'_> { + fn reconcile_scene(&mut self, scene: S) -> &mut Self { + self.queue_handled( + |mut entity: EntityWorldMut| -> Result { + entity.reconcile_scene(scene)?; + Ok(()) + }, + warn, + ) + } +} + +pub trait ReconcileScene { + /// Reconciles the given scene on the entity. + /// + /// See [`crate::reconcile`] for more details on reconciliation. + /// + /// # Note + /// - This is a synchronous operation and any dependencies + /// of the scene must be loaded before calling this method. + /// - Unlike [`crate::SpawnScene::spawn_scene`], this method will not insert a [`crate::ScenePatchInstance`] component + /// + fn reconcile_scene(&mut self, scene: S) -> Result<&mut Self>; + + /// Reconciles a [`ResolvedScene`] on the entity. + /// + /// See [`crate::reconcile`] for more details on reconciliation. + /// + /// Returns an error if any of the templates fail to be applied. + fn reconcile_resolved_scene(&mut self, scene: &mut ResolvedScene) -> Result<&mut Self>; +} + +impl<'w> ReconcileScene for EntityWorldMut<'w> { + fn reconcile_scene(&mut self, scene: S) -> Result<&mut Self> { + // TODO: Deferred scene resolution + let mut resolved_scene = ResolvedScene::default(); + self.world_scope(|world| { + world.resource_scope(|world, assets: Mut| { + scene.patch( + &assets, + world.resource::>(), + &mut resolved_scene, + ); + }); + }); + + self.reconcile_resolved_scene(&mut resolved_scene) + } + + fn reconcile_resolved_scene(&mut self, scene: &mut ResolvedScene) -> Result<&mut Self> { + // Take the receipt from the targeted entity using core::mem::take to avoid archetype moves + let mut receipt = self + .get_mut::() + .map_or_else(ReconcileReceipt::default, |mut r| { + core::mem::take(r.as_mut()) + }); + + let entity_id = self.id(); + + // Diff/remove components + self.world_scope(|world| { + // Collect all the component IDs that will be inserted by the templates + // TODO: Optimize? + let mut component_ids = Vec::with_capacity(scene.templates.len()); + for template in scene.templates.iter_mut() { + if let Some(bundle_info) = template.register_bundle(world) { + component_ids.extend(bundle_info.iter_explicit_components()); + } + } + + // Get the bundle ID of the new component set + let bundle_id = world.register_dynamic_bundle(&component_ids).id(); + + // Remove the components that are no longer needed + if let Some(prev_bundle_id) = receipt.bundle_id { + remove_components_incremental(world, entity_id, prev_bundle_id, bundle_id); + } + + receipt.bundle_id = Some(bundle_id); + }); + + // Apply the templates to the entity + // TODO: Insert as dynamic bundle to avoid archetype moves + for template in scene.templates.iter_mut() { + template.apply(self)?; + } + + self.world_scope(|world| -> Result { + // Reconcile new/updated relationships + for (type_id, related) in scene.related.iter_mut() { + reconcile_related(*type_id, entity_id, related, &mut receipt, world)?; + } + + // Despawn any leftover orphans from outdated relationships + for (type_id, anchors) in receipt.anchors.iter_mut() { + if !scene.related.contains_key(type_id) { + for (_, orphan_id) in anchors.drain() { + if let Ok(entity) = world.get_entity_mut(orphan_id) { + entity.despawn(); + } + } + } + } + + Ok(()) + })?; + + // (Re)Insert the receipt on the entity + self.insert(receipt); + + Ok(self) + } +} + +/// Reconciles the given `related` scenes with the target entity. +fn reconcile_related( + type_id: TypeId, + target_entity: Entity, + related: &mut ResolvedRelatedScenes, + receipt: &mut ReconcileReceipt, + world: &mut World, +) -> Result { + let mut previous_anchors = receipt.anchors.remove(&type_id).unwrap_or_default(); + let receipt_anchors = receipt + .anchors + .entry(type_id) + .or_insert_with(|| HashMap::with_capacity(related.scenes.len())); + let mut ordered_entity_ids = Vec::with_capacity(related.scenes.len()); + + let mut i = 0; + for related_scene in related.scenes.iter_mut() { + // Compute the anchor for this scene, using it's name if supplied + // or an auto-incrementing counter if not. + let name_index = related_scene + .template_indices + .get(&TypeId::of::()) + .copied(); + let anchor = match name_index { + Some(name_index) => ReconcileAnchor::Named( + // TODO: Sanity check for duplicate names + related_scene.templates[name_index] + .downcast_ref::() + .unwrap() + .clone(), + ), + None => { + let anchor = ReconcileAnchor::Auto(i); + i += 1; + anchor + } + }; + + // Find the existing related entity based on the anchor, or spawn a + // new one. + let entity_id = previous_anchors + .remove(&anchor) + .unwrap_or_else(|| world.spawn_empty().id()); + + // Add the anchor and entity id to the receipt + receipt_anchors.insert(anchor, entity_id); + ordered_entity_ids.push(entity_id); + } + + // Clear any remaining orphans + for orphan_id in previous_anchors.into_values() { + if let Ok(entity) = world.get_entity_mut(orphan_id) { + entity.despawn(); + } + } + + // Insert the relationships + for related_entity in ordered_entity_ids.iter() { + let mut entity = world.entity_mut(*related_entity); + (related.insert)(&mut entity, target_entity); + } + + // Reconcile the related scenes + for (related_scene, entity_id) in related.scenes.iter_mut().zip(ordered_entity_ids.iter()) { + world + .entity_mut(*entity_id) + .reconcile_resolved_scene(related_scene)?; + } + + Ok(()) +} + +/// Removes components that should no longer be present when replacing a previous bundle with a new one: +/// - Components that were present in the previous bundle, but absent in the new one +/// - Components that were explicit in the previous bundle, but required (implicit) in the new one +/// +/// Panics if any of the [`BundleId`]s are not registered in the world. +fn remove_components_incremental( + world: &mut World, + entity_id: Entity, + prev_bundle_id: BundleId, + new_bundle_id: BundleId, +) { + // Compare the previous bundle with the new bundle to determine which components to remove + // TODO: Optimize to avoid mass heap allocations + let (new_contributed, new_required) = { + let new_bundle_info = world + .bundles() + .get(new_bundle_id) + .expect("new bundle should be registered"); + let new_contributed: HashSet = + new_bundle_info.iter_contributed_components().collect(); + let new_required: HashSet = + new_bundle_info.iter_required_components().collect(); + (new_contributed, new_required) + }; + let prev_bundle_info = world + .bundles() + .get(prev_bundle_id) + .expect("previous bundle should be registered"); + let prev_explicit: HashSet = prev_bundle_info.iter_explicit_components().collect(); + + let removed_components: Vec = prev_bundle_info + .iter_contributed_components() + .filter(|id| { + !new_contributed.contains(id) + || (prev_explicit.contains(id) && new_required.contains(id)) + }) + .collect(); + + // Remove the components that are no longer needed. + let mut entity = world.entity_mut(entity_id); + entity.remove_by_ids(&removed_components); +} From 74f946f02f7d8c3d10eafbf9007365b46f1bcd65 Mon Sep 17 00:00:00 2001 From: villor Date: Fri, 25 Jul 2025 12:32:15 +0200 Subject: [PATCH 04/14] Use required components to make internal state of feathers widgets implicit --- crates/bevy_feathers/src/controls/button.rs | 9 +++--- crates/bevy_feathers/src/controls/checkbox.rs | 11 ++++--- crates/bevy_feathers/src/controls/radio.rs | 13 +++++--- crates/bevy_feathers/src/controls/slider.rs | 31 +++++++++---------- .../src/controls/toggle_switch.rs | 27 +++++++++------- 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index 3010cecfdc962..6646a6bc7be8b 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -28,6 +28,11 @@ use bevy_input_focus::tab_navigation::TabIndex; /// system to identify which entities are buttons. #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require( + Hovered, + ThemeBackgroundColor(tokens::BUTTON_BG), + ThemeFontColor(tokens::BUTTON_TEXT) +)] pub enum ButtonVariant { /// The standard button appearance #[default] @@ -71,11 +76,8 @@ pub fn button(props: ButtonProps) -> impl Scene { } template_value(props.variant) template_value(props.corners.to_border_radius(4.0)) - Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - ThemeBackgroundColor(tokens::BUTTON_BG) - ThemeFontColor(tokens::BUTTON_TEXT) InheritableFont { font: fonts::REGULAR, font_size: 14.0, @@ -101,7 +103,6 @@ pub fn tool_button(props: ButtonProps) -> impl Scene { } template_value(props.variant) template_value(props.corners.to_border_radius(3.0)) - Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) ThemeBackgroundColor(tokens::BUTTON_BG) diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index 9cefa3e96a80f..972b0969de564 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -39,16 +39,22 @@ pub struct CheckboxProps { /// Marker for the checkbox frame (contains both checkbox and label) #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require(Hovered, ThemeFontColor(tokens::CHECKBOX_TEXT))] struct CheckboxFrame; /// Marker for the checkbox outline #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require( + ThemeBackgroundColor(tokens::CHECKBOX_BG), + ThemeBorderColor(tokens::CHECKBOX_BORDER) +)] struct CheckboxOutline; /// Marker for the checkbox check mark #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require(ThemeBorderColor(tokens::CHECKBOX_MARK))] struct CheckboxMark; /// Checkbox scene function. @@ -68,10 +74,8 @@ pub fn checkbox(props: CheckboxProps) -> impl Scene { on_change: {props.on_change.clone()}, } CheckboxFrame - Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - ThemeFontColor(tokens::CHECKBOX_TEXT) InheritableFont { font: fonts::REGULAR, font_size: 14.0, @@ -84,8 +88,6 @@ pub fn checkbox(props: CheckboxProps) -> impl Scene { } CheckboxOutline BorderRadius::all(Val::Px(4.0)) - ThemeBackgroundColor(tokens::CHECKBOX_BG) - ThemeBorderColor(tokens::CHECKBOX_BORDER) [( // Cheesy checkmark: rotated node with L-shaped border. Node { @@ -101,7 +103,6 @@ pub fn checkbox(props: CheckboxProps) -> impl Scene { } UiTransform::from_rotation(Rot2::FRAC_PI_4) CheckboxMark - ThemeBorderColor(tokens::CHECKBOX_MARK) )] )] } diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index 3049bbb3945f9..d0d423f6f118c 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -31,13 +31,21 @@ use crate::{ /// Marker for the radio outline #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require(ThemeBorderColor(tokens::RADIO_BORDER))] struct RadioOutline; /// Marker for the radio check mark #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require(ThemeBackgroundColor(tokens::RADIO_MARK))] struct RadioMark; +/// Marker for the radio frame +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +#[require(Hovered, ThemeFontColor(tokens::RADIO_TEXT))] +pub struct RadioFrame; + /// Radio scene function. pub fn radio() -> impl Scene { bsn! { @@ -49,10 +57,9 @@ pub fn radio() -> impl Scene { column_gap: Val::Px(4.0), } CoreRadio - Hovered + RadioFrame EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - ThemeFontColor(tokens::RADIO_TEXT) InheritableFont { font: fonts::REGULAR, font_size: 14.0, @@ -68,7 +75,6 @@ pub fn radio() -> impl Scene { } RadioOutline BorderRadius::MAX - ThemeBorderColor(tokens::RADIO_BORDER) [( // Cheesy checkmark: rotated node with L-shaped border. Node { @@ -77,7 +83,6 @@ pub fn radio() -> impl Scene { } BorderRadius::MAX RadioMark - ThemeBackgroundColor(tokens::RADIO_MARK) )] )] } diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index 8855fe077f873..7ad1377675b95 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -36,8 +36,6 @@ use crate::{ /// Slider template properties, passed to [`slider`] function. pub struct SliderProps { - /// Slider current value - pub value: f32, /// Slider minimum value pub min: f32, /// Slider maximum value @@ -49,7 +47,6 @@ pub struct SliderProps { impl Default for SliderProps { fn default() -> Self { Self { - value: 0.0, min: 0.0, max: 1.0, on_change: CallbackTemplate::Ignore, @@ -59,11 +56,25 @@ impl Default for SliderProps { #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require( + SliderValue, + BackgroundGradient(vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgba, + })]) +)] struct SliderStyle; /// Marker for the text #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require(Text)] struct SliderValueText; /// Slider scene function. @@ -85,22 +96,10 @@ pub fn slider(props: SliderProps) -> impl Scene { track_click: TrackClick::Drag, } SliderStyle - SliderValue({props.value}) SliderRange::new(props.min, props.max) EntityCursor::System(bevy_window::SystemCursorIcon::EwResize) TabIndex(0) template_value(RoundedCorners::All.to_border_radius(6.0)) - // Use a gradient to draw the moving bar - BackgroundGradient({vec![Gradient::Linear(LinearGradient { - angle: PI * 0.5, - stops: vec![ - ColorStop::new(Color::NONE, Val::Percent(0.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(100.)), - ], - color_space: InterpolationColorSpace::Srgba, - })]}) [( // Text container Node { @@ -114,7 +113,7 @@ pub fn slider(props: SliderProps) -> impl Scene { font: fonts::MONO, font_size: 12.0, } - [(Text::new("10.0") ThemedText SliderValueText)] + [(ThemedText SliderValueText)] )] } } diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index 673528bef75a6..c5d430c6c7740 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -36,11 +36,27 @@ pub struct ToggleSwitchProps { /// Marker for the toggle switch outline #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require( + Hovered, + ThemeBackgroundColor(tokens::SWITCH_BG), + ThemeBorderColor(tokens::SWITCH_BORDER) +)] struct ToggleSwitchOutline; /// Marker for the toggle switch slide #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] +#[require( + ThemeBackgroundColor(tokens::SWITCH_SLIDE), + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Px(0.), + bottom: Val::Px(0.), + width: Val::Percent(50.), + ..Default::default() + }) +] struct ToggleSwitchSlide; /// Toggle switch scene function. @@ -59,23 +75,12 @@ pub fn toggle_switch(props: ToggleSwitchProps) -> impl Scene { } ToggleSwitchOutline BorderRadius::all(Val::Px(5.0)) - ThemeBackgroundColor(tokens::SWITCH_BG) - ThemeBorderColor(tokens::SWITCH_BORDER) AccessibilityNode(accesskit::Node::new(Role::Switch)) - Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) [( - Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.), - top: Val::Px(0.), - bottom: Val::Px(0.), - width: Val::Percent(50.), - } BorderRadius::all(Val::Px(3.0)) ToggleSwitchSlide - ThemeBackgroundColor(tokens::SWITCH_SLIDE) )] } } From e3024a050ca01bafff7fc7cce63443f83f7ff366 Mon Sep 17 00:00:00 2001 From: villor Date: Fri, 25 Jul 2025 13:15:55 +0200 Subject: [PATCH 05/14] Fix compilation of feathers example --- examples/ui/feathers.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index 3f4c2daf356c1..f9e136b4dd124 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -239,7 +239,6 @@ fn demo_root() -> impl Scene { ( :slider(SliderProps { max: 100.0, - value: 20.0, ..default() }) SliderStep(10.) From 027e39c3d6e097cdf80a40d7165924569047901a Mon Sep 17 00:00:00 2001 From: villor Date: Tue, 12 Aug 2025 23:44:27 +0200 Subject: [PATCH 06/14] Rename SliderStyle -> Slider --- crates/bevy_feathers/src/controls/slider.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index 7ad1377675b95..bf042c391d8d3 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -69,7 +69,7 @@ impl Default for SliderProps { color_space: InterpolationColorSpace::Srgba, })]) )] -struct SliderStyle; +struct Slider; /// Marker for the text #[derive(Component, Default, Clone, Reflect)] @@ -95,7 +95,7 @@ pub fn slider(props: SliderProps) -> impl Scene { on_change: {props.on_change.clone()}, track_click: TrackClick::Drag, } - SliderStyle + Slider SliderRange::new(props.min, props.max) EntityCursor::System(bevy_window::SystemCursorIcon::EwResize) TabIndex(0) @@ -121,7 +121,7 @@ pub fn slider(props: SliderProps) -> impl Scene { fn update_slider_colors( mut q_sliders: Query< (Has, &mut BackgroundGradient), - (With, Or<(Spawned, Added)>), + (With, Or<(Spawned, Added)>), >, theme: Res, ) { @@ -160,7 +160,7 @@ fn update_slider_pos( mut q_sliders: Query< (Entity, &SliderValue, &SliderRange, &mut BackgroundGradient), ( - With, + With, Or<( Changed, Changed, From d742acc2b9a73a6265cf17ee6762887c604cf09c Mon Sep 17 00:00:00 2001 From: villor Date: Wed, 13 Aug 2025 00:10:30 +0200 Subject: [PATCH 07/14] Decouple stateful components from color slider patch --- .../src/controls/color_slider.rs | 69 ++++++++++--------- examples/ui/feathers.rs | 7 -- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 1f70b2d4c9eec..d6c65c56be875 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -1,14 +1,12 @@ use core::f32::consts::PI; use bevy_app::{Plugin, PreUpdate}; -use bevy_asset::Handle; use bevy_color::{Alpha, Color, Hsla}; use bevy_core_widgets::{ CallbackTemplate, CoreSlider, CoreSliderThumb, SliderRange, SliderValue, TrackClick, ValueChange, }; use bevy_ecs::{ - bundle::Bundle, component::Component, entity::Entity, hierarchy::Children, @@ -25,13 +23,9 @@ use bevy_ui::{ FlexDirection, Gradient, InterpolationColorSpace, LinearGradient, Node, Outline, PositionType, UiRect, UiTransform, Val, Val2, ZIndex, }; -use bevy_ui_render::ui_material::MaterialNode; use crate::{ - alpha_pattern::{AlphaPattern, AlphaPatternMaterial}, - cursor::EntityCursor, - palette, - rounded_corners::RoundedCorners, + alpha_pattern::AlphaPattern, cursor::EntityCursor, palette, rounded_corners::RoundedCorners, }; const SLIDER_HEIGHT: f32 = 16.0; @@ -144,8 +138,6 @@ pub struct SliderBaseColor(pub Color); /// Color slider template properties, passed to [`color_slider`] function. pub struct ColorSliderProps { - /// Slider current value - pub value: f32, /// On-change handler pub on_change: CallbackTemplate>>, /// Which color component we're editing @@ -155,7 +147,6 @@ pub struct ColorSliderProps { impl Default for ColorSliderProps { fn default() -> Self { Self { - value: 0.0, on_change: CallbackTemplate::Ignore, channel: ColorChannel::Alpha, } @@ -164,7 +155,7 @@ impl Default for ColorSliderProps { /// A color slider widget. #[derive(Component, Default, Clone)] -#[require(SliderBaseColor(Color::WHITE))] +#[require(SliderValue, SliderBaseColor(Color::WHITE))] pub struct ColorSlider { /// Which channel is being edited by this slider. pub channel: ColorChannel, @@ -174,8 +165,38 @@ pub struct ColorSlider { #[derive(Component, Default, Clone)] struct ColorSliderTrack; +/// Marker for the left/right endcaps +#[derive(Component, Default, Clone)] +#[require(BackgroundColor)] +struct ColorSliderEndCap; + +#[derive(Component, Default, Clone)] +#[require( + BackgroundGradient(vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgba, + })]) +)] +struct ColorSliderGradient; + /// Marker for the thumb #[derive(Component, Default, Clone)] +#[require( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Percent(50.), + width: Val::Px(THUMB_SIZE), + height: Val::Px(THUMB_SIZE), + border: UiRect::all(Val::Px(2.0)), + ..Default::default() + } +)] struct ColorSliderThumb; /// Spawn a new slider widget. @@ -201,7 +222,6 @@ pub fn color_slider(props: ColorSliderProps) -> impl Scene { ColorSlider { channel: {props.channel.clone()}, } - SliderValue({props.value}) template_value(channel_range) EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) @@ -216,41 +236,24 @@ pub fn color_slider(props: ColorSliderProps) -> impl Scene { } template_value(RoundedCorners::All.to_border_radius(TRACK_RADIUS)) ColorSliderTrack - AlphaPattern - MaterialNode::(Handle::default()) + AlphaPattern::default() [ // Left endcap ( + ColorSliderEndCap Node { width: Val::Px({THUMB_SIZE * 0.5}), } template_value(RoundedCorners::Left.to_border_radius(TRACK_RADIUS)) - BackgroundColor({palette::X_AXIS}) ), // Track with gradient ( Node { flex_grow: 1.0, } - BackgroundGradient({vec![Gradient::Linear(LinearGradient { - angle: PI * 0.5, - stops: vec![ - ColorStop::new(Color::NONE, Val::Percent(0.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(100.)), - ], - color_space: InterpolationColorSpace::Srgba, - })]}) + ColorSliderGradient ZIndex(1) [ - Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.), - top: Val::Percent(50.), - width: Val::Px(THUMB_SIZE), - height: Val::Px(THUMB_SIZE), - border: UiRect::all(Val::Px(2.0)), - } CoreSliderThumb ColorSliderThumb BorderRadius::MAX @@ -268,11 +271,11 @@ pub fn color_slider(props: ColorSliderProps) -> impl Scene { ), // Right endcap ( + ColorSliderEndCap Node { width: Val::Px({THUMB_SIZE * 0.5}), } template_value(RoundedCorners::Right.to_border_radius(TRACK_RADIUS)) - BackgroundColor({palette::Z_AXIS}) ), ] ] diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index f9e136b4dd124..7d2abf55df12f 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -252,7 +252,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.rgb_color.red = change.value; @@ -263,7 +262,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.rgb_color.green = change.value; @@ -274,7 +272,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.rgb_color.blue = change.value; @@ -285,7 +282,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.rgb_color.alpha = change.value; @@ -302,7 +298,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.hsl_color.hue = change.value; @@ -313,7 +308,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.hsl_color.saturation = change.value; @@ -324,7 +318,6 @@ fn demo_root() -> impl Scene { ), :color_slider( ColorSliderProps { - value: 0.5, on_change: callback( |change: In>, mut color: ResMut| { color.hsl_color.lightness = change.value; From d80425a8f4e8b5898b6c58e317ed9f310033aca6 Mon Sep 17 00:00:00 2001 From: villor Date: Wed, 13 Aug 2025 00:21:28 +0200 Subject: [PATCH 08/14] Add bsn_reconcile example --- Cargo.toml | 4 + examples/scene/bsn_reconcile.rs | 198 ++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 examples/scene/bsn_reconcile.rs diff --git a/Cargo.toml b/Cargo.toml index 59f3c631f47ed..d570ba13d5ecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2825,6 +2825,10 @@ wasm = false name = "bsn" path = "examples/scene/bsn.rs" +[[example]] +name = "bsn_reconcile" +path = "examples/scene/bsn_reconcile.rs" + [[example]] name = "ui_scene" path = "examples/scene/ui_scene.rs" diff --git a/examples/scene/bsn_reconcile.rs b/examples/scene/bsn_reconcile.rs new file mode 100644 index 0000000000000..1e64820a16304 --- /dev/null +++ b/examples/scene/bsn_reconcile.rs @@ -0,0 +1,198 @@ +//! This example shows off reconciliation using the `bevy_feathers` widgets. +//! +//! It also serves as a test suite to see how the widget states behave when the scene is reconciled. +//! +//! Run this with subsecond hot patching to enable bsn!-macro hot reloading: +//! `BEVY_ASSET_ROOT="." dx serve --hot-patch --example bsn_reconcile --features=hotpatching` +//! + +use bevy::{ + color::palettes, + core_widgets::{ + callback, Activate, CoreRadio, CoreRadioGroup, CoreWidgetsPlugins, SliderPrecision, + SliderStep, SliderValue, ValueChange, + }, + feathers::{ + controls::{ + button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, + ButtonProps, CheckboxProps, ColorChannel, ColorSliderProps, SliderProps, + ToggleSwitchProps, + }, + dark_theme::create_dark_theme, + theme::{ThemeBackgroundColor, ThemedText, UiTheme}, + tokens, FeathersPlugin, + }, + input_focus::{ + tab_navigation::{TabGroup, TabNavigationPlugin}, + InputDispatchPlugin, + }, + prelude::*, + scene2::prelude::{Scene, *}, + ui::Checked, +}; + +/// A struct to hold the state of various widgets shown in the demo. +#[derive(Resource)] +struct DemoWidgetStates { + controlled_slider_value: f32, + hsl_color: Hsla, +} + +fn main() { + App::new() + .add_plugins(( + DefaultPlugins, + CoreWidgetsPlugins, + InputDispatchPlugin, + TabNavigationPlugin, + FeathersPlugin, + )) + .insert_resource(UiTheme(create_dark_theme())) + .insert_resource(DemoWidgetStates { + controlled_slider_value: 20.0, + hsl_color: palettes::tailwind::AMBER_800.into(), + }) + .add_systems(Startup, setup) + .add_systems(Update, reconcile_ui) + .run(); +} + +#[derive(Component)] +struct UiRoot; + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); + commands.spawn(UiRoot); +} + +fn reconcile_ui( + mut commands: Commands, + ui_root_query: Single>, + state: Res, +) { + // Reconcile the UI on every frame + commands + .entity(ui_root_query.entity()) + .reconcile_scene(demo_root(&state)); +} + +fn demo_root(state: &DemoWidgetStates) -> impl Scene { + let DemoWidgetStates { + controlled_slider_value, + hsl_color, + } = *state; + + bsn! { + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + align_items: AlignItems::Start, + justify_content: JustifyContent::Start, + display: Display::Flex, + flex_direction: FlexDirection::Row, + column_gap: Val::Px(10.0), + } + TabGroup + ThemeBackgroundColor(tokens::WINDOW_BG) + [ + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + justify_content: JustifyContent::Start, + padding: UiRect::all(Val::Px(8.0)), + row_gap: Val::Px(8.0), + width: Val::Percent(30.), + min_width: Val::Px(200.), + } [ + ( + :button(ButtonProps { + on_click: callback(|_: In| { + info!("Button clicked!"); + }), + ..default() + }) [(Text("Click me!") ThemedText)] + ), + ( + :checkbox(CheckboxProps::default()) + [(Text("Checkbox") ThemedText)] + ), + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + } + CoreRadioGroup { + // Update radio button states based on notification from radio group. + on_change: callback( + |ent: In, q_radio: Query>, mut commands: Commands| { + for radio in q_radio.iter() { + if radio == ent.0.0 { + commands.entity(radio).insert(Checked); + } else { + commands.entity(radio).remove::(); + } + } + }, + ), + } + [ + :radio [(Text("One") ThemedText)], + :radio [(Text("Two") ThemedText)], + ] + ), + :toggle_switch(ToggleSwitchProps::default()), + Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + } [ + // Uncontrolled slider (the slider widget owns the state) + ( + :slider(SliderProps { + max: 1.0, + ..default() + }) + SliderStep(0.1) + SliderPrecision(3) + ), + // Controlled slider (the caller owns the state) + ( + :slider(SliderProps { + max: 100.0, + on_change: callback(|change: In>, mut state: ResMut| { + state.controlled_slider_value = change.value; + }), + ..default() + }) + SliderValue(controlled_slider_value) + SliderStep(10.) + SliderPrecision(2) + ), + ( + Node { + justify_content: JustifyContent::SpaceBetween, + } [ + Text("Hsl"), + (color_swatch() BackgroundColor(hsl_color)), + ] + ), + // Controlled color slider + ( + :color_slider( + ColorSliderProps { + on_change: callback( + |change: In>, mut color: ResMut| { + color.hsl_color.hue = change.value; + }, + ), + channel: ColorChannel::HslHue + } + ) + SliderValue({hsl_color.hue}) + ), + ] + ], + ] + } +} From 835e5e5bdcca38d5347a786ac81a71d9cd4e6e5a Mon Sep 17 00:00:00 2001 From: villor Date: Sun, 5 Oct 2025 15:50:51 +0200 Subject: [PATCH 09/14] Remove unnecessary diffs after merge --- crates/bevy_feathers/src/controls/button.rs | 2 +- crates/bevy_feathers/src/controls/slider.rs | 2 +- crates/bevy_feathers/src/controls/toggle_switch.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index 2b61be2625af4..aeb749604fe58 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -98,7 +98,7 @@ pub fn tool_button(props: ButtonProps) -> impl Scene { font_size: 14.0, } template_value(props.variant) - template_value(props.corners.to_border_radius(3.0)) + template_value(props.corners.to_border_radius(4.0)) } } diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index 2374717096687..40247b1b7c53a 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -184,7 +184,7 @@ fn update_slider_pos( mut q_sliders: Query< (Entity, &SliderValue, &SliderRange, &mut BackgroundGradient), ( - With, + With, Or<( Changed, Changed, diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index 87cceaf447118..6d1f0f19d4acc 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -69,10 +69,10 @@ pub fn toggle_switch() -> impl Scene { AccessibilityNode(accesskit::Node::new(Role::Switch)) EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - [( + [ BorderRadius::all(Val::Px(3.0)) ToggleSwitchSlide - )] + ] } } From 69e5b4b781d1849befa33cb0d18bd04c7f8ae27a Mon Sep 17 00:00:00 2001 From: villor Date: Sun, 5 Oct 2025 18:25:39 +0200 Subject: [PATCH 10/14] Introduce OnHandle component to avoid duplicate observers during reconciliation --- crates/bevy_scene2/src/lib.rs | 38 +++++++++++++++++++++++++++------ examples/scene/bsn_reconcile.rs | 1 - 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/bevy_scene2/src/lib.rs b/crates/bevy_scene2/src/lib.rs index 42513930ebe36..116fca338101f 100644 --- a/crates/bevy_scene2/src/lib.rs +++ b/crates/bevy_scene2/src/lib.rs @@ -26,7 +26,10 @@ pub use spawn::*; use bevy_app::{App, Plugin, Update}; use bevy_asset::{AssetApp, AssetPath, AssetServer, Handle}; -use bevy_ecs::{prelude::*, system::IntoObserverSystem, template::Template}; +use bevy_ecs::{ + lifecycle::HookContext, prelude::*, system::IntoObserverSystem, template::Template, + world::DeferredWorld, +}; use std::marker::PhantomData; #[derive(Default)] @@ -65,16 +68,37 @@ impl LoadScene for AssetServer { } } +#[derive(Component, Clone)] +#[component(on_remove = on_handle_remove::)] +pub struct OnHandle(Entity, PhantomData); + +fn on_handle_remove( + mut world: DeferredWorld, + context: HookContext, +) { + let observer_entity = world.get::>(context.entity).unwrap().0; + world.commands().entity(observer_entity).despawn(); +} + pub struct OnTemplate(pub I, pub PhantomData (E, B, M)>); -impl + Clone, E: EntityEvent, B: Bundle, M: 'static> Template - for OnTemplate +impl< + I: IntoObserverSystem + Sync + Clone, + E: EntityEvent, + B: Bundle, + M: Send + Sync + 'static, + > Template for OnTemplate { - type Output = (); + type Output = OnHandle; fn build(&mut self, entity: &mut EntityWorldMut) -> Result { - entity.observe(self.0.clone()); - Ok(()) + if let Some(handle) = entity.get::>() { + return Ok(handle.clone()); + } + + let observer = Observer::new(self.0.clone()).with_entity(entity.id()); + let observer_entity = entity.world_scope(|world| world.spawn(observer).id()); + Ok(OnHandle(observer_entity, PhantomData)) } } @@ -82,7 +106,7 @@ impl< I: IntoObserverSystem + Clone + Send + Sync, E: EntityEvent, B: Bundle, - M: 'static, + M: Send + Sync + 'static, > Scene for OnTemplate { fn patch( diff --git a/examples/scene/bsn_reconcile.rs b/examples/scene/bsn_reconcile.rs index 2966f8e3fc3c7..2970f2824dc70 100644 --- a/examples/scene/bsn_reconcile.rs +++ b/examples/scene/bsn_reconcile.rs @@ -97,7 +97,6 @@ fn demo_root(state: &DemoWidgetStates) -> impl Scene { } [ ( button(ButtonProps::default()) - // TODO: Make sure observers are not duplicated on reconciliation. on(|_: On| { info!("Button clicked!"); }) From 335489f9b6175fa084186aed21fc38f776ab3378 Mon Sep 17 00:00:00 2001 From: villor Date: Sat, 1 Nov 2025 22:52:35 +0100 Subject: [PATCH 11/14] Implement two stage reconciliation to ensure ScopedEntities are populated before applying templates --- crates/bevy_scene2/Cargo.toml | 1 + crates/bevy_scene2/src/lib.rs | 2 +- crates/bevy_scene2/src/reconcile.rs | 261 ++++++++++++++++------------ 3 files changed, 149 insertions(+), 115 deletions(-) diff --git a/crates/bevy_scene2/Cargo.toml b/crates/bevy_scene2/Cargo.toml index c4307f3a5493e..a289424bfba57 100644 --- a/crates/bevy_scene2/Cargo.toml +++ b/crates/bevy_scene2/Cargo.toml @@ -15,6 +15,7 @@ bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } bevy_utils = { path = "../bevy_utils", version = "0.17.0-dev" } +indexmap = { version = "2.5.0", default-features = false } variadics_please = "1.0" [lints] diff --git a/crates/bevy_scene2/src/lib.rs b/crates/bevy_scene2/src/lib.rs index b34926daff753..afe92099bdfd3 100644 --- a/crates/bevy_scene2/src/lib.rs +++ b/crates/bevy_scene2/src/lib.rs @@ -81,7 +81,7 @@ fn on_handle_remove( context: HookContext, ) { let observer_entity = world.get::>(context.entity).unwrap().0; - world.commands().entity(observer_entity).despawn(); + world.commands().entity(observer_entity).try_despawn(); } pub struct OnTemplate(pub I, pub PhantomData (E, B, M)>); diff --git a/crates/bevy_scene2/src/reconcile.rs b/crates/bevy_scene2/src/reconcile.rs index dd2a12a46b9b9..f8f83768a90e6 100644 --- a/crates/bevy_scene2/src/reconcile.rs +++ b/crates/bevy_scene2/src/reconcile.rs @@ -1,21 +1,18 @@ //! Reconciliation is the process of incrementally updating entities, components, //! and relationships from a [`Scene`] by storing state from previous reconciliations. //! -//! When a scene is reconciled on an entity, it will: -//! 1. Build the templates from the scene. -//! 2. Remove components that were previously inserted during reconciliation but should no longer be present. +//! When a scene is reconciled, it will: +//! - Remove components that were previously inserted during reconciliation but should no longer be present. //! - This includes components that were present in the previous bundle but absent in the new one //! - and components that were explicit in the previous bundle but implicit (required components) in the new one. -//! 3. Insert the new components onto the entity. +//! - Insert the new components onto the entity. //! - *Note*: There is no diffing of values involved here, components are re-inserted on every reconciliation. -//! 4. Map related entities to previously reconciled entities by their -//! [`ReconcileAnchor`]s or otherwise spawn new entities -//! 5. Despawn any leftover orphans from outdated relationships. -//! 6. Recursively reconcile related entities (1). -//! 7. Store the state of the reconciliation in a [`ReconcileReceipt`] component on the entity. +//! - Map related entities to previously reconciled entities by their +//! [`ReconcileAnchor`]s, or otherwise spawn new entities, as well as despawn any outdated related entities. +//! - Recursively reconcile related entities. +//! - Store the state of the reconciliation in a [`ReconcileReceipt`] component on the entity. //! //! # Caveats -//! - Currently does not play well with observers added using [`crate::on`]/[`crate::OnTemplate`]. //! - Currently not integrated with the deferred/async scene systems, all dependencies must be loaded before reconciliation. //! //! # Example @@ -33,6 +30,7 @@ //! } //! ``` use core::any::TypeId; +use indexmap::IndexMap; use bevy_asset::{AssetServer, Assets}; use bevy_ecs::{ @@ -45,7 +43,7 @@ use bevy_ecs::{ template::{EntityScopes, ScopedEntities, TemplateContext}, world::{EntityWorldMut, Mut, World}, }; -use bevy_platform::collections::{HashMap, HashSet}; +use bevy_platform::collections::HashSet; use bevy_utils::TypeIdMap; use crate::{PatchContext, ResolvedRelatedScenes, ResolvedScene, Scene, ScenePatch}; @@ -65,7 +63,17 @@ pub struct ReconcileReceipt { /// Id of the bundle that was inserted on the previous reconciliation. pub bundle_id: Option, /// The anchors of the related entities on the previous reconciliation. - pub anchors: TypeIdMap>, + pub anchors: TypeIdMap>, +} + +impl ReconcileReceipt { + fn take_or_default(entity: &mut EntityWorldMut) -> Self { + entity + .get_mut::() + .map_or_else(ReconcileReceipt::default, |mut r| { + core::mem::take(r.as_mut()) + }) + } } pub trait EntityCommandsReconcileScene { @@ -106,22 +114,11 @@ pub trait ReconcileScene { /// - Unlike [`crate::SpawnScene::spawn_scene`], this method will not insert a [`crate::ScenePatchInstance`] component /// fn reconcile_scene(&mut self, scene: S) -> Result<&mut Self>; - - /// Reconciles a [`ResolvedScene`] on the entity. - /// - /// See [`crate::reconcile`] for more details on reconciliation. - /// - /// Returns an error if any of the templates fail to be applied. - fn reconcile_resolved_scene( - &mut self, - entity_scopes: &EntityScopes, - scene: &mut ResolvedScene, - ) -> Result<&mut Self>; } impl<'w> ReconcileScene for EntityWorldMut<'w> { fn reconcile_scene(&mut self, scene: S) -> Result<&mut Self> { - // TODO: Deferred scene resolution + // Recursively patch the scenes onto a resolved scene let mut resolved_scene = ResolvedScene::default(); let mut entity_scopes = EntityScopes::default(); self.world_scope(|world| { @@ -138,107 +135,75 @@ impl<'w> ReconcileScene for EntityWorldMut<'w> { }); }); - self.reconcile_resolved_scene(&entity_scopes, &mut resolved_scene) - } - - fn reconcile_resolved_scene( - &mut self, - entity_scopes: &EntityScopes, - scene: &mut ResolvedScene, - ) -> Result<&mut Self> { - // Take the receipt from the targeted entity using core::mem::take to avoid archetype moves - let mut receipt = self - .get_mut::() - .map_or_else(ReconcileReceipt::default, |mut r| { - core::mem::take(r.as_mut()) - }); - - let entity_id = self.id(); - - // Diff/remove components - self.world_scope(|world| { - // Collect all the component IDs that will be inserted by the templates - // TODO: Optimize? - let mut component_ids = Vec::with_capacity(scene.templates.len()); - for template in scene.templates.iter_mut() { - if let Some(bundle_info) = template.register_bundle(world) { - component_ids.extend(bundle_info.iter_explicit_components()); - } - } - - // Get the bundle ID of the new component set - let bundle_id = world.register_dynamic_bundle(&component_ids).id(); + // Walk the scene to reconcile entities, spawning/despawning as needed, and build up the anchors + let mut scoped_entities = ScopedEntities::new(entity_scopes.entity_count()); + reconcile_entities(&resolved_scene, self, &mut scoped_entities, &entity_scopes); - // Remove the components that are no longer needed - if let Some(prev_bundle_id) = receipt.bundle_id { - remove_components_incremental(world, entity_id, prev_bundle_id, bundle_id); - } + // Apply the templates and relationships to the entities recursively + reconcile_apply( + &mut resolved_scene, + self, + &mut scoped_entities, + &entity_scopes, + )?; - receipt.bundle_id = Some(bundle_id); - }); + Ok(self) + } +} - // Apply the templates to the entity - // TODO: Insert as dynamic bundle to avoid archetype moves / incorrect hook/observer behavior - for template in scene.templates.iter_mut() { - template.apply(&mut TemplateContext::new( - self, - &mut ScopedEntities::new(entity_scopes.entity_count()), +fn reconcile_entities( + scene: &ResolvedScene, + entity: &mut EntityWorldMut, + scoped_entities: &mut ScopedEntities, + entity_scopes: &EntityScopes, +) { + let mut receipt = ReconcileReceipt::take_or_default(entity); + + entity.world_scope(|world| { + // Reconcile new/updated related entities + for (type_id, related) in scene.related.iter() { + reconcile_related_entities( + *type_id, + related, + &mut receipt, entity_scopes, - ))?; + scoped_entities, + world, + ); } - self.world_scope(|world| -> Result { - // Reconcile new/updated relationships - for (type_id, related) in scene.related.iter_mut() { - reconcile_related( - *type_id, - entity_id, - related, - &mut receipt, - entity_scopes, - world, - )?; - } - - // Despawn any leftover orphans from outdated relationships - for (type_id, anchors) in receipt.anchors.iter_mut() { - if !scene.related.contains_key(type_id) { - for (_, orphan_id) in anchors.drain() { - if let Ok(entity) = world.get_entity_mut(orphan_id) { - entity.despawn(); - } + // Despawn any leftover orphans from outdated relationships + for (type_id, anchors) in receipt.anchors.iter_mut() { + if !scene.related.contains_key(type_id) { + for (_, orphan_id) in anchors.drain(..) { + if let Ok(entity) = world.get_entity_mut(orphan_id) { + entity.despawn(); } } } + } + }); - Ok(()) - })?; - - // (Re)Insert the receipt on the entity - self.insert(receipt); - - Ok(self) - } + entity.insert(receipt); } -/// Reconciles the given `related` scenes with the target entity. -fn reconcile_related( +fn reconcile_related_entities( type_id: TypeId, - target_entity: Entity, - related: &mut ResolvedRelatedScenes, + related: &ResolvedRelatedScenes, receipt: &mut ReconcileReceipt, entity_scopes: &EntityScopes, + scoped_entities: &mut ScopedEntities, world: &mut World, -) -> Result { +) { + // TODO: A bit wasteful to allocate a new IndexMap here each time let mut previous_anchors = receipt.anchors.remove(&type_id).unwrap_or_default(); let receipt_anchors = receipt .anchors .entry(type_id) - .or_insert_with(|| HashMap::with_capacity(related.scenes.len())); - let mut ordered_entity_ids = Vec::with_capacity(related.scenes.len()); + .or_insert_with(|| IndexMap::with_capacity(related.scenes.len())); let mut i = 0; - for related_scene in related.scenes.iter_mut() { + for related_scene in related.scenes.iter() { // Compute the anchor for this scene, using it's name if supplied // or an auto-incrementing counter if not. let name_index = related_scene @@ -263,12 +228,17 @@ fn reconcile_related( // Find the existing related entity based on the anchor, or spawn a // new one. let entity_id = previous_anchors - .remove(&anchor) + .shift_remove(&anchor) .unwrap_or_else(|| world.spawn_empty().id()); + // Update scoped entities to ensure that entity references point correctly and does not spawn duplicates + // TODO: Share name anchors inside the same scope, to allow moving entities around while preserving identity/state + if let Some((scope, index)) = related_scene.entity_references.first().copied() { + scoped_entities.set(entity_scopes, scope, index, entity_id); + } + // Add the anchor and entity id to the receipt receipt_anchors.insert(anchor, entity_id); - ordered_entity_ids.push(entity_id); } // Clear any remaining orphans @@ -278,19 +248,82 @@ fn reconcile_related( } } - // Insert the relationships - for related_entity in ordered_entity_ids.iter() { - let mut entity = world.entity_mut(*related_entity); - (related.insert)(&mut entity, target_entity); + // Reconcile the related entities + for (related_scene, entity_id) in related.scenes.iter().zip(receipt_anchors.values()) { + let mut entity = world.entity_mut(*entity_id); + reconcile_entities(related_scene, &mut entity, scoped_entities, entity_scopes); } +} + +fn reconcile_apply( + scene: &mut ResolvedScene, + entity: &mut EntityWorldMut, + scoped_entities: &mut ScopedEntities, + entity_scopes: &EntityScopes, +) -> Result { + // Take the receipt from the targeted entity using core::mem::take to avoid archetype moves + let mut receipt = ReconcileReceipt::take_or_default(entity); + + let entity_id = entity.id(); + + // Diff/remove components + entity.world_scope(|world| { + // Collect all the component IDs that will be inserted by the templates + // TODO: Optimize? + let mut component_ids = Vec::with_capacity(scene.templates.len()); + for template in scene.templates.iter_mut() { + if let Some(bundle_info) = template.register_bundle(world) { + component_ids.extend(bundle_info.iter_explicit_components()); + } + } + + // Get the bundle ID of the new component set + let bundle_id = world.register_dynamic_bundle(&component_ids).id(); - // Reconcile the related scenes - for (related_scene, entity_id) in related.scenes.iter_mut().zip(ordered_entity_ids.iter()) { - world - .entity_mut(*entity_id) - .reconcile_resolved_scene(entity_scopes, related_scene)?; + // Remove the components that are no longer needed + if let Some(prev_bundle_id) = receipt.bundle_id { + remove_components_incremental(world, entity_id, prev_bundle_id, bundle_id); + } + + receipt.bundle_id = Some(bundle_id); + }); + + // Apply the templates to the entity + // TODO: Insert as dynamic bundle to avoid archetype moves / incorrect hook/observer behavior + for template in scene.templates.iter_mut() { + template.apply(&mut TemplateContext { + entity, + scoped_entities, + entity_scopes, + })?; } + entity.world_scope(|world| -> Result { + // Reconcile components of related entities + for (type_id, related) in scene.related.iter_mut() { + let receipt_anchors = receipt.anchors.get(type_id).unwrap(); + + // Insert the relationships + for related_entity in receipt_anchors.values() { + let mut entity = world.entity_mut(*related_entity); + (related.insert)(&mut entity, entity_id); + } + + // Recursively reconcile the components/relationships of related entities + for (related_scene, entity_id) in + related.scenes.iter_mut().zip(receipt_anchors.values()) + { + let mut entity = world.entity_mut(*entity_id); + reconcile_apply(related_scene, &mut entity, scoped_entities, entity_scopes)?; + } + } + + Ok(()) + })?; + + // (Re)Insert the receipt on the entity + entity.insert(receipt); + Ok(()) } From 2deb3ae21d7afaf160198c83b57974ce6c75ad83 Mon Sep 17 00:00:00 2001 From: villor Date: Sat, 1 Nov 2025 23:11:57 +0100 Subject: [PATCH 12/14] Add todo list demo to reconcile example --- examples/scene/bsn_reconcile.rs | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/examples/scene/bsn_reconcile.rs b/examples/scene/bsn_reconcile.rs index 2970f2824dc70..92acf8c1585aa 100644 --- a/examples/scene/bsn_reconcile.rs +++ b/examples/scene/bsn_reconcile.rs @@ -7,7 +7,7 @@ //! use bevy::{ - color::palettes, + color::palettes::tailwind, feathers::{ controls::{ button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, @@ -40,7 +40,7 @@ fn main() { .insert_resource(UiTheme(create_dark_theme())) .insert_resource(DemoWidgetStates { controlled_slider_value: 20.0, - hsl_color: palettes::tailwind::AMBER_800.into(), + hsl_color: tailwind::AMBER_800.into(), }) .add_systems(Startup, setup) .add_systems(Update, reconcile_ui) @@ -184,6 +184,55 @@ fn demo_root(state: &DemoWidgetStates) -> impl Scene { ), ] ], + + :todos + ] + } +} + +fn todos() -> impl Scene { + bsn! { + Node::default() [ + Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(8.0), + padding: UiRect::all(Val::Px(8.0)), + } [ + Text("Today"), + #A :todo_item("Write BSN"), + #B :todo_item("Hot reload it!"), + #C :todo_item("Add checkboxes"), + #D :todo_item("Try some styling"), + #E :todo_item("Move things around"), + ], + + Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(8.0), + padding: UiRect::all(Val::Px(8.0)), + } [ + Text("Tomorrow"), + ] + ] + } +} + +fn todo_item(title: &'static str) -> impl Scene { + bsn! { + :checkbox + on(checkbox_self_update) + Node { + column_gap: Val::Px(10.0), + padding: UiRect::all(Val::Px(6.0)), + border: UiRect::all(Val::Px(1.0)), + align_items: AlignItems::Center, + } + BorderColor::all(tailwind::NEUTRAL_700) + BorderRadius::all(Val::Px(5.0)) + BackgroundColor(tailwind::NEUTRAL_800) + [ + Text(title) + TextColor(tailwind::NEUTRAL_100) TextFont { font_size: 16.0 } ] } } From fb0301af1b4909d62876c07c035ba0946617e645 Mon Sep 17 00:00:00 2001 From: villor Date: Sat, 1 Nov 2025 23:39:20 +0100 Subject: [PATCH 13/14] Add DependsOn to test entity reference with reconciliation --- examples/scene/bsn_reconcile.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/scene/bsn_reconcile.rs b/examples/scene/bsn_reconcile.rs index 92acf8c1585aa..da518d37c7373 100644 --- a/examples/scene/bsn_reconcile.rs +++ b/examples/scene/bsn_reconcile.rs @@ -200,7 +200,7 @@ fn todos() -> impl Scene { } [ Text("Today"), #A :todo_item("Write BSN"), - #B :todo_item("Hot reload it!"), + #B :todo_item("Hot reload it!") DependsOn(#A), #C :todo_item("Add checkboxes"), #D :todo_item("Try some styling"), #E :todo_item("Move things around"), @@ -217,6 +217,9 @@ fn todos() -> impl Scene { } } +#[derive(Component, GetTemplate)] +struct DependsOn(pub Entity); + fn todo_item(title: &'static str) -> impl Scene { bsn! { :checkbox @@ -234,5 +237,13 @@ fn todo_item(title: &'static str) -> impl Scene { Text(title) TextColor(tailwind::NEUTRAL_100) TextFont { font_size: 16.0 } ] + on(move |add: On, query: Query<&DependsOn>, mut previous: Local>| { + if let Ok(depends_on) = query.get(add.entity) + && (previous.is_none() || previous.unwrap() != depends_on.0) + { + *previous = Some(depends_on.0); + info!("'{title}' ({:?}) depends on {:?}", add.entity, depends_on.0); + } + }) } } From 506d815e07b110dcef4bd3d46efbf02bb0ae8775 Mon Sep 17 00:00:00 2001 From: villor Date: Sun, 2 Nov 2025 18:35:38 +0100 Subject: [PATCH 14/14] Fix OnHandle on_remove hook to not output warnings for despawned entities --- crates/bevy_scene2/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bevy_scene2/src/lib.rs b/crates/bevy_scene2/src/lib.rs index afe92099bdfd3..67456b44bd066 100644 --- a/crates/bevy_scene2/src/lib.rs +++ b/crates/bevy_scene2/src/lib.rs @@ -81,7 +81,11 @@ fn on_handle_remove( context: HookContext, ) { let observer_entity = world.get::>(context.entity).unwrap().0; - world.commands().entity(observer_entity).try_despawn(); + world.commands().queue(move |world: &mut World| { + if world.get_entity(context.entity).is_ok() { + world.entity_mut(observer_entity).despawn(); + } + }); } pub struct OnTemplate(pub I, pub PhantomData (E, B, M)>);