From 5f25528d4c5b4fa08b310a7f7f03800a8c78c44d Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Sat, 4 Jul 2026 13:29:29 -0700 Subject: [PATCH 1/6] Retain UI render world data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At present, Bevy re-extracts all render world data for the UI from scratch every frame. This is wasteful, as most UI widgets don't change from frame to frame. This PR changes Bevy UI to retain phase items and render-world entities from frame to frame in the render world. It uses ECS change detection to only re-extract UI widgets that have changed in some way. UI widgets differ from 2D and 3D meshes in that a single UI widget in the main world may extract to multiple phase items in the render world. For UI nodes, multiple systems process the same node repeatedly in order to generate the various rendered pieces (the base node, drop shadows, backgrounds, etc.) To handle this, I split out the change detection and invalidation logic into a separate system, `extract_uinode_changes`, that runs before any of the main extraction systems. When that system detects a change to any of the components that are relevant to rendering, all render-world entities corresponding to the changed main-world entity are despawned and created anew. This isn't as efficient as it could be, but it's fine for now and is a sizable improvement to the current situation. Note that one unfortunate side effect of these changes is that we now mark UI nodes as changed if their visuals change in any way, as opposed to only when their layout changes. The layout code uses the `Changed` flag to determine whether reflow needs to be performed, so reflows will now be needlessly performed if e.g. the color of a node changes, even though color only affects visuals and not layout and so the reflow is unnecessary. I believe that this use of the changed flag by layout was always an abuse, because `Changed` is supposed to be set whenever a component changes, not just when some part of a component relevant to some arbitrary system (in this case, layout) changes. In other words, the `Changed` flag should be for every system to use; layout shouldn't have exclusive use of it. If we care about making these types of visual-only changes fast again, layout should be fixed to maintain its own dirty flags in a follow-up. Several potential improvements have been delegated to follow-ups. I opted to perform these as follow-ups to this PR, as this patch is already quite large. 1. The phase items in the `SortedRenderPhase` should be retained as well. This will result in a large speedup, as `extract_ui_camera_view` and `queue_uinodes` time will drop to zero if no changes were made from the previous frame, and `sort_phase_system` will likely improve as well as the data will be in mostly-sorted order. (See attached profile.) 2. The `UiBatch` component is redundant, as the phase items and `ExtractedUiNodes` already contain all the relevant data. I opted to leave it for now and clean it up later. 3. A large chunk of the GPU data that `prepare_uinodes` and similar (`prepare_shadows`, etc.) emit is redundant, as it's duplicated for every vertex. This should be moved to instance-rate shading (and, in fact, as every UI phase item represents a quad, the entire UI subsystem can be moved to *only* emit per-instance data). 4. `prepare_uinodes` and similar duplicate in an ad-hoc way what `batch_and_prepare_sorted_render_phase` does. These systems should be removed in favor of the generic functionality. 5. Much of the work that `prepare_uinodes` and similar do should be moved to the extraction phase so that it can be retained from frame to frame. 6. UI should support GPU preprocessing to eliminate the work that remains. As mentioned before, this work should be a series of follow-ups. On `many_buttons`, with this patch I observed the median frame time to decrease from 48.29 ms to 45.16 ms, a 1.07× speedup. Note that the true value of this patch as far as performance is concerned isn't what the patch itself does but rather what it unlocks in the future. --- crates/bevy_render/src/render_phase/mod.rs | 2 +- crates/bevy_ui/src/layout/mod.rs | 43 +- crates/bevy_ui/src/lib.rs | 7 +- crates/bevy_ui/src/update.rs | 43 +- crates/bevy_ui/src/widget/image.rs | 70 +- crates/bevy_ui_render/src/box_shadow.rs | 208 ++- crates/bevy_ui_render/src/debug_overlay.rs | 63 +- crates/bevy_ui_render/src/gradient.rs | 452 ++++--- crates/bevy_ui_render/src/lib.rs | 1112 +++++++++++------ crates/bevy_ui_render/src/render_pass.rs | 2 +- crates/bevy_ui_render/src/text.rs | 159 ++- .../src/ui_material_pipeline.rs | 218 ++-- .../src/ui_texture_slice_pipeline.rs | 217 ++-- 13 files changed, 1725 insertions(+), 871 deletions(-) diff --git a/crates/bevy_render/src/render_phase/mod.rs b/crates/bevy_render/src/render_phase/mod.rs index 41b39c8399774..6fd35af1bae50 100644 --- a/crates/bevy_render/src/render_phase/mod.rs +++ b/crates/bevy_render/src/render_phase/mod.rs @@ -1929,7 +1929,7 @@ pub trait PhaseItem: Sized + Send + Sync + 'static { /// The corresponding entity that will be drawn. /// /// This is used to fetch the render data of the entity, required by the draw function, - /// from the render world . + /// from the render world. fn entity(&self) -> Entity; /// The main world entity represented by this `PhaseItem`. diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index 26103cb967ce5..1389752537851 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -310,15 +310,23 @@ pub fn ui_layout_system( } let content_size = Vec2::new(layout.content_size.width, layout.content_size.height); - node.bypass_change_detection().content_size = content_size; + if node.content_size != content_size { + node.content_size = content_size; + } let taffy_rect_to_border_rect = |rect: taffy::Rect| BorderRect { min_inset: Vec2::new(rect.left, rect.top), max_inset: Vec2::new(rect.right, rect.bottom), }; - node.bypass_change_detection().border = taffy_rect_to_border_rect(layout.border); - node.bypass_change_detection().padding = taffy_rect_to_border_rect(layout.padding); + let new_border = taffy_rect_to_border_rect(layout.border); + if node.border != new_border { + node.border = new_border; + } + let new_padding = taffy_rect_to_border_rect(layout.padding); + if node.padding != new_padding { + node.padding = new_padding; + } // Compute the node's new global transform let mut local_transform = transform.compute_affine( @@ -334,16 +342,19 @@ pub fn ui_layout_system( } // We don't trigger change detection for changes to border radius - node.bypass_change_detection().border_radius = style.border_radius.resolve( + // unless the border radius actually changed + let new_border_radius = style.border_radius.resolve( inverse_target_scale_factor.recip(), node.size, target_size, ); + if node.border_radius != new_border_radius { + node.border_radius = new_border_radius; + } if let Some(outline) = maybe_outline { - // don't trigger change detection when only outlines are changed - let node = node.bypass_change_detection(); - node.outline_width = if style.display != Display::None { + // don't trigger change detection unless the outline actually changed + let new_outline_width = if style.display != Display::None { outline .width .resolve( @@ -357,7 +368,11 @@ pub fn ui_layout_system( 0. }; - node.outline_offset = outline + if node.outline_width != new_outline_width { + node.outline_width = new_outline_width; + } + + let new_outline_offset = outline .offset .resolve( inverse_target_scale_factor.recip(), @@ -368,10 +383,16 @@ pub fn ui_layout_system( // Clamp outline offsets to at least the length of the node's shorter side // Negative offset outlines can be useful to create thing like in-set focus indicators .max(-0.5 * node.size.min_element()); + if node.outline_offset != new_outline_offset { + node.outline_offset = new_outline_offset; + } } - node.bypass_change_detection().scrollbar_size = + let new_scrollbar_size = Vec2::new(layout.scrollbar_size.width, layout.scrollbar_size.height); + if node.scrollbar_size != new_scrollbar_size { + node.scrollbar_size = new_scrollbar_size; + } let scroll_position: Vec2 = maybe_scroll_position .map(|scroll_pos| { @@ -396,7 +417,9 @@ pub fn ui_layout_system( let physical_scroll_position = clamped_scroll_position.floor(); - node.bypass_change_detection().scroll_position = physical_scroll_position; + if node.scroll_position != physical_scroll_position { + node.scroll_position = physical_scroll_position; + } for child_uinode in ui_children.iter_ui_children(entity) { update_uinode_geometry_recursive( diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 966616a705506..1b685beaabfec 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -201,7 +201,12 @@ impl Plugin for UiPlugin { // They run independently since `widget::image_node_system` will only ever observe // its own ImageNode, and `widget::text_system` & `bevy_text::update_text2d_layout` // will never modify a pre-existing `Image` asset. - widget::update_image_content_size_system + ( + widget::update_image_content_size_system, + widget::update_texture_atlas_layout_components, + widget::mark_images_as_changed_if_their_assets_changed, + ) + .chain() .in_set(UiSystems::Content) .in_set(AmbiguousWithText) .in_set(AmbiguousWithUpdateText2dLayout), diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index fc8347dd68e11..4647dc0ee69a0 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -118,11 +118,17 @@ pub fn propagate_ui_target_cameras( ui_scale: Res, camera_query: Query<&Camera>, target_camera_query: Query<&UiTargetCamera>, - ui_root_nodes: UiRootNodes, + ui_root_nodes: Query<( + Entity, + Option<&Propagate>, + Option<&Propagate>, + )>, ) { let default_camera_entity = default_ui_camera.get(); - for root_entity in ui_root_nodes.iter() { + for (root_entity, maybe_computed_ui_target_camera, maybe_computed_ui_render_target_info) in + ui_root_nodes.iter() + { let camera = target_camera_query .get(root_entity) .ok() @@ -130,9 +136,15 @@ pub fn propagate_ui_target_cameras( .or(default_camera_entity) .unwrap_or(Entity::PLACEHOLDER); - commands - .entity(root_entity) - .try_insert(Propagate(ComputedUiTargetCamera { camera })); + // Only update `ComputedUiTargetCamera` if it actually changed. + match maybe_computed_ui_target_camera { + Some(computed_ui_target_camera) if computed_ui_target_camera.0.camera == camera => {} + _ => { + commands + .entity(root_entity) + .try_insert(Propagate(ComputedUiTargetCamera { camera })); + } + } let (scale_factor, physical_size) = camera_query .get(camera) @@ -145,12 +157,21 @@ pub fn propagate_ui_target_cameras( }) .unwrap_or((1., UVec2::ZERO)); - commands - .entity(root_entity) - .try_insert(Propagate(ComputedUiRenderTargetInfo { - scale_factor, - physical_size, - })); + let new_computed_ui_render_target_info = ComputedUiRenderTargetInfo { + scale_factor, + physical_size, + }; + + // Only update `ComputedUiRenderTargetInfo` if it actually changed. + match maybe_computed_ui_render_target_info { + Some(computed_ui_render_target_info) + if computed_ui_render_target_info.0 == new_computed_ui_render_target_info => {} + _ => { + commands + .entity(root_entity) + .try_insert(Propagate(new_computed_ui_render_target_info)); + } + } } } diff --git a/crates/bevy_ui/src/widget/image.rs b/crates/bevy_ui/src/widget/image.rs index 8b5a389510191..8ecff4e03406f 100644 --- a/crates/bevy_ui/src/widget/image.rs +++ b/crates/bevy_ui/src/widget/image.rs @@ -2,8 +2,9 @@ use crate::{ ComputedUiRenderTargetInfo, ContentSize, Measure, MeasureArgs, Node, NodeMeasure, ResolvedAxis, VisualBox, }; -use bevy_asset::{AsAssetId, AssetId, Assets, Handle}; +use bevy_asset::{asset_changed::AssetChanged, AsAssetId, AssetId, Assets, Handle}; use bevy_color::Color; +use bevy_derive::{Deref, DerefMut}; use bevy_ecs::prelude::*; use bevy_image::{prelude::*, TRANSPARENT_IMAGE_HANDLE}; use bevy_math::{Rect, UVec2, Vec2}; @@ -256,6 +257,34 @@ impl ImageNodeSize { } } +// This component is more or less a workaround for the fact that `AsAssetId` +// only allows each component to expose one asset. `ImageNode` exposes two types +// of assets: an `Image` and a `TextureAtlasLayout`. We have to mark the image +// node as changed if either one of those assets changes. The only way to detect +// asset changes is to use the `AssetChanged` query filter. Unfortunately, the +// `AssetChanged` query filter relies on `AsAssetId`, which we can only +// implement once per component. Thus we need this second component, which +// essentially serves to provide a second implementation of `AsAssetId` on +// `ImageNode`. + +/// The texture atlas layout, if the image has one. +/// +/// The [`update_texture_atlas_layout_component`] system automatically keeps +/// this component up to date based on [`ImageNode::texture_atlas`]. Don't +/// update this component yourself; [`ImageNode::texture_atlas`] is the source +/// of truth. +#[derive(Component, Debug, Clone, Reflect, Deref, DerefMut)] +#[reflect(Component, Debug, Clone)] +pub struct ImageNodeTextureAtlasLayout(pub Handle); + +impl AsAssetId for ImageNodeTextureAtlasLayout { + type Asset = TextureAtlasLayout; + + fn as_asset_id(&self) -> AssetId { + self.id() + } +} + #[derive(Clone)] /// Used to calculate the size of UI image nodes pub struct ImageMeasure { @@ -415,3 +444,42 @@ pub fn update_image_content_size_system( } } } + +/// A system that marks [`ImageNode`]s as changed if either their [`Image`] or +/// [`TextureAtlasLayout`] changed. +pub fn mark_images_as_changed_if_their_assets_changed( + mut images_query: Query<&mut ImageNode, AssetChanged>, + mut texture_atlas_layouts_query: Query< + &mut ImageNodeTextureAtlasLayout, + AssetChanged, + >, +) { + for mut image in &mut images_query { + image.set_changed(); + } + for mut texture_atlas_layout in &mut texture_atlas_layouts_query { + texture_atlas_layout.set_changed(); + } +} + +/// A system that copies the [`TextureAtlasLayout`] stored within an +/// [`ImageNode`] to the [`TextureAtlasLayout`] component. +pub fn update_texture_atlas_layout_components( + mut commands: Commands, + images_query: Query<(Entity, &ImageNode), Changed>, +) { + for (entity, image_node) in &images_query { + match image_node.texture_atlas { + Some(ref texture_atlas) => { + commands + .entity(entity) + .insert(ImageNodeTextureAtlasLayout(texture_atlas.layout.clone())); + } + None => { + commands + .entity(entity) + .remove::(); + } + } + } +} diff --git a/crates/bevy_ui_render/src/box_shadow.rs b/crates/bevy_ui_render/src/box_shadow.rs index 030ae69669a3b..8befb7d235bb9 100644 --- a/crates/bevy_ui_render/src/box_shadow.rs +++ b/crates/bevy_ui_render/src/box_shadow.rs @@ -6,6 +6,7 @@ use bevy_app::prelude::*; use bevy_asset::*; use bevy_camera::visibility::InheritedVisibility; use bevy_color::{Alpha, ColorToComponents, LinearRgba}; +use bevy_ecs::entity::EntityIndexMap; use bevy_ecs::prelude::*; use bevy_ecs::{ prelude::Component, @@ -16,7 +17,7 @@ use bevy_ecs::{ }; use bevy_math::{vec2, Affine2, FloatOrd, Rect, Vec2}; use bevy_mesh::VertexBufferLayout; -use bevy_render::sync_world::{MainEntity, TemporaryRenderEntity}; +use bevy_render::sync_world::{MainEntity, MainEntityHashMap, MainEntityHashSet}; use bevy_render::{ render_phase::*, render_resource::{binding_types::uniform_buffer, *}, @@ -190,39 +191,77 @@ pub struct ExtractedBoxShadow { pub radius: ResolvedBorderRadius, pub blur_radius: f32, pub size: Vec2, - pub main_entity: MainEntity, - pub render_entity: Entity, } /// List of extracted shadows to be sorted and queued for rendering #[derive(Resource, Default)] pub struct ExtractedBoxShadows { - pub box_shadows: Vec, + /// The list of box shadows. + /// + /// This is a two-level data structure so that we can quickly remove all box + /// shadows associated with a main-world entity when it changes. + pub box_shadows: MainEntityHashMap>, } pub fn extract_shadows( mut commands: Commands, mut extracted_box_shadows: ResMut, box_shadow_query: Extract< - Query<( - Entity, - &ComputedNode, - &ComputedStackIndex, - &UiGlobalTransform, - &InheritedVisibility, - &BoxShadow, - Option<&CalculatedClip>, - &ComputedUiTargetCamera, - &ComputedUiRenderTargetInfo, - )>, + Query< + ( + Entity, + &ComputedNode, + &ComputedStackIndex, + &UiGlobalTransform, + &InheritedVisibility, + &BoxShadow, + Option<&CalculatedClip>, + &ComputedUiTargetCamera, + &ComputedUiRenderTargetInfo, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + >, >, camera_map: Extract, + ( + mut removed_computed_node_query, + mut removed_computed_stack_index_query, + mut removed_ui_global_transform_query, + mut removed_inherited_visibility_query, + mut removed_box_shadow_query, + mut removed_calculated_clip_query, + mut removed_computed_ui_target_camera_query, + mut removed_computed_ui_render_target_info_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), + mut nodes_processed_this_frame: Local, ) { + nodes_processed_this_frame.clear(); + let mut mapping = camera_map.get_mapper(); for (entity, uinode, stack_index, transform, visibility, box_shadow, clip, camera, target) in &box_shadow_query { + let main_entity = MainEntity::from(entity); + // Skip if no visible shadows if !visibility.get() || box_shadow.is_empty() || uinode.is_empty() { continue; @@ -266,6 +305,8 @@ pub fn extract_shadows( continue; } + nodes_processed_this_frame.insert(main_entity); + let radius = ResolvedBorderRadius { top_left: uinode.border_radius.top_left * spread_ratio, top_right: uinode.border_radius.top_right * spread_ratio, @@ -273,19 +314,50 @@ pub fn extract_shadows( bottom_right: uinode.border_radius.bottom_right * spread_ratio, }; - extracted_box_shadows.box_shadows.push(ExtractedBoxShadow { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: Affine2::from(transform) * Affine2::from_translation(offset), - color: drop_shadow.color.into(), - bounds: shadow_size + 6. * blur_radius, - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - radius, - blur_radius, - size: shadow_size, - main_entity: entity.into(), - }); + extracted_box_shadows + .box_shadows + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedBoxShadow { + stack_index: stack_index.0, + transform: Affine2::from(transform) * Affine2::from_translation(offset), + color: drop_shadow.color.into(), + bounds: shadow_size + 6. * blur_radius, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + radius, + blur_radius, + size: shadow_size, + }, + ); + } + } + + // Only remove the render-world data if we didn't handle the node above. + // It's possible that a relevant component was removed and added in the same + // frame. + for main_entity in removed_computed_node_query + .read() + .chain(removed_computed_stack_index_query.read()) + .chain(removed_ui_global_transform_query.read()) + .chain(removed_inherited_visibility_query.read()) + .chain(removed_box_shadow_query.read()) + .chain(removed_calculated_clip_query.read()) + .chain(removed_computed_ui_target_camera_query.read()) + .chain(removed_computed_ui_render_target_info_query.read()) + { + let main_entity = MainEntity::from(main_entity); + if nodes_processed_this_frame.contains(&main_entity) { + continue; + } + let Some(mut extracted_nodes) = extracted_box_shadows.box_shadows.remove(&main_entity) + else { + continue; + }; + for (render_entity, _) in extracted_nodes.drain(..) { + commands.entity(render_entity).despawn(); } } } @@ -305,43 +377,46 @@ pub fn queue_shadows( draw_functions: Res>, ) { let draw_function = draw_functions.read().id::(); - for (index, extracted_shadow) in extracted_box_shadows.box_shadows.iter().enumerate() { - let entity = extracted_shadow.render_entity; - let Ok((default_camera_view, shadow_samples)) = - render_views.get_mut(extracted_shadow.extracted_camera_entity) - else { - continue; - }; + for (main_entity, extracted_sub_shadows) in extracted_box_shadows.box_shadows.iter() { + for (entity, extracted_shadow) in extracted_sub_shadows.iter() { + let Ok((default_camera_view, shadow_samples)) = + render_views.get_mut(extracted_shadow.extracted_camera_entity) + else { + continue; + }; - let Ok(view) = camera_views.get(default_camera_view.0) else { - continue; - }; + let Ok(view) = camera_views.get(default_camera_view.0) else { + continue; + }; - let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) - else { - continue; - }; + let Some(transparent_phase) = + transparent_render_phases.get_mut(&view.retained_view_entity) + else { + continue; + }; - let pipeline = pipelines.specialize( - &pipeline_cache, - &box_shadow_pipeline, - BoxShadowPipelineKey { - target_format: view.target_format, - samples: shadow_samples.copied().unwrap_or_default().0, - }, - ); + let pipeline = pipelines.specialize( + &pipeline_cache, + &box_shadow_pipeline, + BoxShadowPipelineKey { + target_format: view.target_format, + samples: shadow_samples.copied().unwrap_or_default().0, + }, + ); - transparent_phase.add_transient(TransparentUi { - draw_function, - pipeline, - entity: (entity, extracted_shadow.main_entity), - sort_key: FloatOrd(extracted_shadow.stack_index as f32 + stack_z_offsets::BOX_SHADOW), - - batch_range: 0..0, - extra_index: PhaseItemExtraIndex::None, - index, - indexed: true, - }); + transparent_phase.add_transient(TransparentUi { + draw_function, + pipeline, + entity: (*entity, *main_entity), + sort_key: FloatOrd( + extracted_shadow.stack_index as f32 + stack_z_offsets::BOX_SHADOW, + ), + + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + indexed: true, + }); + } } } @@ -351,7 +426,7 @@ pub fn prepare_shadows( render_queue: Res, pipeline_cache: Res, mut ui_meta: ResMut, - mut extracted_shadows: ResMut, + extracted_shadows: Res, view_uniforms: Res, box_shadow_pipeline: Res, mut phases: ResMut>, @@ -377,11 +452,15 @@ pub fn prepare_shadows( let item = &mut ui_phase.items[item_index]; let Some(box_shadow) = extracted_shadows .box_shadows - .get(item.index) - .filter(|n| item.entity() == n.render_entity) + .get(&item.main_entity()) + .and_then(|sub_shadows| sub_shadows.get(&item.entity())) else { continue; }; + + // Initialize the batch range to be zero-length initially. + // We'll extend it as we accumulate items into this batch. + item.batch_range = (item_index as u32)..(item_index as u32); let rect_size = box_shadow.bounds; // Specify the corners of the node @@ -495,7 +574,6 @@ pub fn prepare_shadows( *previous_len = batches.len(); commands.try_insert_batch(batches); } - extracted_shadows.box_shadows.clear(); } pub type DrawBoxShadows = (SetItemPipeline, SetBoxShadowViewBindGroup<0>, DrawBoxShadow); diff --git a/crates/bevy_ui_render/src/debug_overlay.rs b/crates/bevy_ui_render/src/debug_overlay.rs index 73138c4f7ed3d..9c356d10ed2e7 100644 --- a/crates/bevy_ui_render/src/debug_overlay.rs +++ b/crates/bevy_ui_render/src/debug_overlay.rs @@ -8,6 +8,7 @@ use bevy_asset::AssetId; use bevy_camera::visibility::InheritedVisibility; use bevy_color::Hsla; use bevy_color::LinearRgba; +use bevy_ecs::entity::ContainsEntity as _; use bevy_ecs::entity::Entity; use bevy_ecs::prelude::Component; use bevy_ecs::prelude::ReflectComponent; @@ -21,7 +22,6 @@ use bevy_math::Affine2; use bevy_math::Rect; use bevy_math::Vec2; use bevy_reflect::Reflect; -use bevy_render::sync_world::TemporaryRenderEntity; use bevy_render::Extract; use bevy_sprite::BorderRect; use bevy_ui::ui_transform::UiGlobalTransform; @@ -172,7 +172,7 @@ impl From for GlobalUiDebugOptions { pub fn extract_debug_overlay( mut commands: Commands, debug_options: Extract>, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -188,10 +188,14 @@ pub fn extract_debug_overlay( ui_stack: Extract>, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); for (entity, uinode, stack_index, transform, visibility, maybe_clip, computed_target, debug) in - &uinode_query + extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { let debug_options = debug.copied().unwrap_or((*debug_options.as_ref()).into()); if !debug_options.enabled { @@ -217,31 +221,36 @@ pub fn extract_debug_overlay( return; } - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - // Keep all overlays above UI, and nudge each type slightly in Z so ordering is stable. - z_order, - clip: maybe_clip - .filter(|_| !debug_options.show_clipped) - .map(|clip| clip.clip), - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(rect.center()), - item: ExtractedUiItem::Node { - color, - rect: Rect { - min: Vec2::ZERO, - max: rect.size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + // Keep all overlays above UI, and nudge each type slightly in Z so ordering is stable. + z_order, + clip: maybe_clip + .filter(|_| !debug_options.show_clipped) + .map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(rect.center()), + item: ExtractedUiItem::Node { + color, + rect: Rect { + min: Vec2::ZERO, + max: rect.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border, + border_radius: radius, + node_type: NodeType::Border(shader_flags::BORDER_ALL), + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border, - border_radius: radius, - node_type: NodeType::Border(shader_flags::BORDER_ALL), - }, - main_entity: entity.into(), - }); + ); }; let border_box = Rect::from_center_size(Vec2::ZERO, uinode.size); diff --git a/crates/bevy_ui_render/src/gradient.rs b/crates/bevy_ui_render/src/gradient.rs index 13fc7155e4b4d..4f0415852e58d 100644 --- a/crates/bevy_ui_render/src/gradient.rs +++ b/crates/bevy_ui_render/src/gradient.rs @@ -25,11 +25,10 @@ use bevy_render::{ render_phase::*, render_resource::{binding_types::uniform_buffer, *}, renderer::{RenderDevice, RenderQueue}, - sync_world::TemporaryRenderEntity, view::*, Extract, ExtractSchedule, Render, RenderSystems, }; -use bevy_render::{sync_world::MainEntity, GpuResourceAppExt, RenderStartup}; +use bevy_render::{GpuResourceAppExt, RenderStartup}; use bevy_shader::Shader; use bevy_sprite::BorderRect; use bevy_ui::{ @@ -50,7 +49,6 @@ impl Plugin for GradientPlugin { render_app .add_render_command::() .init_resource::() - .init_resource::() .init_gpu_resource::() .init_gpu_resource::>() .add_systems(RenderStartup, init_gradient_pipeline) @@ -232,11 +230,8 @@ pub struct ExtractedGradient { pub rect: Rect, pub clip: Option, pub extracted_camera_entity: Entity, - /// range into `ExtractedColorStops` - pub stops_range: Range, + pub stops: Vec<(LinearRgba, f32, f32)>, pub node_type: NodeType, - pub main_entity: MainEntity, - pub render_entity: Entity, /// Border radius of the UI node. /// Ordering: top left, top right, bottom right, bottom left. pub border_radius: ResolvedBorderRadius, @@ -247,14 +242,16 @@ pub struct ExtractedGradient { pub color_space: InterpolationColorSpace, } +/// A render-world resource that stores all gradients in the scene. #[derive(Resource, Default)] pub struct ExtractedGradients { - pub items: Vec, + /// The list of gradients. + /// + /// This is a two-level data structure so that we can quickly remove all + /// gradients associated with a main-world entity when it changes. + pub items: MainEntityHashMap>, } -#[derive(Resource, Default)] -pub struct ExtractedColorStops(pub Vec<(LinearRgba, f32, f32)>); - // Interpolate implicit stops (where position is `f32::NAN`) // If the first and last stops are implicit set them to the `min` and `max` values // so that we always have explicit start and end points to interpolate between. @@ -295,8 +292,9 @@ fn compute_color_stops( length: f32, target_size: Vec2, scratch: &mut Vec<(LinearRgba, f32, f32)>, - extracted_color_stops: &mut Vec<(LinearRgba, f32, f32)>, -) { +) -> Vec<(LinearRgba, f32, f32)> { + let mut extracted_color_stops = vec![]; + // resolve the physical distances of explicit stops and sort them scratch.extend(stops.iter().filter_map(|stop| { stop.point @@ -321,8 +319,6 @@ fn compute_color_stops( let mut sorted_stops_drain = scratch.drain(..); - let range_start = extracted_color_stops.len(); - // Fill the extracted color stops buffer extracted_color_stops.extend(stops.iter().map(|stop| { if stop.point == Val::Auto { @@ -332,29 +328,66 @@ fn compute_color_stops( } })); - interpolate_color_stops(&mut extracted_color_stops[range_start..], min, max); + interpolate_color_stops(&mut extracted_color_stops, min, max); + + extracted_color_stops } pub fn extract_gradients( mut commands: Commands, mut extracted_gradients: ResMut, - mut extracted_color_stops: ResMut, mut extracted_uinodes: ResMut, gradients_query: Extract< - Query<( - Entity, - &ComputedNode, - &ComputedStackIndex, - &ComputedUiTargetCamera, - &ComputedUiRenderTargetInfo, - &UiGlobalTransform, - &InheritedVisibility, - Option<&CalculatedClip>, - AnyOf<(&BackgroundGradient, &BorderGradient)>, - )>, + Query< + ( + Entity, + &ComputedNode, + &ComputedStackIndex, + &ComputedUiTargetCamera, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + AnyOf<(&BackgroundGradient, &BorderGradient)>, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + >, >, + ( + mut removed_computed_node_query, + mut removed_computed_stack_index_query, + mut removed_computed_ui_target_camera_query, + mut removed_computed_ui_render_target_info_query, + mut removed_ui_global_transform_query, + mut removed_inherited_visibility_query, + mut removed_calculated_clip_query, + mut removed_background_gradient_query, + mut removed_border_gradient_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), camera_map: Extract, + mut nodes_processed_this_frame: Local, ) { + nodes_processed_this_frame.clear(); let mut camera_mapper = camera_map.get_mapper(); let mut sorted_stops = vec![]; @@ -370,6 +403,8 @@ pub fn extract_gradients( (gradient, gradient_border), ) in &gradients_query { + let main_entity = MainEntity::from(entity); + // Skip invisible images if !inherited_visibility.get() { continue; @@ -390,34 +425,44 @@ pub fn extract_gradients( if gradient.is_empty() { continue; } + + nodes_processed_this_frame.insert(main_entity); + if let Some(color) = gradient.get_single() { // With a single color stop there's no gradient, fill the node with the color - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 - + match node_type { - NodeType::Rect | NodeType::Inverted => stack_z_offsets::GRADIENT, - NodeType::Border(_) => stack_z_offsets::BORDER_GRADIENT, + extracted_uinodes + .uinodes + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + + match node_type { + NodeType::Rect | NodeType::Inverted => { + stack_z_offsets::GRADIENT + } + NodeType::Border(_) => stack_z_offsets::BORDER_GRADIENT, + }, + image: AssetId::default(), + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + transform: transform.into(), + item: ExtractedUiItem::Node { + color: color.into(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border_radius: uinode.border_radius, + border: uinode.border, + node_type, + }, }, - image: AssetId::default(), - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - transform: transform.into(), - item: ExtractedUiItem::Node { - color: color.into(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, - }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border_radius: uinode.border_radius, - border: uinode.border, - node_type, - }, - main_entity: entity.into(), - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - }); + ); continue; } match gradient { @@ -428,35 +473,37 @@ pub fn extract_gradients( }) => { let length = compute_gradient_line_length(*angle, uinode.size); - let range_start = extracted_color_stops.0.len(); - - compute_color_stops( + let extracted_stops = compute_color_stops( stops, target.scale_factor(), length, target.physical_size().as_vec2(), &mut sorted_stops, - &mut extracted_color_stops.0, ); - extracted_gradients.items.push(ExtractedGradient { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: transform.into(), - stops_range: range_start..extracted_color_stops.0.len(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, - }, - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - main_entity: entity.into(), - node_type, - border_radius: uinode.border_radius, - border: uinode.border, - resolved_gradient: ResolvedGradient::Linear { angle: *angle }, - color_space: *color_space, - }); + extracted_gradients + .items + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedGradient { + stack_index: stack_index.0, + transform: transform.into(), + stops: extracted_stops, + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Linear { angle: *angle }, + color_space: *color_space, + }, + ); } Gradient::Radial(RadialGradient { color_space, @@ -479,34 +526,37 @@ pub fn extract_gradients( let length = size.x; - let range_start = extracted_color_stops.0.len(); - compute_color_stops( + let computed_stops = compute_color_stops( stops, target.scale_factor(), length, target.physical_size().as_vec2(), &mut sorted_stops, - &mut extracted_color_stops.0, ); - extracted_gradients.items.push(ExtractedGradient { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: transform.into(), - stops_range: range_start..extracted_color_stops.0.len(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, - }, - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - main_entity: entity.into(), - node_type, - border_radius: uinode.border_radius, - border: uinode.border, - resolved_gradient: ResolvedGradient::Radial { center: c, size }, - color_space: *color_space, - }); + extracted_gradients + .items + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedGradient { + stack_index: stack_index.0, + transform: transform.into(), + stops: computed_stops, + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Radial { center: c, size }, + color_space: *color_space, + }, + ); } Gradient::Conic(ConicGradient { color_space, @@ -519,7 +569,6 @@ pub fn extract_gradients( uinode.size, target.physical_size().as_vec2(), ); - let range_start = extracted_color_stops.0.len(); // sort the explicit stops sorted_stops.extend(stops.iter().filter_map(|stop| { @@ -531,46 +580,76 @@ pub fn extract_gradients( let mut sorted_stops_drain = sorted_stops.drain(..); // fill the extracted stops buffer - extracted_color_stops.0.extend(stops.iter().map(|stop| { - if stop.angle.is_none() { - (stop.color.to_linear(), f32::NAN, stop.hint) - } else { - sorted_stops_drain.next().unwrap() - } - })); - - interpolate_color_stops( - &mut extracted_color_stops.0[range_start..], - 0., - TAU, - ); - - extracted_gradients.items.push(ExtractedGradient { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: transform.into(), - stops_range: range_start..extracted_color_stops.0.len(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, - }, - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - main_entity: entity.into(), - node_type, - border_radius: uinode.border_radius, - border: uinode.border, - resolved_gradient: ResolvedGradient::Conic { - start: *start, - center: g_start, - }, - color_space: *color_space, - }); + let mut extracted_color_stops: Vec<_> = stops + .iter() + .map(|stop| { + if stop.angle.is_none() { + (stop.color.to_linear(), f32::NAN, stop.hint) + } else { + sorted_stops_drain.next().unwrap() + } + }) + .collect(); + + interpolate_color_stops(&mut extracted_color_stops, 0., TAU); + + extracted_gradients + .items + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedGradient { + stack_index: stack_index.0, + transform: transform.into(), + stops: extracted_color_stops, + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Conic { + start: *start, + center: g_start, + }, + color_space: *color_space, + }, + ); } } } } } + + // Only remove the render-world data if we didn't handle the node above. + // It's possible that a relevant component was removed and added in the same + // frame. + for main_entity in removed_computed_node_query + .read() + .chain(removed_computed_stack_index_query.read()) + .chain(removed_computed_ui_target_camera_query.read()) + .chain(removed_computed_ui_render_target_info_query.read()) + .chain(removed_ui_global_transform_query.read()) + .chain(removed_inherited_visibility_query.read()) + .chain(removed_calculated_clip_query.read()) + .chain(removed_background_gradient_query.read()) + .chain(removed_border_gradient_query.read()) + { + let main_entity = MainEntity::from(main_entity); + if nodes_processed_this_frame.contains(&main_entity) { + continue; + } + let Some(mut extracted_nodes) = extracted_gradients.items.remove(&main_entity) else { + continue; + }; + for (render_entity, _) in extracted_nodes.drain(..) { + commands.entity(render_entity).despawn(); + } + } } #[expect( @@ -588,48 +667,50 @@ pub fn queue_gradient( draw_functions: Res>, ) { let draw_function = draw_functions.read().id::(); - for (index, gradient) in extracted_gradients.items.iter().enumerate() { - let Ok((default_camera_view, ui_anti_alias)) = - render_views.get_mut(gradient.extracted_camera_entity) - else { - continue; - }; - - let Ok(view) = camera_views.get(default_camera_view.0) else { - continue; - }; - - let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) - else { - continue; - }; - - let pipeline = pipelines.specialize( - &pipeline_cache, - &gradients_pipeline, - UiGradientPipelineKey { - anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), - color_space: gradient.color_space, - target_format: view.target_format, - }, - ); - - transparent_phase.add_transient(TransparentUi { - draw_function, - pipeline, - entity: (gradient.render_entity, gradient.main_entity), - sort_key: FloatOrd( - gradient.stack_index as f32 - + match gradient.node_type { - NodeType::Rect | NodeType::Inverted => stack_z_offsets::GRADIENT, - NodeType::Border(_) => stack_z_offsets::BORDER_GRADIENT, - }, - ), - batch_range: 0..0, - extra_index: PhaseItemExtraIndex::None, - index, - indexed: true, - }); + for (main_entity, sub_gradients) in extracted_gradients.items.iter() { + for (render_entity, gradient) in sub_gradients.iter() { + let Ok((default_camera_view, ui_anti_alias)) = + render_views.get_mut(gradient.extracted_camera_entity) + else { + continue; + }; + + let Ok(view) = camera_views.get(default_camera_view.0) else { + continue; + }; + + let Some(transparent_phase) = + transparent_render_phases.get_mut(&view.retained_view_entity) + else { + continue; + }; + + let pipeline = pipelines.specialize( + &pipeline_cache, + &gradients_pipeline, + UiGradientPipelineKey { + anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), + color_space: gradient.color_space, + target_format: view.target_format, + }, + ); + + transparent_phase.add_transient(TransparentUi { + draw_function, + pipeline, + entity: (*render_entity, *main_entity), + sort_key: FloatOrd( + gradient.stack_index as f32 + + match gradient.node_type { + NodeType::Rect | NodeType::Inverted => stack_z_offsets::GRADIENT, + NodeType::Border(_) => stack_z_offsets::BORDER_GRADIENT, + }, + ), + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + indexed: true, + }); + } } } @@ -692,8 +773,7 @@ pub fn prepare_gradient( render_queue: Res, pipeline_cache: Res, mut ui_meta: ResMut, - mut extracted_gradients: ResMut, - mut extracted_color_stops: ResMut, + extracted_gradients: Res, view_uniforms: Res, gradients_pipeline: Res, mut phases: ResMut>, @@ -719,8 +799,8 @@ pub fn prepare_gradient( let item = &mut ui_phase.items[item_index]; if let Some(gradient) = extracted_gradients .items - .get(item.index) - .filter(|n| item.entity() == n.render_entity) + .get(&item.main_entity()) + .and_then(|subgradients| subgradients.get(&item.entity())) { *item.batch_range_mut() = item_index as u32..item_index as u32 + 1; let uinode_rect = gradient.rect; @@ -823,14 +903,14 @@ pub fn prepare_gradient( flags |= g_flags; - let range = gradient.stops_range.start..gradient.stops_range.end - 1; + let range = 0..gradient.stops.len() - 1; let mut segment_count = 0; for stop_index in range { - let mut start_stop = extracted_color_stops.0[stop_index]; - let end_stop = extracted_color_stops.0[stop_index + 1]; + let mut start_stop = gradient.stops[stop_index]; + let end_stop = gradient.stops[stop_index + 1]; if start_stop.1 == end_stop.1 { - if stop_index == gradient.stops_range.end - 2 { + if stop_index == gradient.stops.len() - 2 { if 0 < segment_count { start_stop.0 = LinearRgba::NONE; } @@ -842,12 +922,10 @@ pub fn prepare_gradient( convert_color_to_space(start_stop.0, gradient.color_space); let end_color = convert_color_to_space(end_stop.0, gradient.color_space); let mut stop_flags = flags; - if 0. < start_stop.1 - && (stop_index == gradient.stops_range.start || segment_count == 0) - { + if 0. < start_stop.1 && (stop_index == 0 || segment_count == 0) { stop_flags |= shader_flags::FILL_START; } - if stop_index == gradient.stops_range.end - 2 { + if stop_index == gradient.stops.len() - 2 { stop_flags |= shader_flags::FILL_END; } @@ -907,8 +985,6 @@ pub fn prepare_gradient( *previous_len = batches.len(); commands.try_insert_batch(batches); } - extracted_gradients.items.clear(); - extracted_color_stops.0.clear(); } pub type DrawGradientFns = (SetItemPipeline, SetGradientViewBindGroup<0>, DrawGradient); diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index a1efacc959275..e86879588dfa6 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -21,18 +21,20 @@ mod debug_overlay; use bevy_camera::visibility::InheritedVisibility; use bevy_camera::{Camera, Camera2d, Camera3d, RenderTarget}; +use bevy_ecs::entity::EntityIndexMap; use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; use bevy_render::camera::{extract_cameras, CameraMainPassTextureFormats}; +use bevy_render::sync_world::{MainEntityHashMap, MainEntityHashSet}; use bevy_shader::load_shader_library; use bevy_sprite_render::SpriteAssetEvents; use bevy_ui::widget::{ - ImageNode, ImageNodeSize, NodeImageMode, TextScroll, TextShadow, ViewportNode, + ImageNode, ImageNodeSize, NodeImageMode, Text, TextScroll, TextShadow, ViewportNode, }; use bevy_ui::{ - BackgroundColor, BorderColor, CalculatedClip, ComputedNode, ComputedStackIndex, - ComputedUiTargetCamera, Display, Node, OuterColor, Outline, ResolvedBorderRadius, - UiGlobalTransform, VisualBox, + BackgroundColor, BackgroundGradient, BorderColor, BorderGradient, BoxShadow, CalculatedClip, + ComputedNode, ComputedStackIndex, ComputedUiTargetCamera, Display, Node, OuterColor, Outline, + ResolvedBorderRadius, UiGlobalTransform, VisualBox, }; use bevy_app::prelude::*; @@ -53,7 +55,7 @@ use bevy_render::{ }, render_resource::*, renderer::{RenderDevice, RenderQueue}, - sync_world::{MainEntity, RenderEntity, TemporaryRenderEntity}, + sync_world::{MainEntity, RenderEntity}, texture::GpuImage, view::{ExtractedView, RetainedViewEntity, ViewUniforms}, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, @@ -66,13 +68,15 @@ use gradient::GradientPlugin; use bevy_platform::collections::{HashMap, HashSet}; use bevy_text::{ - ComputedTextBlock, PositionedGlyph, Strikethrough, StrikethroughColor, TextBackgroundColor, - TextColor, TextCursorStyle, TextLayoutInfo, Underline, UnderlineColor, + ComputedTextBlock, EditableText, PositionedGlyph, Strikethrough, StrikethroughColor, + TextBackgroundColor, TextColor, TextCursorStyle, TextLayoutInfo, TextSpan, Underline, + UnderlineColor, }; use bevy_transform::components::GlobalTransform; use box_shadow::BoxShadowPlugin; use bytemuck::{Pod, Zeroable}; use core::ops::Range; +use std::mem; pub use pipeline::*; pub use render_pass::*; @@ -121,6 +125,7 @@ pub mod stack_z_offsets { #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum RenderUiSystems { + ExtractChanges, ExtractCameraViews, ExtractBoxShadows, ExtractBackgrounds, @@ -220,6 +225,7 @@ impl Plugin for UiRenderPlugin { .configure_sets( ExtractSchedule, ( + RenderUiSystems::ExtractChanges, RenderUiSystems::ExtractCameraViews, RenderUiSystems::ExtractBoxShadows, RenderUiSystems::ExtractBackgrounds, @@ -238,6 +244,7 @@ impl Plugin for UiRenderPlugin { .add_systems( ExtractSchedule, ( + extract_uinode_changes.in_set(RenderUiSystems::ExtractChanges), extract_ui_camera_view .after(extract_cameras) .in_set(RenderUiSystems::ExtractCameraViews), @@ -260,6 +267,7 @@ impl Plugin for UiRenderPlugin { queue_uinodes.in_set(RenderSystems::Queue), sort_phase_system::.in_set(RenderSystems::PhaseSort), prepare_uinodes.in_set(RenderSystems::PrepareBindGroups), + clear_batches.in_set(RenderSystems::Cleanup), ), ) .add_systems( @@ -331,8 +339,6 @@ pub struct ExtractedUiNode { /// Render world entity of the extracted camera corresponding to this node's target camera. pub extracted_camera_entity: Entity, pub item: ExtractedUiItem, - pub main_entity: MainEntity, - pub render_entity: Entity, pub transform: Affine2, } @@ -362,8 +368,8 @@ pub enum ExtractedUiItem { }, /// A contiguous sequence of text glyphs from the same section Glyphs { - /// Indices into [`ExtractedUiNodes::glyphs`] - range: Range, + /// The color, position, and UV rect of each glyph. + glyphs: Vec, }, } @@ -373,22 +379,233 @@ pub struct ExtractedGlyph { pub rect: Rect, } +/// The list of gradients. +/// +/// This is a two-level data structure so that we can quickly remove all +/// gradients associated with a main-world entity when it changes. #[derive(Resource, Default)] pub struct ExtractedUiNodes { - pub uinodes: Vec, - pub glyphs: Vec, + /// The list of UI nodes. + /// + /// This is a two-level data structure so that we can quickly remove all UI + /// nodes associated with a main-world entity when it changes. + pub uinodes: MainEntityHashMap>, + /// UI nodes that changed this frame. + pub changed: MainEntityHashSet, } -impl ExtractedUiNodes { - pub fn clear(&mut self) { - self.uinodes.clear(); - self.glyphs.clear(); +// Note: Whenever you add a new component that affects UI rendering, make sure +// to add a `Changed` query filter and a reference to the `RemovedComponents` +// resource to `extract_uinode_changes` below. +// +// Note: We don't have to match on `AssetChanged` for images or texture atlas +// layouts because the +// `bevy_ui::widget::mark_images_as_changed_as_their_assets_changed` image marks +// the `ImageNode` for us automatically as changed when those assets change. + +/// A render-world system that scans for any UI nodes that have changed and +/// removes the render world data associated with them. +pub fn extract_uinode_changes( + mut commands: Commands, + mut extracted_uinodes: ResMut, + uinode_query: Extract< + Query< + Entity, + ( + With, + With, + With, + With, + With, + Or<( + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + )>, + ), + >, + >, + text_span_query: Extract>>, + text_query: Extract>>, + ( + mut removed_computed_node_query, + mut removed_computed_stack_index_query, + mut removed_ui_global_transform_query, + mut removed_inherited_visibility_query, + mut removed_calculated_clip_query, + mut removed_computed_ui_target_camera_query, + mut removed_background_color_query, + mut removed_outer_color_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), + ( + mut removed_image_node_query, + mut removed_image_node_size_query, + mut removed_border_color_query, + mut removed_outline_query, + mut removed_viewport_node_query, + mut removed_computed_text_block_query, + mut removed_text_color_query, + mut removed_text_layout_info_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), + ( + mut removed_text_scroll_query, + mut removed_text_cursor_style_query, + mut removed_text_shadow_query, + mut removed_background_gradient_query, + mut removed_border_gradient_query, + mut removed_editable_text_query, + mut removed_ui_debug_options, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), + mut extra_nodes_to_invalidate: Local, +) { + extracted_uinodes.changed.clear(); + + // Go through all nodes that have changed and invalidate any render world + // data associated with them. + for main_entity in uinode_query + .iter() + .chain(removed_computed_node_query.read()) + .chain(removed_computed_stack_index_query.read()) + .chain(removed_ui_global_transform_query.read()) + .chain(removed_inherited_visibility_query.read()) + .chain(removed_calculated_clip_query.read()) + .chain(removed_computed_ui_target_camera_query.read()) + .chain(removed_background_color_query.read()) + .chain(removed_outer_color_query.read()) + .chain(removed_image_node_query.read()) + .chain(removed_image_node_size_query.read()) + .chain(removed_border_color_query.read()) + .chain(removed_outline_query.read()) + .chain(removed_viewport_node_query.read()) + .chain(removed_computed_text_block_query.read()) + .chain(removed_text_color_query.read()) + .chain(removed_text_layout_info_query.read()) + .chain(removed_text_scroll_query.read()) + .chain(removed_text_cursor_style_query.read()) + .chain(removed_text_shadow_query.read()) + .chain(removed_background_gradient_query.read()) + .chain(removed_border_gradient_query.read()) + .chain(removed_editable_text_query.read()) + .chain(removed_ui_debug_options.read()) + { + process_changed_entity( + main_entity.into(), + &mut commands, + &text_span_query, + &text_query, + &mut extracted_uinodes, + Some(&mut extra_nodes_to_invalidate), + ); + } + + for main_entity in extra_nodes_to_invalidate.drain() { + process_changed_entity( + main_entity, + &mut commands, + &text_span_query, + &text_query, + &mut extracted_uinodes, + None, + ); + } + + fn process_changed_entity( + mut main_entity: MainEntity, + commands: &mut Commands, + text_span_query: &Query<&ChildOf, With>, + text_query: &Query>, + extracted_uinodes: &mut ExtractedUiNodes, + maybe_extra_nodes_to_invalidate: Option<&mut MainEntityHashSet>, + ) { + // Mark the node as changed so that the other `extract_` systems will + // know to process it. + extracted_uinodes.changed.insert(main_entity); + + if let Some(mut render_entities) = extracted_uinodes.uinodes.remove(&main_entity) { + for (render_entity, _) in render_entities.drain(..) { + commands.entity(render_entity).despawn(); + } + } + + // If this node is a `TextSpan`, then we need to invalidate the ancestor + // `Text` node too. This is because `extract_text_decorations` only + // looks at the `text_background_colors_query` for the text spans if the + // `uinode_query` that it's iterating over matched the ancestor `Text` + // node. + if let Some(extra_nodes_to_invalidate) = maybe_extra_nodes_to_invalidate + && let Ok(parent) = text_span_query.get(main_entity.entity()) + { + main_entity = parent.parent().into(); + loop { + if text_query.contains(main_entity.entity()) { + extra_nodes_to_invalidate.insert(main_entity); + break; + } + match text_span_query.get(main_entity.entity()) { + Ok(parent) => main_entity = parent.parent().into(), + Err(_) => break, + } + } + } } } pub fn extract_uinode_background_colors( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -404,6 +621,7 @@ pub fn extract_uinode_background_colors( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); for ( @@ -416,7 +634,10 @@ pub fn extract_uinode_background_colors( camera, background_color, maybe_outer_color, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip invisible backgrounds if !inherited_visibility.get() @@ -432,62 +653,72 @@ pub fn extract_uinode_background_colors( }; if !background_color.is_fully_transparent() { - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, - clip: clip.map(|clip| clip.clip), - image: AssetId::default(), - extracted_camera_entity, - transform: transform.into(), - item: ExtractedUiItem::Node { - color: background_color.0.into(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: transform.into(), + item: ExtractedUiItem::Node { + color: background_color.0.into(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: uinode.border(), + border_radius: uinode.border_radius(), + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: uinode.border(), - border_radius: uinode.border_radius(), - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } if let Some(outer_color) = maybe_outer_color && !outer_color.0.is_fully_transparent() { - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, - clip: clip.map(|clip| clip.clip), - image: AssetId::default(), - extracted_camera_entity, - transform: transform.into(), - item: ExtractedUiItem::Node { - color: outer_color.0.into(), - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: transform.into(), + item: ExtractedUiItem::Node { + color: outer_color.0.into(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: uinode.border_radius(), + node_type: NodeType::Inverted, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: uinode.border_radius(), - node_type: NodeType::Inverted, - }, - main_entity: entity.into(), - }); + ); } } } pub fn extract_uinode_images( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, texture_atlases: Extract>>, uinode_query: Extract< Query<( @@ -504,7 +735,9 @@ pub fn extract_uinode_images( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); + for ( entity, uinode, @@ -515,7 +748,10 @@ pub fn extract_uinode_images( camera, image, image_size, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { let visual_box = match image.visual_box { VisualBox::ContentBox => uinode.content_box(), @@ -576,31 +812,37 @@ pub fn extract_uinode_images( None }; - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip: clip.map(|clip| clip.clip), - image: image.image.id(), - extracted_camera_entity, - transform: Affine2::from(*transform) * Affine2::from_translation(visual_box.center()), - item: ExtractedUiItem::Node { - color: image.color.into(), - rect, - atlas_scaling, - flip_x: image.flip_x, - flip_y: image.flip_y, - border: BorderRect::ZERO, - border_radius: uinode.border_radius, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, + clip: clip.map(|clip| clip.clip), + image: image.image.id(), + extracted_camera_entity, + transform: Affine2::from(*transform) + * Affine2::from_translation(visual_box.center()), + item: ExtractedUiItem::Node { + color: image.color.into(), + rect, + atlas_scaling, + flip_x: image.flip_x, + flip_y: image.flip_y, + border: BorderRect::ZERO, + border_radius: uinode.border_radius, + node_type: NodeType::Rect, + }, + }, + ); } } pub fn extract_uinode_borders( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -616,6 +858,7 @@ pub fn extract_uinode_borders( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let image = AssetId::::default(); let mut camera_mapper = camera_map.get_mapper(); @@ -629,7 +872,10 @@ pub fn extract_uinode_borders( maybe_clip, camera, (maybe_border_color, maybe_outline), - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip invisible borders and removed nodes if !inherited_visibility.get() || node.is_some_and(|node| node.display == Display::None) { @@ -677,7 +923,7 @@ pub fn extract_uinode_borders( } completed_flags |= border_flags; - extracted_uinodes.uinodes.push(ExtractedUiNode { + let node = ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::BORDER, image, clip: maybe_clip.map(|clip| clip.clip), @@ -696,9 +942,13 @@ pub fn extract_uinode_borders( border_radius: computed_node.border_radius(), node_type: NodeType::Border(border_flags), }, - main_entity: entity.into(), - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - }); + }; + + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert(commands.spawn_empty().id(), node); } } @@ -709,28 +959,33 @@ pub fn extract_uinode_borders( if let Some(outline) = maybe_outline.filter(|outline| !outline.color.is_fully_transparent()) { let outline_size = computed_node.outlined_node_size(); - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::BORDER, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - image, - clip: maybe_clip.map(|clip| clip.clip), - extracted_camera_entity, - transform: transform.into(), - item: ExtractedUiItem::Node { - color: outline.color.into(), - rect: Rect { - max: outline_size, - ..Default::default() + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::BORDER, + image, + clip: maybe_clip.map(|clip| clip.clip), + extracted_camera_entity, + transform: transform.into(), + item: ExtractedUiItem::Node { + color: outline.color.into(), + rect: Rect { + max: outline_size, + ..Default::default() + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::all(computed_node.outline_width()), + border_radius: computed_node.outline_radius(), + node_type: NodeType::Border(shader_flags::BORDER_ALL), + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::all(computed_node.outline_width()), - border_radius: computed_node.outline_radius(), - node_type: NodeType::Border(shader_flags::BORDER_ALL), - }, - main_entity: entity.into(), - }); + ); } } } @@ -772,6 +1027,15 @@ pub struct UiCameraView(pub Entity); #[derive(Component)] pub struct UiViewTarget(pub Entity); +/// Information that [`extract_ui_camera_view`] maintains about each view that +/// it has seen. +pub struct CachedUiViewData { + /// The render-world [`ExtractedView`]. + extracted_view_entity: Entity, + /// The unique, stable identifier for the view across frames. + retained_view_entity: RetainedViewEntity, +} + /// Extracts all UI elements associated with a camera into the render world. pub fn extract_ui_camera_view( mut commands: Commands, @@ -781,42 +1045,69 @@ pub fn extract_ui_camera_view( ( Entity, RenderEntity, - &Camera, - Option<&UiAntiAlias>, - Option<&BoxShadowSamples>, + Ref, + Option>, + Option>, ), Or<(With, With)>, >, >, + changed_query: Extract< + Query< + Entity, + Or<( + Changed, + Changed, + Changed, + )>, + >, + >, main_pass_formats: Res, mut live_entities: Local>, + mut cached_ui_view_data: Local>, + ( + mut removed_cameras_query, + mut removed_ui_anti_alias_query, + mut removed_box_shadow_samples_query, + ): ( + Extract>, + Extract>, + Extract>, + ), + mut changed_cameras: Local, + mut cameras_updated_this_frame: Local, ) { - live_entities.clear(); + changed_cameras.clear(); + for main_entity in changed_query + .iter() + .chain(removed_ui_anti_alias_query.read()) + .chain(removed_box_shadow_samples_query.read()) + { + changed_cameras.insert(main_entity.into()); + } + cameras_updated_this_frame.clear(); for (main_entity, render_entity, camera, ui_anti_alias, shadow_samples) in &query { - // ignore inactive cameras - if !camera.is_active { - commands - .get_entity(render_entity) - .expect("Camera entity wasn't synced.") - .remove::<(UiCameraView, UiAntiAlias, BoxShadowSamples)>(); - continue; - } + let main_entity = MainEntity::from(main_entity); + let retained_view_entity = RetainedViewEntity::new(main_entity, None, UI_CAMERA_SUBVIEW); - if let (Some(physical_viewport_rect), Some(_viewport_size), Some(target_size)) = ( + // ignore inactive cameras + if let (Some(physical_viewport_rect), Some(target_size), Some(target_format)) = ( camera.physical_viewport_rect(), - camera.physical_viewport_size(), camera.physical_target_size(), + main_pass_formats.get(&render_entity).copied(), ) && target_size.x != 0 && target_size.y != 0 + && camera.physical_viewport_size().is_some() + && camera.is_active { - let Some(target_format) = main_pass_formats.get(&render_entity).copied() else { - commands - .get_entity(render_entity) - .expect("Camera entity wasn't synced.") - .remove::<(UiCameraView, UiAntiAlias, BoxShadowSamples)>(); + cameras_updated_this_frame.insert(main_entity); + transparent_render_phases.prepare_for_new_frame(retained_view_entity); + + // If the camera hasn't changed, we're done. + if !changed_cameras.contains(&main_entity) { continue; - }; + } // use a projection matrix with the origin in the top left instead of the bottom left that comes with OrthographicProjection let projection_matrix = Mat4::orthographic_rh( @@ -829,8 +1120,6 @@ pub fn extract_ui_camera_view( ); // We use `UI_CAMERA_SUBVIEW` here so as not to conflict with the // main 3D or 2D camera, which will have subview index 0. - let retained_view_entity = - RetainedViewEntity::new(main_entity.into(), None, UI_CAMERA_SUBVIEW); // Creates the UI view. let ui_camera_view = commands .spawn(( @@ -853,7 +1142,6 @@ pub fn extract_ui_camera_view( }, // Link to the main camera view. UiViewTarget(render_entity), - TemporaryRenderEntity::default(), )) .id(); @@ -868,18 +1156,57 @@ pub fn extract_ui_camera_view( if let Some(shadow_samples) = shadow_samples { entity_commands.insert(*shadow_samples); } - transparent_render_phases.prepare_for_new_frame(retained_view_entity); live_entities.insert(retained_view_entity); + cached_ui_view_data.insert( + main_entity, + CachedUiViewData { + extracted_view_entity: entity_commands.id(), + retained_view_entity, + }, + ); + continue; + } + + // If we got here, the camera no longer exists or is no longer + // renderable. Remove its associated render-world data. + commands + .get_entity(render_entity) + .expect("Camera entity wasn't synced.") + .remove::<(UiCameraView, UiAntiAlias, BoxShadowSamples)>(); + live_entities.remove(&retained_view_entity); + if let Some(cached_ui_view_data) = cached_ui_view_data.remove(&main_entity) { + commands + .entity(cached_ui_view_data.extracted_view_entity) + .despawn(); } } + // Only remove the render-world data for a camera if we didn't handle the + // camera above. + // It's possible that the `Camera` component was removed and added in the + // same frame. + for main_entity in removed_cameras_query.read() { + let main_entity = MainEntity::from(main_entity); + if cameras_updated_this_frame.contains(&main_entity) { + continue; + } + + if let Some(cached_ui_view_data) = cached_ui_view_data.remove(&main_entity) { + commands + .entity(cached_ui_view_data.extracted_view_entity) + .despawn(); + live_entities.remove(&cached_ui_view_data.retained_view_entity); + } + } + + // Clean up render phases belonging to cameras that no longer exist. transparent_render_phases.retain(|entity, _| live_entities.contains(entity)); } pub fn extract_viewport_nodes( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, camera_query: Extract>, uinode_query: Extract< Query<( @@ -895,7 +1222,9 @@ pub fn extract_viewport_nodes( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); + for ( entity, uinode, @@ -905,7 +1234,10 @@ pub fn extract_viewport_nodes( clip, camera, viewport_node, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip invisible images if !inherited_visibility.get() || uinode.is_empty() { @@ -927,34 +1259,39 @@ pub fn extract_viewport_nodes( continue; }; - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip: clip.map(|clip| clip.clip), - image: image.id(), - extracted_camera_entity, - transform: transform.into(), - item: ExtractedUiItem::Node { - color: LinearRgba::WHITE, - rect: Rect { - min: Vec2::ZERO, - max: uinode.size, + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, + clip: clip.map(|clip| clip.clip), + image: image.id(), + extracted_camera_entity, + transform: transform.into(), + item: ExtractedUiItem::Node { + color: LinearRgba::WHITE, + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: uinode.border(), + border_radius: uinode.border_radius(), + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: uinode.border(), - border_radius: uinode.border_radius(), - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } pub fn extract_text_sections( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -974,10 +1311,11 @@ pub fn extract_text_sections( text_styles: Extract>, camera_map: Extract, ) { - let mut start = extracted_uinodes.glyphs.len(); - let mut end = start + 1; - + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); + + let mut glyphs = vec![]; + for ( entity, uinode, @@ -991,7 +1329,10 @@ pub fn extract_text_sections( text_layout_info, text_scroll, cursor_style, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) if !inherited_visibility.get() || uinode.is_empty() { @@ -1066,7 +1407,7 @@ pub fn extract_text_sections( color }; - extracted_uinodes.glyphs.push(ExtractedGlyph { + glyphs.push(ExtractedGlyph { color, translation: *position, rect: atlas_info.rect, @@ -1077,27 +1418,31 @@ pub fn extract_text_sections( .get(i + 1) .is_none_or(|info| info.atlas_info.texture != atlas_info.texture) { - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - image: atlas_info.texture, - clip, - extracted_camera_entity, - item: ExtractedUiItem::Glyphs { range: start..end }, - main_entity: entity.into(), - transform, - }); - start = end; + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, + image: atlas_info.texture, + clip, + extracted_camera_entity, + item: ExtractedUiItem::Glyphs { + glyphs: mem::take(&mut glyphs), + }, + transform, + }, + ); } - - end += 1; } } } pub fn extract_text_shadows( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -1116,10 +1461,11 @@ pub fn extract_text_shadows( text_decoration_query: Extract, Has)>>, camera_map: Extract, ) { - let mut start = extracted_uinodes.glyphs.len(); - let mut end = start + 1; - + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); + + let mut glyphs = vec![]; + for ( entity, uinode, @@ -1132,7 +1478,10 @@ pub fn extract_text_shadows( shadow, computed_block, text_scroll, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) if !inherited_visibility.get() || uinode.is_empty() { @@ -1170,7 +1519,7 @@ pub fn extract_text_shadows( }, ) in text_layout_info.glyphs.iter().enumerate() { - extracted_uinodes.glyphs.push(ExtractedGlyph { + glyphs.push(ExtractedGlyph { color: shadow.color.into(), translation: *position, rect: atlas_info.rect, @@ -1180,20 +1529,24 @@ pub fn extract_text_shadows( info.section_index != *section_index || info.atlas_info.texture != atlas_info.texture }) { - extracted_uinodes.uinodes.push(ExtractedUiNode { - transform: node_transform, - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - image: atlas_info.texture, - clip, - extracted_camera_entity, - item: ExtractedUiItem::Glyphs { range: start..end }, - main_entity: entity.into(), - }); - start = end; + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + transform: node_transform, + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, + image: atlas_info.texture, + clip, + extracted_camera_entity, + item: ExtractedUiItem::Glyphs { + glyphs: mem::take(&mut glyphs), + }, + }, + ); } - - end += 1; } for run in text_layout_info.run_geometry.iter() { @@ -1210,54 +1563,65 @@ pub fn extract_text_shadows( }; if has_strikethrough { - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: node_transform - * Affine2::from_translation(run.strikethrough_position()), - item: ExtractedUiItem::Node { - color: shadow.color.into(), - rect: Rect { - min: Vec2::ZERO, - max: run.strikethrough_size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: node_transform + * Affine2::from_translation(run.strikethrough_position()), + item: ExtractedUiItem::Node { + color: shadow.color.into(), + rect: Rect { + min: Vec2::ZERO, + max: run.strikethrough_size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: ResolvedBorderRadius::ZERO, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } if has_underline { - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: node_transform * Affine2::from_translation(run.underline_position()), - item: ExtractedUiItem::Node { - color: shadow.color.into(), - rect: Rect { - min: Vec2::ZERO, - max: run.underline_size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: node_transform + * Affine2::from_translation(run.underline_position()), + item: ExtractedUiItem::Node { + color: shadow.color.into(), + rect: Rect { + min: Vec2::ZERO, + max: run.underline_size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: ResolvedBorderRadius::ZERO, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } } @@ -1265,7 +1629,7 @@ pub fn extract_text_shadows( pub fn extract_text_decorations( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, uinode_query: Extract< Query<( Entity, @@ -1290,7 +1654,9 @@ pub fn extract_text_decorations( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); + for ( entity, uinode, @@ -1302,7 +1668,10 @@ pub fn extract_text_decorations( camera, text_layout_info, text_scroll, - ) in &uinode_query + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| uinode_query.get(main_entity.entity()).ok()) { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) if !inherited_visibility.get() || uinode.is_empty() { @@ -1348,28 +1717,33 @@ pub fn extract_text_decorations( }; if let Some(text_background_color) = text_background_color { - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(run.bounds.center()), - item: ExtractedUiItem::Node { - color: text_background_color.0.to_linear(), - rect: Rect { - min: Vec2::ZERO, - max: run.bounds.size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(run.bounds.center()), + item: ExtractedUiItem::Node { + color: text_background_color.0.to_linear(), + rect: Rect { + min: Vec2::ZERO, + max: run.bounds.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: ResolvedBorderRadius::ZERO, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } if maybe_strikethrough.is_some() { @@ -1378,28 +1752,34 @@ pub fn extract_text_decorations( .unwrap_or(text_color.0) .to_linear(); - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(run.strikethrough_position()), - item: ExtractedUiItem::Node { - color, - rect: Rect { - min: Vec2::ZERO, - max: run.strikethrough_size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform + * Affine2::from_translation(run.strikethrough_position()), + item: ExtractedUiItem::Node { + color, + rect: Rect { + min: Vec2::ZERO, + max: run.strikethrough_size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: ResolvedBorderRadius::ZERO, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } if maybe_underline.is_some() { @@ -1408,35 +1788,41 @@ pub fn extract_text_decorations( .unwrap_or(text_color.0) .to_linear(); - extracted_uinodes.uinodes.push(ExtractedUiNode { - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(run.underline_position()), - item: ExtractedUiItem::Node { - color, - rect: Rect { - min: Vec2::ZERO, - max: run.underline_size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform + * Affine2::from_translation(run.underline_position()), + item: ExtractedUiItem::Node { + color, + rect: Rect { + min: Vec2::ZERO, + max: run.underline_size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::ZERO, - border_radius: ResolvedBorderRadius::ZERO, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } } } #[repr(C)] -#[derive(Copy, Clone, Pod, Zeroable)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] struct UiVertex { pub position: [f32; 3], pub uv: [f32; 2], @@ -1482,7 +1868,7 @@ pub(crate) const QUAD_VERTEX_POSITIONS: [Vec2; 4] = [ pub(crate) const QUAD_INDICES: [usize; 6] = [0, 2, 3, 0, 1, 2]; -#[derive(Component)] +#[derive(Component, Debug)] pub struct UiBatch { pub range: Range, pub image: AssetId, @@ -1522,48 +1908,51 @@ pub fn queue_uinodes( let mut current_camera_entity = Entity::PLACEHOLDER; let mut current_phase = None; - for (index, extracted_uinode) in extracted_uinodes.uinodes.iter().enumerate() { - if current_camera_entity != extracted_uinode.extracted_camera_entity { - current_phase = render_views - .get(extracted_uinode.extracted_camera_entity) - .ok() - .and_then(|(default_camera_view, ui_anti_alias)| { - camera_views - .get(default_camera_view.0) - .ok() - .and_then(|view| { - transparent_render_phases - .get_mut(&view.retained_view_entity) - .map(|transparent_phase| (view, ui_anti_alias, transparent_phase)) - }) - }); - current_camera_entity = extracted_uinode.extracted_camera_entity; - } + for (main_entity, extracted_sub_uinodes) in extracted_uinodes.uinodes.iter() { + for (render_entity, extracted_uinode) in extracted_sub_uinodes.iter() { + if current_camera_entity != extracted_uinode.extracted_camera_entity { + current_phase = render_views + .get(extracted_uinode.extracted_camera_entity) + .ok() + .and_then(|(default_camera_view, ui_anti_alias)| { + camera_views + .get(default_camera_view.0) + .ok() + .and_then(|view| { + transparent_render_phases + .get_mut(&view.retained_view_entity) + .map(|transparent_phase| { + (view, ui_anti_alias, transparent_phase) + }) + }) + }); + current_camera_entity = extracted_uinode.extracted_camera_entity; + } - let Some((view, ui_anti_alias, transparent_phase)) = current_phase.as_mut() else { - continue; - }; + let Some((view, ui_anti_alias, transparent_phase)) = current_phase.as_mut() else { + continue; + }; - let pipeline = pipelines.specialize( - &pipeline_cache, - &ui_pipeline, - UiPipelineKey { - target_format: view.target_format, - anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), - }, - ); + let pipeline = pipelines.specialize( + &pipeline_cache, + &ui_pipeline, + UiPipelineKey { + target_format: view.target_format, + anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), + }, + ); - transparent_phase.add_transient(TransparentUi { - draw_function, - pipeline, - entity: (extracted_uinode.render_entity, extracted_uinode.main_entity), - sort_key: FloatOrd(extracted_uinode.z_order), - index, - // batch_range will be calculated in prepare_uinodes - batch_range: 0..0, - extra_index: PhaseItemExtraIndex::None, - indexed: true, - }); + transparent_phase.add_transient(TransparentUi { + draw_function, + pipeline, + entity: (*render_entity, *main_entity), + sort_key: FloatOrd(extracted_uinode.z_order), + // batch_range will be calculated in prepare_uinodes + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + indexed: true, + }); + } } } @@ -1578,7 +1967,7 @@ pub fn prepare_uinodes( render_queue: Res, pipeline_cache: Res, mut ui_meta: ResMut, - mut extracted_uinodes: ResMut, + extracted_uinodes: Res, view_uniforms: Res, ui_pipeline: Res, mut image_bind_groups: ResMut, @@ -1623,13 +2012,17 @@ pub fn prepare_uinodes( let item = &mut ui_phase.items[item_index]; let Some(extracted_uinode) = extracted_uinodes .uinodes - .get(item.index) - .filter(|n| item.entity() == n.render_entity) + .get(&item.main_entity()) + .and_then(|sub_uinodes| sub_uinodes.get(&item.entity())) else { batch_image_handle = None; continue; }; + // Initialize the batch range to be zero-length initially. + // We'll extend it as we accumulate items into this batch. + item.batch_range = (item_index as u32)..(item_index as u32); + let mut existing_batch = batches.last_mut(); if batch_image_handle.is_none() @@ -1646,7 +2039,6 @@ pub fn prepare_uinodes( range: vertices_index..vertices_index, image: extracted_uinode.image, }; - batches.push((item.entity(), new_batch)); image_bind_groups @@ -1790,14 +2182,14 @@ pub fn prepare_uinodes( .map(|scaling| image.size_2d().as_vec2() * scaling) .unwrap_or(uinode_rect.max); if *flip_x { - core::mem::swap(&mut uinode_rect.max.x, &mut uinode_rect.min.x); + mem::swap(&mut uinode_rect.max.x, &mut uinode_rect.min.x); positions_diff[0].x *= -1.; positions_diff[1].x *= -1.; positions_diff[2].x *= -1.; positions_diff[3].x *= -1.; } if *flip_y { - core::mem::swap(&mut uinode_rect.max.y, &mut uinode_rect.min.y); + mem::swap(&mut uinode_rect.max.y, &mut uinode_rect.min.y); positions_diff[0].y *= -1.; positions_diff[1].y *= -1.; positions_diff[2].y *= -1.; @@ -1836,7 +2228,7 @@ pub fn prepare_uinodes( } for i in 0..4 { - ui_meta.vertices.push(UiVertex { + let ui_vertex = UiVertex { position: positions_clipped[i].into(), uv: uvs[i].into(), color, @@ -1850,7 +2242,8 @@ pub fn prepare_uinodes( ], size: rect_size.into(), point: points[i].into(), - }); + }; + ui_meta.vertices.push(ui_vertex); } for &i in &QUAD_INDICES { @@ -1860,14 +2253,14 @@ pub fn prepare_uinodes( vertices_index += 6; indices_index += 4; } - ExtractedUiItem::Glyphs { range } => { + ExtractedUiItem::Glyphs { glyphs } => { let image = gpu_images .get(extracted_uinode.image) .expect("Image was checked during batching and should still exist"); let atlas_extent = image.size_2d().as_vec2(); - for glyph in &extracted_uinodes.glyphs[range.clone()] { + for glyph in glyphs { let color = glyph.color.to_f32_array(); let glyph_rect = glyph.rect; let rect_size = glyph_rect.size(); @@ -1974,5 +2367,16 @@ pub fn prepare_uinodes( *previous_len = batches.len(); commands.try_insert_batch(batches); } - extracted_uinodes.clear(); +} + +/// A render-world system that removes all [`UiBatch`] components. +/// +/// They're currently rebuilt from scratch every frame, so we have to remove +/// them. +/// +/// This is run during the render cleanup phase. +pub fn clear_batches(mut commands: Commands, batches_query: Query>) { + for entity in &batches_query { + commands.entity(entity).remove::(); + } } diff --git a/crates/bevy_ui_render/src/render_pass.rs b/crates/bevy_ui_render/src/render_pass.rs index 116c68c171405..90ea1d454513b 100644 --- a/crates/bevy_ui_render/src/render_pass.rs +++ b/crates/bevy_ui_render/src/render_pass.rs @@ -74,6 +74,7 @@ pub fn ui_pass( pass_span.end(&mut render_pass); } +#[derive(Debug)] pub struct TransparentUi { pub sort_key: FloatOrd, pub entity: (Entity, MainEntity), @@ -81,7 +82,6 @@ pub struct TransparentUi { pub draw_function: DrawFunctionId, pub batch_range: Range, pub extra_index: PhaseItemExtraIndex, - pub index: usize, pub indexed: bool, } diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs index 50735ef30cb27..950d5d793cc93 100644 --- a/crates/bevy_ui_render/src/text.rs +++ b/crates/bevy_ui_render/src/text.rs @@ -4,7 +4,7 @@ use bevy_color::Alpha; use bevy_ecs::prelude::*; use bevy_input_focus::InputFocus; use bevy_math::{Affine2, Rect, Vec2}; -use bevy_render::{sync_world::TemporaryRenderEntity, Extract}; +use bevy_render::Extract; use bevy_sprite::BorderRect; use bevy_text::{EditableText, TextColor, TextCursorStyle, TextLayoutInfo}; use bevy_ui::{ @@ -18,7 +18,7 @@ use crate::{ pub fn extract_text_cursor( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, text_node_query: Extract< Query<( Entity, @@ -36,6 +36,7 @@ pub fn extract_text_cursor( camera_map: Extract, input_focus: Extract>>, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); for ( @@ -49,7 +50,10 @@ pub fn extract_text_cursor( text_layout_info, cursor_style, text_scroll, - ) in text_node_query.iter() + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| text_node_query.get(main_entity.entity()).ok()) { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) if !inherited_visibility.get() || uinode.is_empty() { @@ -136,28 +140,33 @@ pub fn extract_text_cursor( } } - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_SELECTION, - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(selection.center()), - item: ExtractedUiItem::Node { - color: selection_color, - rect: Rect { - min: Vec2::ZERO, - max: selection.size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_SELECTION, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(selection.center()), + item: ExtractedUiItem::Node { + color: selection_color, + rect: Rect { + min: Vec2::ZERO, + max: selection.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::default(), + border_radius, + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::default(), - border_radius, - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } @@ -165,35 +174,40 @@ pub fn extract_text_cursor( && !cursor_rect.is_empty() && !cursor_style.color.is_fully_transparent() { - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_CURSOR, - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(cursor_rect.center()), - item: ExtractedUiItem::Node { - color: cursor_style.color.to_linear(), - rect: Rect { - min: Vec2::ZERO, - max: cursor_rect.size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_CURSOR, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(cursor_rect.center()), + item: ExtractedUiItem::Node { + color: cursor_style.color.to_linear(), + rect: Rect { + min: Vec2::ZERO, + max: cursor_rect.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::default(), + border_radius: ResolvedBorderRadius::default(), + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::default(), - border_radius: ResolvedBorderRadius::default(), - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } } pub fn extract_preedit_underlines( mut commands: Commands, - mut extracted_uinodes: ResMut, + extracted_uinodes: ResMut, text_node_query: Extract< Query< ( @@ -213,6 +227,7 @@ pub fn extract_preedit_underlines( >, camera_map: Extract, ) { + let extracted_uinodes = extracted_uinodes.into_inner(); let mut camera_mapper = camera_map.get_mapper(); for ( @@ -226,7 +241,10 @@ pub fn extract_preedit_underlines( target_camera, stack_index, text_scroll, - ) in text_node_query.iter() + ) in extracted_uinodes + .changed + .iter() + .flat_map(|main_entity| text_node_query.get(main_entity.entity()).ok()) { if !inherited_visibility.get() || uinode.is_empty() @@ -258,28 +276,33 @@ pub fn extract_preedit_underlines( let color = text_color.0.to_linear(); for rect in text_layout_info.preedit_underline_rects.iter() { - extracted_uinodes.uinodes.push(ExtractedUiNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, - clip, - image: AssetId::default(), - extracted_camera_entity, - transform: transform * Affine2::from_translation(rect.center()), - item: ExtractedUiItem::Node { - color, - rect: Rect { - min: Vec2::ZERO, - max: rect.size(), + extracted_uinodes + .uinodes + .entry(entity.into()) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiNode { + z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, + clip, + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(rect.center()), + item: ExtractedUiItem::Node { + color, + rect: Rect { + min: Vec2::ZERO, + max: rect.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::default(), + border_radius: ResolvedBorderRadius::default(), + node_type: NodeType::Rect, + }, }, - atlas_scaling: None, - flip_x: false, - flip_y: false, - border: BorderRect::default(), - border_radius: ResolvedBorderRadius::default(), - node_type: NodeType::Rect, - }, - main_entity: entity.into(), - }); + ); } } } diff --git a/crates/bevy_ui_render/src/ui_material_pipeline.rs b/crates/bevy_ui_render/src/ui_material_pipeline.rs index d9cbbb520c333..4a6d00f1dff7a 100644 --- a/crates/bevy_ui_render/src/ui_material_pipeline.rs +++ b/crates/bevy_ui_render/src/ui_material_pipeline.rs @@ -17,7 +17,7 @@ use bevy_render::{ render_phase::*, render_resource::{binding_types::uniform_buffer, *}, renderer::{RenderDevice, RenderQueue}, - sync_world::{MainEntity, TemporaryRenderEntity}, + sync_world::MainEntity, view::*, Extract, ExtractSchedule, Render, RenderSystems, }; @@ -301,13 +301,16 @@ pub struct ExtractedUiMaterialNode { // it is defaulted to a single camera if only one exists. // Nodes with ambiguous camera will be ignored. pub extracted_camera_entity: Entity, - pub main_entity: MainEntity, - pub render_entity: Entity, } +/// A render-world resource that stores all material nodes in the scene. #[derive(Resource)] pub struct ExtractedUiMaterialNodes { - pub uinodes: Vec>, + /// The list of material nodes. + /// + /// This is a two-level data structure so that we can quickly remove all + /// material nodes associated with a main-world entity when it changes. + pub uinodes: MainEntityHashMap>>, } impl Default for ExtractedUiMaterialNodes { @@ -323,19 +326,49 @@ pub fn extract_ui_material_nodes( mut extracted_uinodes: ResMut>, materials: Extract>>, uinode_query: Extract< - Query<( - Entity, - &ComputedNode, - &ComputedStackIndex, - &UiGlobalTransform, - &MaterialNode, - &InheritedVisibility, - Option<&CalculatedClip>, - &ComputedUiTargetCamera, - )>, + Query< + ( + Entity, + &ComputedNode, + &ComputedStackIndex, + &UiGlobalTransform, + &MaterialNode, + &InheritedVisibility, + Option<&CalculatedClip>, + &ComputedUiTargetCamera, + ), + Or<( + Changed, + Changed, + Changed, + Changed>, + Changed, + Changed, + Changed, + )>, + >, >, camera_map: Extract, + ( + mut removed_computed_node_query, + mut removed_computed_stack_index_query, + mut removed_ui_global_transform_query, + mut removed_material_node_query, + mut removed_inherited_visibility_query, + mut removed_calculated_clip_query, + mut removed_computed_ui_target_camera_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>>, + Extract>, + Extract>, + Extract>, + ), + mut nodes_processed_this_frame: Local, ) { + nodes_processed_this_frame.clear(); let mut camera_mapper = camera_map.get_mapper(); for ( @@ -363,21 +396,53 @@ pub fn extract_ui_material_nodes( continue; }; - extracted_uinodes.uinodes.push(ExtractedUiMaterialNode { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: transform.into(), - material: handle.id(), - rect: Rect { - min: Vec2::ZERO, - max: computed_node.size(), - }, - border: computed_node.border(), - border_radius: computed_node.border_radius().into(), - clip: clip.map(|clip| clip.clip), - extracted_camera_entity, - main_entity: entity.into(), - }); + let main_entity = MainEntity::from(entity); + nodes_processed_this_frame.insert(main_entity); + + extracted_uinodes + .uinodes + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiMaterialNode { + stack_index: stack_index.0, + transform: transform.into(), + material: handle.id(), + rect: Rect { + min: Vec2::ZERO, + max: computed_node.size(), + }, + border: computed_node.border(), + border_radius: computed_node.border_radius().into(), + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + }, + ); + } + + // Only remove the render-world data if we didn't handle the node above. + // It's possible that a relevant component was removed and added in the same + // frame. + for main_entity in removed_computed_node_query + .read() + .chain(removed_computed_stack_index_query.read()) + .chain(removed_ui_global_transform_query.read()) + .chain(removed_material_node_query.read()) + .chain(removed_inherited_visibility_query.read()) + .chain(removed_calculated_clip_query.read()) + .chain(removed_computed_ui_target_camera_query.read()) + { + let main_entity = MainEntity::from(main_entity); + if nodes_processed_this_frame.contains(&main_entity) { + continue; + } + let Some(mut extracted_nodes) = extracted_uinodes.uinodes.remove(&main_entity) else { + continue; + }; + for (render_entity, _) in extracted_nodes.drain(..) { + commands.entity(render_entity).despawn(); + } } } @@ -387,7 +452,7 @@ pub fn prepare_uimaterial_nodes( render_queue: Res, pipeline_cache: Res, mut ui_meta: ResMut>, - mut extracted_uinodes: ResMut>, + extracted_uinodes: Res>, view_uniforms: Res, globals_buffer: Res, ui_material_pipeline: Res>, @@ -416,9 +481,13 @@ pub fn prepare_uimaterial_nodes( let item = &mut ui_phase.items[item_index]; if let Some(extracted_uinode) = extracted_uinodes .uinodes - .get(item.index) - .filter(|n| item.entity() == n.render_entity) + .get(&item.main_entity()) + .and_then(|subnodes| subnodes.get(&item.entity())) { + // Initialize the batch range to be zero-length initially. + // We'll extend it as we accumulate items into this batch. + item.batch_range = (item_index as u32)..(item_index as u32); + let mut existing_batch = batches .last_mut() .filter(|_| batch_shader_handle == Some(extracted_uinode.material)); @@ -544,7 +613,6 @@ pub fn prepare_uimaterial_nodes( *previous_len = batches.len(); commands.try_insert_batch(batches); } - extracted_uinodes.uinodes.clear(); } pub struct PreparedUiMaterial { @@ -606,48 +674,50 @@ pub fn queue_ui_material_nodes( { let draw_function = draw_functions.read().id::>(); - for (index, extracted_uinode) in extracted_uinodes.uinodes.iter().enumerate() { - let Some(material) = render_materials.get(extracted_uinode.material) else { - continue; - }; - - let Ok(default_camera_view) = - render_views.get_mut(extracted_uinode.extracted_camera_entity) - else { - continue; - }; - - let Ok(view) = camera_views.get(default_camera_view.0) else { - continue; - }; - - let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) - else { - continue; - }; - - let pipeline = pipelines.specialize( - &pipeline_cache, - &ui_material_pipeline, - UiMaterialKey { - target_format: view.target_format, - bind_group_data: material.key.clone(), - }, - ); - if transparent_phase.items.capacity() < extracted_uinodes.uinodes.len() { - transparent_phase.items.reserve_exact( - extracted_uinodes.uinodes.len() - transparent_phase.items.capacity(), + for (main_entity, extracted_sub_uinodes) in extracted_uinodes.uinodes.iter() { + for (render_entity, extracted_uinode) in extracted_sub_uinodes.iter() { + let Some(material) = render_materials.get(extracted_uinode.material) else { + continue; + }; + + let Ok(default_camera_view) = + render_views.get_mut(extracted_uinode.extracted_camera_entity) + else { + continue; + }; + + let Ok(view) = camera_views.get(default_camera_view.0) else { + continue; + }; + + let Some(transparent_phase) = + transparent_render_phases.get_mut(&view.retained_view_entity) + else { + continue; + }; + + let pipeline = pipelines.specialize( + &pipeline_cache, + &ui_material_pipeline, + UiMaterialKey { + target_format: view.target_format, + bind_group_data: material.key.clone(), + }, ); + if transparent_phase.items.capacity() < extracted_uinodes.uinodes.len() { + transparent_phase.items.reserve_exact( + extracted_uinodes.uinodes.len() - transparent_phase.items.capacity(), + ); + } + transparent_phase.add_transient(TransparentUi { + draw_function, + pipeline, + entity: (*render_entity, *main_entity), + sort_key: FloatOrd(extracted_uinode.stack_index as f32 + M::stack_z_offset()), + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + indexed: false, + }); } - transparent_phase.add_transient(TransparentUi { - draw_function, - pipeline, - entity: (extracted_uinode.render_entity, extracted_uinode.main_entity), - sort_key: FloatOrd(extracted_uinode.stack_index as f32 + M::stack_z_offset()), - batch_range: 0..0, - extra_index: PhaseItemExtraIndex::None, - index, - indexed: false, - }); } } diff --git a/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs b/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs index fa6f4f3aff48b..3b01e84971ae7 100644 --- a/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs +++ b/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs @@ -203,13 +203,16 @@ pub struct ExtractedUiTextureSlice { pub flip_x: bool, pub flip_y: bool, pub inverse_scale_factor: f32, - pub main_entity: MainEntity, - pub render_entity: Entity, } +/// A render-world resource that stores all texture slices in the scene. #[derive(Resource, Default)] pub struct ExtractedUiTextureSlices { - pub slices: Vec, + /// The list of texture slices. + /// + /// This is a two-level data structure so that we can quickly remove all + /// texture slices associated with a main-world entity when it changes. + pub slices: MainEntityHashMap>, } pub fn extract_ui_texture_slices( @@ -217,24 +220,61 @@ pub fn extract_ui_texture_slices( mut extracted_ui_slicers: ResMut, texture_atlases: Extract>>, slicers_query: Extract< - Query<( - Entity, - &ComputedNode, - &ComputedStackIndex, - &UiGlobalTransform, - &InheritedVisibility, - Option<&CalculatedClip>, - &ComputedUiTargetCamera, - &ImageNode, - )>, + Query< + ( + Entity, + &ComputedNode, + &ComputedStackIndex, + &UiGlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + &ComputedUiTargetCamera, + &ImageNode, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + // The `bevy_ui::widget::update_image_content_size_system` marks + // `ImageNodeSize` as changed to indicate that the image metrics + // and/or texture atlas layout changed, so we need to watch for + // changes to that component, even though we don't read it. + Changed, + )>, + >, >, camera_map: Extract, + ( + mut removed_computed_node_query, + mut removed_computed_stack_index_query, + mut removed_ui_global_transform_query, + mut removed_inherited_visibility_query, + mut removed_calculated_clip_query, + mut removed_computed_ui_target_camera_query, + mut removed_image_node_query, + ): ( + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + Extract>, + ), + mut nodes_processed_this_frame: Local, ) { + nodes_processed_this_frame.clear(); let mut camera_mapper = camera_map.get_mapper(); for (entity, uinode, stack_index, transform, inherited_visibility, clip, camera, image) in &slicers_query { + let main_entity = MainEntity::from(entity); + let visual_box = match image.visual_box { VisualBox::ContentBox => uinode.content_box(), VisualBox::PaddingBox => uinode.padding_box(), @@ -268,6 +308,8 @@ pub fn extract_ui_texture_slices( continue; }; + nodes_processed_this_frame.insert(main_entity); + let atlas_rect = image .texture_atlas .as_ref() @@ -285,25 +327,55 @@ pub fn extract_ui_texture_slices( } }; - extracted_ui_slicers.slices.push(ExtractedUiTextureSlice { - render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), - stack_index: stack_index.0, - transform: Affine2::from(*transform) * Affine2::from_translation(visual_box.center()), - color: image.color.into(), - rect: Rect { - min: Vec2::ZERO, - max: visual_box.size(), - }, - clip: clip.map(|clip| clip.clip), - image: image.image.id(), - extracted_camera_entity, - image_scale_mode, - atlas_rect, - flip_x: image.flip_x, - flip_y: image.flip_y, - inverse_scale_factor: uinode.inverse_scale_factor, - main_entity: entity.into(), - }); + extracted_ui_slicers + .slices + .entry(main_entity) + .or_default() + .insert( + commands.spawn_empty().id(), + ExtractedUiTextureSlice { + stack_index: stack_index.0, + transform: Affine2::from(*transform) + * Affine2::from_translation(visual_box.center()), + color: image.color.into(), + rect: Rect { + min: Vec2::ZERO, + max: visual_box.size(), + }, + clip: clip.map(|clip| clip.clip), + image: image.image.id(), + extracted_camera_entity, + image_scale_mode, + atlas_rect, + flip_x: image.flip_x, + flip_y: image.flip_y, + inverse_scale_factor: uinode.inverse_scale_factor, + }, + ); + } + + // Only remove the render-world data if we didn't handle the node above. + // It's possible that a relevant component was removed and added in the same + // frame. + for main_entity in removed_computed_node_query + .read() + .chain(removed_computed_stack_index_query.read()) + .chain(removed_ui_global_transform_query.read()) + .chain(removed_inherited_visibility_query.read()) + .chain(removed_calculated_clip_query.read()) + .chain(removed_computed_ui_target_camera_query.read()) + .chain(removed_image_node_query.read()) + { + let main_entity = MainEntity::from(main_entity); + if nodes_processed_this_frame.contains(&main_entity) { + continue; + } + let Some(mut extracted_nodes) = extracted_ui_slicers.slices.remove(&main_entity) else { + continue; + }; + for (render_entity, _) in extracted_nodes.drain(..) { + commands.entity(render_entity).despawn(); + } } } @@ -322,40 +394,42 @@ pub fn queue_ui_slices( draw_functions: Res>, ) { let draw_function = draw_functions.read().id::(); - for (index, extracted_slicer) in extracted_ui_slicers.slices.iter().enumerate() { - let Ok(default_camera_view) = - render_views.get_mut(extracted_slicer.extracted_camera_entity) - else { - continue; - }; - - let Ok(view) = camera_views.get(default_camera_view.0) else { - continue; - }; - - let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) - else { - continue; - }; - - let pipeline = pipelines.specialize( - &pipeline_cache, - &ui_slicer_pipeline, - UiTextureSlicePipelineKey { - target_format: view.target_format, - }, - ); - - transparent_phase.add_transient(TransparentUi { - draw_function, - pipeline, - entity: (extracted_slicer.render_entity, extracted_slicer.main_entity), - sort_key: FloatOrd(extracted_slicer.stack_index as f32 + stack_z_offsets::IMAGE), - batch_range: 0..0, - extra_index: PhaseItemExtraIndex::None, - index, - indexed: true, - }); + for (main_entity, subslices) in extracted_ui_slicers.slices.iter() { + for (render_entity, extracted_slicer) in subslices.iter() { + let Ok(default_camera_view) = + render_views.get_mut(extracted_slicer.extracted_camera_entity) + else { + continue; + }; + + let Ok(view) = camera_views.get(default_camera_view.0) else { + continue; + }; + + let Some(transparent_phase) = + transparent_render_phases.get_mut(&view.retained_view_entity) + else { + continue; + }; + + let pipeline = pipelines.specialize( + &pipeline_cache, + &ui_slicer_pipeline, + UiTextureSlicePipelineKey { + target_format: view.target_format, + }, + ); + + transparent_phase.add_transient(TransparentUi { + draw_function, + pipeline, + entity: (*render_entity, *main_entity), + sort_key: FloatOrd(extracted_slicer.stack_index as f32 + stack_z_offsets::IMAGE), + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + indexed: true, + }); + } } } @@ -365,7 +439,7 @@ pub fn prepare_ui_slices( render_queue: Res, pipeline_cache: Res, mut ui_meta: ResMut, - mut extracted_slices: ResMut, + extracted_slices: Res, view_uniforms: Res, texture_slicer_pipeline: Res, mut image_bind_groups: ResMut, @@ -411,9 +485,13 @@ pub fn prepare_ui_slices( let item = &mut ui_phase.items[item_index]; if let Some(texture_slices) = extracted_slices .slices - .get(item.index) - .filter(|n| item.entity() == n.render_entity) + .get(&item.main_entity()) + .and_then(|subslices| subslices.get(&item.entity())) { + // Initialize the batch range to be zero-length initially. + // We'll extend it as we accumulate items into this batch. + item.batch_range = (item_index as u32)..(item_index as u32); + let mut existing_batch = batches.last_mut(); if batch_image_handle.is_none() @@ -633,7 +711,6 @@ pub fn prepare_ui_slices( *previous_len = batches.len(); commands.try_insert_batch(batches); } - extracted_slices.slices.clear(); } pub type DrawUiTextureSlices = ( From eb0064386ea5b5021cf193799aa2da0aa55d1f0d Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Mon, 6 Jul 2026 13:29:15 -0700 Subject: [PATCH 2/6] Fix camera entity --- crates/bevy_ui_render/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index e86879588dfa6..9896823bd3d3a 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -1161,7 +1161,7 @@ pub fn extract_ui_camera_view( cached_ui_view_data.insert( main_entity, CachedUiViewData { - extracted_view_entity: entity_commands.id(), + extracted_view_entity: ui_camera_view, retained_view_entity, }, ); From e7ed4bcbd54fcd38116c31e359ca05dd0c33c65a Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Mon, 6 Jul 2026 13:31:35 -0700 Subject: [PATCH 3/6] Fix doc link --- crates/bevy_ui/src/widget/image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ui/src/widget/image.rs b/crates/bevy_ui/src/widget/image.rs index 8ecff4e03406f..656494cd800d4 100644 --- a/crates/bevy_ui/src/widget/image.rs +++ b/crates/bevy_ui/src/widget/image.rs @@ -269,7 +269,7 @@ impl ImageNodeSize { /// The texture atlas layout, if the image has one. /// -/// The [`update_texture_atlas_layout_component`] system automatically keeps +/// The [`update_texture_atlas_layout_components`] system automatically keeps /// this component up to date based on [`ImageNode::texture_atlas`]. Don't /// update this component yourself; [`ImageNode::texture_atlas`] is the source /// of truth. From f35bf4c1124e3b9ae4e0959dc7e30695a2a07fd5 Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Mon, 6 Jul 2026 21:33:20 -0700 Subject: [PATCH 4/6] Fix view leak --- crates/bevy_ui_render/src/lib.rs | 49 +++++++++++++++++--------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index 9896823bd3d3a..c81d2adebc972 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -1121,29 +1121,32 @@ pub fn extract_ui_camera_view( // We use `UI_CAMERA_SUBVIEW` here so as not to conflict with the // main 3D or 2D camera, which will have subview index 0. // Creates the UI view. - let ui_camera_view = commands - .spawn(( - ExtractedView { - retained_view_entity, - clip_from_view: projection_matrix, - world_from_view: GlobalTransform::from_xyz( - 0.0, - 0.0, - UI_CAMERA_FAR + UI_CAMERA_TRANSFORM_OFFSET, - ), - clip_from_world: None, - target_format, - viewport: UVec4::from(( - physical_viewport_rect.min, - physical_viewport_rect.size(), - )), - color_grading: Default::default(), - invert_culling: false, - }, - // Link to the main camera view. - UiViewTarget(render_entity), - )) - .id(); + let extracted_view = ExtractedView { + retained_view_entity, + clip_from_view: projection_matrix, + world_from_view: GlobalTransform::from_xyz( + 0.0, + 0.0, + UI_CAMERA_FAR + UI_CAMERA_TRANSFORM_OFFSET, + ), + clip_from_world: None, + target_format, + viewport: UVec4::from((physical_viewport_rect.min, physical_viewport_rect.size())), + color_grading: Default::default(), + invert_culling: false, + }; + // Link to the main camera view. + let ui_view_target_component = UiViewTarget(render_entity); + + let ui_camera_view = match cached_ui_view_data.get(&main_entity) { + Some(cached_ui_view_data) => commands + .entity(cached_ui_view_data.extracted_view_entity) + .insert((extracted_view, ui_view_target_component)) + .id(), + None => commands + .spawn((extracted_view, ui_view_target_component)) + .id(), + }; let mut entity_commands = commands .get_entity(render_entity) From 5d8b1ad9fc8b5fa3152e457d49466c2c66f8d4d9 Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Tue, 7 Jul 2026 08:13:00 -0700 Subject: [PATCH 5/6] Fix failing test --- crates/bevy_ui/src/update.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index 4647dc0ee69a0..fa8451f08e09d 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -12,7 +12,8 @@ use bevy_app::Propagate; use bevy_camera::Camera; use bevy_ecs::{ entity::Entity, - query::Has, + hierarchy::ChildOf, + query::{Has, With, Without}, system::{Commands, Query, Res}, }; use bevy_math::{Rect, UVec2}; @@ -118,11 +119,14 @@ pub fn propagate_ui_target_cameras( ui_scale: Res, camera_query: Query<&Camera>, target_camera_query: Query<&UiTargetCamera>, - ui_root_nodes: Query<( - Entity, - Option<&Propagate>, - Option<&Propagate>, - )>, + ui_root_nodes: Query< + ( + Entity, + Option<&Propagate>, + Option<&Propagate>, + ), + (With, Without), + >, ) { let default_camera_entity = default_ui_camera.get(); From c46a27592f8845caecf2a35026df8f95f8ac1d3e Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Tue, 7 Jul 2026 08:26:12 -0700 Subject: [PATCH 6/6] Include a separate query for changed text spans in `extract_uinode_changes` --- crates/bevy_ui_render/src/lib.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index c81d2adebc972..ff318621489a5 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -452,7 +452,23 @@ pub fn extract_uinode_changes( ), >, >, - text_span_query: Extract>>, + text_span_query: Extract< + Query< + Entity, + ( + With, + Or<( + Changed, + Changed, + Changed, + Changed, + Changed, + Changed, + )>, + ), + >, + >, + text_span_parent_query: Extract>>, text_query: Extract>>, ( mut removed_computed_node_query, @@ -517,6 +533,7 @@ pub fn extract_uinode_changes( // data associated with them. for main_entity in uinode_query .iter() + .chain(text_span_query.iter()) .chain(removed_computed_node_query.read()) .chain(removed_computed_stack_index_query.read()) .chain(removed_ui_global_transform_query.read()) @@ -544,7 +561,7 @@ pub fn extract_uinode_changes( process_changed_entity( main_entity.into(), &mut commands, - &text_span_query, + &text_span_parent_query, &text_query, &mut extracted_uinodes, Some(&mut extra_nodes_to_invalidate), @@ -555,7 +572,7 @@ pub fn extract_uinode_changes( process_changed_entity( main_entity, &mut commands, - &text_span_query, + &text_span_parent_query, &text_query, &mut extracted_uinodes, None, @@ -565,7 +582,7 @@ pub fn extract_uinode_changes( fn process_changed_entity( mut main_entity: MainEntity, commands: &mut Commands, - text_span_query: &Query<&ChildOf, With>, + text_span_parent_query: &Query<&ChildOf, With>, text_query: &Query>, extracted_uinodes: &mut ExtractedUiNodes, maybe_extra_nodes_to_invalidate: Option<&mut MainEntityHashSet>, @@ -586,7 +603,7 @@ pub fn extract_uinode_changes( // `uinode_query` that it's iterating over matched the ancestor `Text` // node. if let Some(extra_nodes_to_invalidate) = maybe_extra_nodes_to_invalidate - && let Ok(parent) = text_span_query.get(main_entity.entity()) + && let Ok(parent) = text_span_parent_query.get(main_entity.entity()) { main_entity = parent.parent().into(); loop { @@ -594,7 +611,7 @@ pub fn extract_uinode_changes( extra_nodes_to_invalidate.insert(main_entity); break; } - match text_span_query.get(main_entity.entity()) { + match text_span_parent_query.get(main_entity.entity()) { Ok(parent) => main_entity = parent.parent().into(), Err(_) => break, }