From 4b5a33d970c50b5e1af3d31bebd26809988ca444 Mon Sep 17 00:00:00 2001 From: Gabriel Bourgeois Date: Wed, 2 Nov 2022 22:06:04 +0000 Subject: [PATCH] Add z-index support with a predictable UI stack (#5877) # Objective Add consistent UI rendering and interaction where deep nodes inside two different hierarchies will never render on top of one-another by default and offer an escape hatch (z-index) for nodes to change their depth. ## The problem with current implementation The current implementation of UI rendering is broken in that regard, mainly because [it sets the Z value of the `Transform` component based on a "global Z" space](https://github.com/bevyengine/bevy/blob/main/crates/bevy_ui/src/update.rs#L43) shared by all nodes in the UI. This doesn't account for the fact that each node's final `GlobalTransform` value will be relative to its parent. This effectively makes the depth unpredictable when two deep trees are rendered on top of one-another. At the moment, it's also up to each part of the UI code to sort all of the UI nodes. The solution that's offered here does the full sorting of UI node entities once and offers the result through a resource so that all systems can use it. ## Solution ### New ZIndex component This adds a new optional `ZIndex` enum component for nodes which offers two mechanism: - `ZIndex::Local(i32)`: Overrides the depth of the node relative to its siblings. - `ZIndex::Global(i32)`: Overrides the depth of the node relative to the UI root. This basically allows any node in the tree to "escape" the parent and be ordered relative to the entire UI. Note that in the current implementation, omitting `ZIndex` on a node has the same result as adding `ZIndex::Local(0)`. Additionally, the "global" stacking context is essentially a way to add your node to the root stacking context, so using `ZIndex::Local(n)` on a root node (one without parent) will share that space with all nodes using `Index::Global(n)`. ### New UiStack resource This adds a new `UiStack` resource which is calculated from both hierarchy and `ZIndex` during UI update and contains a vector of all node entities in the UI, ordered by depth (from farthest from camera to closest). This is exposed publicly by the bevy_ui crate with the hope that it can be used for consistent ordering and to reduce the amount of sorting that needs to be done by UI systems (i.e. instead of sorting everything by `global_transform.z` in every system, this array can be iterated over). ### New z_index example This also adds a new z_index example that showcases the new `ZIndex` component. It's also a good general demo of the new UI stack system, because making this kind of UI was very broken with the old system (e.g. nodes would render on top of each other, not respecting hierarchy or insert order at all). ![image](https://user-images.githubusercontent.com/1060971/189015985-8ea8f989-0e9d-4601-a7e0-4a27a43a53f9.png) --- ## Changelog - Added the `ZIndex` component to bevy_ui. - Added the `UiStack` resource to bevy_ui, and added implementation in a new `stack.rs` module. - Removed the previous Z updating system from bevy_ui, because it was replaced with the above. - Changed bevy_ui rendering to use UiStack instead of z ordering. - Changed bevy_ui focus/interaction system to use UiStack instead of z ordering. - Added a new z_index example. ## ZIndex demo Here's a demo I wrote to test these features https://user-images.githubusercontent.com/1060971/188329295-d7beebd6-9aee-43ab-821e-d437df5dbe8a.mp4 Co-authored-by: Carter Anderson --- Cargo.toml | 10 ++ crates/bevy_ui/src/entity.rs | 13 +- crates/bevy_ui/src/focus.rs | 94 +++++++------ crates/bevy_ui/src/lib.rs | 12 +- crates/bevy_ui/src/render/mod.rs | 154 ++++++++++++---------- crates/bevy_ui/src/stack.rs | 220 +++++++++++++++++++++++++++++++ crates/bevy_ui/src/ui_node.rs | 28 ++++ crates/bevy_ui/src/update.rs | 166 +---------------------- examples/README.md | 1 + examples/ui/z_index.rs | 123 +++++++++++++++++ 10 files changed, 539 insertions(+), 282 deletions(-) create mode 100644 crates/bevy_ui/src/stack.rs create mode 100644 examples/ui/z_index.rs diff --git a/Cargo.toml b/Cargo.toml index 7f2d1fd25317b..3226a40152643 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1462,6 +1462,16 @@ description = "Demonstrates transparency for UI" category = "UI (User Interface)" wasm = true +[[example]] +name = "z_index" +path = "examples/ui/z_index.rs" + +[package.metadata.example.z_index] +name = "UI Z-Index" +description = "Demonstrates how to control the relative depth (z-position) of UI elements" +category = "UI (User Interface)" +wasm = true + [[example]] name = "ui" path = "examples/ui/ui.rs" diff --git a/crates/bevy_ui/src/entity.rs b/crates/bevy_ui/src/entity.rs index abb73fae70ec2..1e3491ad14d27 100644 --- a/crates/bevy_ui/src/entity.rs +++ b/crates/bevy_ui/src/entity.rs @@ -2,7 +2,7 @@ use crate::{ widget::{Button, ImageMode}, - BackgroundColor, CalculatedSize, FocusPolicy, Interaction, Node, Style, UiImage, + BackgroundColor, CalculatedSize, FocusPolicy, Interaction, Node, Style, UiImage, ZIndex, }; use bevy_ecs::{ bundle::Bundle, @@ -45,6 +45,8 @@ pub struct NodeBundle { pub visibility: Visibility, /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering pub computed_visibility: ComputedVisibility, + /// Indicates the depth at which the node should appear in the UI + pub z_index: ZIndex, } impl Default for NodeBundle { @@ -60,6 +62,7 @@ impl Default for NodeBundle { global_transform: Default::default(), visibility: Default::default(), computed_visibility: Default::default(), + z_index: Default::default(), } } } @@ -97,6 +100,8 @@ pub struct ImageBundle { pub visibility: Visibility, /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering pub computed_visibility: ComputedVisibility, + /// Indicates the depth at which the node should appear in the UI + pub z_index: ZIndex, } /// A UI node that is text @@ -126,6 +131,8 @@ pub struct TextBundle { pub visibility: Visibility, /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering pub computed_visibility: ComputedVisibility, + /// Indicates the depth at which the node should appear in the UI + pub z_index: ZIndex, } impl TextBundle { @@ -174,6 +181,7 @@ impl Default for TextBundle { global_transform: Default::default(), visibility: Default::default(), computed_visibility: Default::default(), + z_index: Default::default(), } } } @@ -211,6 +219,8 @@ pub struct ButtonBundle { pub visibility: Visibility, /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering pub computed_visibility: ComputedVisibility, + /// Indicates the depth at which the node should appear in the UI + pub z_index: ZIndex, } impl Default for ButtonBundle { @@ -227,6 +237,7 @@ impl Default for ButtonBundle { global_transform: Default::default(), visibility: Default::default(), computed_visibility: Default::default(), + z_index: Default::default(), } } } diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index 44ea324a12e90..423711d6e39ff 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -1,7 +1,8 @@ -use crate::{entity::UiCameraConfig, CalculatedClip, Node}; +use crate::{entity::UiCameraConfig, CalculatedClip, Node, UiStack}; use bevy_ecs::{ entity::Entity, prelude::Component, + query::WorldQuery, reflect::ReflectComponent, system::{Local, Query, Res}, }; @@ -11,7 +12,6 @@ use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; use bevy_render::camera::{Camera, RenderTarget}; use bevy_render::view::ComputedVisibility; use bevy_transform::components::GlobalTransform; -use bevy_utils::FloatOrd; use bevy_window::Windows; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; @@ -62,6 +62,19 @@ pub struct State { entities_to_reset: SmallVec<[Entity; 1]>, } +/// Main query for [`ui_focus_system`] +#[derive(WorldQuery)] +#[world_query(mutable)] +pub struct NodeQuery { + entity: Entity, + node: &'static Node, + global_transform: &'static GlobalTransform, + interaction: Option<&'static mut Interaction>, + focus_policy: Option<&'static FocusPolicy>, + calculated_clip: Option<&'static CalculatedClip>, + computed_visibility: Option<&'static ComputedVisibility>, +} + /// The system that sets Interaction for all UI elements based on the mouse cursor activity /// /// Entities with a hidden [`ComputedVisibility`] are always treated as released. @@ -71,15 +84,8 @@ pub fn ui_focus_system( windows: Res, mouse_button_input: Res>, touches_input: Res, - mut node_query: Query<( - Entity, - &Node, - &GlobalTransform, - Option<&mut Interaction>, - Option<&FocusPolicy>, - Option<&CalculatedClip>, - Option<&ComputedVisibility>, - )>, + ui_stack: Res, + mut node_query: Query, ) { // reset entities that were both clicked and released in the last frame for entity in state.entities_to_reset.drain(..) { @@ -91,10 +97,8 @@ pub fn ui_focus_system( let mouse_released = mouse_button_input.just_released(MouseButton::Left) || touches_input.any_just_released(); if mouse_released { - for (_entity, _node, _global_transform, interaction, _focus_policy, _clip, _visibility) in - node_query.iter_mut() - { - if let Some(mut interaction) = interaction { + for node in node_query.iter_mut() { + if let Some(mut interaction) = node.interaction { if *interaction == Interaction::Clicked { *interaction = Interaction::None; } @@ -123,15 +127,21 @@ pub fn ui_focus_system( .find_map(|window| window.cursor_position()) .or_else(|| touches_input.first_pressed_position()); - let mut moused_over_z_sorted_nodes = node_query - .iter_mut() - .filter_map( - |(entity, node, global_transform, interaction, focus_policy, clip, visibility)| { + // prepare an iterator that contains all the nodes that have the cursor in their rect, + // from the top node to the bottom one. this will also reset the interaction to `None` + // for all nodes encountered that are no longer hovered. + let mut moused_over_nodes = ui_stack + .uinodes + .iter() + // reverse the iterator to traverse the tree from closest nodes to furthest + .rev() + .filter_map(|entity| { + if let Ok(node) = node_query.get_mut(*entity) { // Nodes that are not rendered should not be interactable - if let Some(computed_visibility) = visibility { + if let Some(computed_visibility) = node.computed_visibility { if !computed_visibility.is_visible() { // Reset their interaction to None to avoid strange stuck state - if let Some(mut interaction) = interaction { + if let Some(mut interaction) = node.interaction { // We cannot simply set the interaction to None, as that will trigger change detection repeatedly if *interaction != Interaction::None { *interaction = Interaction::None; @@ -142,12 +152,12 @@ pub fn ui_focus_system( } } - let position = global_transform.translation(); + let position = node.global_transform.translation(); let ui_position = position.truncate(); - let extents = node.calculated_size / 2.0; + let extents = node.node.size() / 2.0; let mut min = ui_position - extents; let mut max = ui_position + extents; - if let Some(clip) = clip { + if let Some(clip) = node.calculated_clip { min = Vec2::max(min, clip.clip.min); max = Vec2::min(max, clip.clip.max); } @@ -161,9 +171,9 @@ pub fn ui_focus_system( }; if contains_cursor { - Some((entity, focus_policy, interaction, FloatOrd(position.z))) + Some(*entity) } else { - if let Some(mut interaction) = interaction { + if let Some(mut interaction) = node.interaction { if *interaction == Interaction::Hovered || (cursor_position.is_none() && *interaction != Interaction::None) { @@ -172,16 +182,18 @@ pub fn ui_focus_system( } None } - }, - ) - .collect::>(); - - moused_over_z_sorted_nodes.sort_by_key(|(_, _, _, z)| -*z); + } else { + None + } + }) + .collect::>() + .into_iter(); - let mut moused_over_z_sorted_nodes = moused_over_z_sorted_nodes.into_iter(); - // set Clicked or Hovered on top nodes - for (entity, focus_policy, interaction, _) in moused_over_z_sorted_nodes.by_ref() { - if let Some(mut interaction) = interaction { + // set Clicked or Hovered on top nodes. as soon as a node with a `Block` focus policy is detected, + // the iteration will stop on it because it "captures" the interaction. + let mut iter = node_query.iter_many_mut(moused_over_nodes.by_ref()); + while let Some(node) = iter.fetch_next() { + if let Some(mut interaction) = node.interaction { if mouse_clicked { // only consider nodes with Interaction "clickable" if *interaction != Interaction::Clicked { @@ -189,7 +201,7 @@ pub fn ui_focus_system( // if the mouse was simultaneously released, reset this Interaction in the next // frame if mouse_released { - state.entities_to_reset.push(entity); + state.entities_to_reset.push(node.entity); } } } else if *interaction == Interaction::None { @@ -197,16 +209,18 @@ pub fn ui_focus_system( } } - match focus_policy.cloned().unwrap_or(FocusPolicy::Block) { + match node.focus_policy.unwrap_or(&FocusPolicy::Block) { FocusPolicy::Block => { break; } FocusPolicy::Pass => { /* allow the next node to be hovered/clicked */ } } } - // reset lower nodes to None - for (_entity, _focus_policy, interaction, _) in moused_over_z_sorted_nodes { - if let Some(mut interaction) = interaction { + // reset `Interaction` for the remaining lower nodes to `None`. those are the nodes that remain in + // `moused_over_nodes` after the previous loop is exited. + let mut iter = node_query.iter_many_mut(moused_over_nodes); + while let Some(node) = iter.fetch_next() { + if let Some(mut interaction) = node.interaction { // don't reset clicked nodes because they're handled separately if *interaction != Interaction::Clicked && *interaction != Interaction::None { *interaction = Interaction::None; diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index e1c73540c3ac0..39fae6f1761bf 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -6,6 +6,7 @@ mod flex; mod focus; mod geometry; mod render; +mod stack; mod ui_node; pub mod entity; @@ -33,7 +34,9 @@ use bevy_ecs::{ use bevy_input::InputSystem; use bevy_transform::TransformSystem; use bevy_window::ModifiesWindows; -use update::{ui_z_system, update_clipping_system}; +use stack::ui_stack_system; +pub use stack::UiStack; +use update::update_clipping_system; use crate::prelude::UiCameraConfig; @@ -48,6 +51,8 @@ pub enum UiSystem { Flex, /// After this label, input interactions with UI entities have been updated for this frame Focus, + /// After this label, the [`UiStack`] resource has been updated + Stack, } /// The current scale of the UI. @@ -71,6 +76,7 @@ impl Plugin for UiPlugin { app.add_plugin(ExtractComponentPlugin::::default()) .init_resource::() .init_resource::() + .init_resource::() .register_type::() .register_type::() .register_type::() @@ -135,9 +141,7 @@ impl Plugin for UiPlugin { ) .add_system_to_stage( CoreStage::PostUpdate, - ui_z_system - .after(UiSystem::Flex) - .before(TransformSystem::TransformPropagate), + ui_stack_system.label(UiSystem::Stack), ) .add_system_to_stage( CoreStage::PostUpdate, diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 9ef419e6bc82b..51418b6b448c4 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -5,7 +5,7 @@ use bevy_core_pipeline::{core_2d::Camera2d, core_3d::Camera3d}; pub use pipeline::*; pub use render_pass::*; -use crate::{prelude::UiCameraConfig, BackgroundColor, CalculatedClip, Node, UiImage}; +use crate::{prelude::UiCameraConfig, BackgroundColor, CalculatedClip, Node, UiImage, UiStack}; use bevy_app::prelude::*; use bevy_asset::{load_internal_asset, AssetEvent, Assets, Handle, HandleUntyped}; use bevy_ecs::prelude::*; @@ -184,6 +184,7 @@ fn get_ui_graph(render_app: &mut App) -> RenderGraph { } pub struct ExtractedUiNode { + pub stack_index: usize, pub transform: Mat4, pub background_color: Color, pub rect: Rect, @@ -201,6 +202,7 @@ pub struct ExtractedUiNodes { pub fn extract_uinodes( mut extracted_uinodes: ResMut, images: Extract>>, + ui_stack: Extract>, windows: Extract>, uinode_query: Extract< Query<( @@ -215,31 +217,34 @@ pub fn extract_uinodes( ) { let scale_factor = windows.scale_factor(WindowId::primary()) as f32; extracted_uinodes.uinodes.clear(); - for (uinode, transform, color, image, visibility, clip) in uinode_query.iter() { - if !visibility.is_visible() { - continue; - } - let image = image.0.clone_weak(); - // Skip loading images - if !images.contains(&image) { - continue; - } - // Skip completely transparent nodes - if color.0.a() == 0.0 { - continue; + for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() { + if let Ok((uinode, transform, color, image, visibility, clip)) = uinode_query.get(*entity) { + if !visibility.is_visible() { + continue; + } + let image = image.0.clone_weak(); + // Skip loading images + if !images.contains(&image) { + continue; + } + // Skip completely transparent nodes + if color.0.a() == 0.0 { + continue; + } + extracted_uinodes.uinodes.push(ExtractedUiNode { + stack_index, + transform: transform.compute_matrix(), + background_color: color.0, + rect: Rect { + min: Vec2::ZERO, + max: uinode.calculated_size, + }, + image, + atlas_size: None, + clip: clip.map(|clip| clip.clip), + scale_factor, + }); } - extracted_uinodes.uinodes.push(ExtractedUiNode { - transform: transform.compute_matrix(), - background_color: color.0, - rect: Rect { - min: Vec2::ZERO, - max: uinode.calculated_size, - }, - image, - atlas_size: None, - clip: clip.map(|clip| clip.clip), - scale_factor, - }); } } @@ -303,6 +308,7 @@ pub fn extract_text_uinodes( mut extracted_uinodes: ResMut, texture_atlases: Extract>>, windows: Extract>, + ui_stack: Extract>, uinode_query: Extract< Query<( &Node, @@ -315,52 +321,56 @@ pub fn extract_text_uinodes( >, ) { let scale_factor = windows.scale_factor(WindowId::primary()) as f32; - for (uinode, global_transform, text, text_layout_info, visibility, clip) in uinode_query.iter() - { - if !visibility.is_visible() { - continue; - } - // Skip if size is set to zero (e.g. when a parent is set to `Display::None`) - if uinode.calculated_size == Vec2::ZERO { - continue; - } - let text_glyphs = &text_layout_info.glyphs; - let alignment_offset = (uinode.calculated_size / -2.0).extend(0.0); - - let mut color = Color::WHITE; - let mut current_section = usize::MAX; - for text_glyph in text_glyphs { - if text_glyph.section_index != current_section { - color = text.sections[text_glyph.section_index] - .style - .color - .as_rgba_linear(); - current_section = text_glyph.section_index; + for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() { + if let Ok((uinode, global_transform, text, text_layout_info, visibility, clip)) = + uinode_query.get(*entity) + { + if !visibility.is_visible() { + continue; + } + // Skip if size is set to zero (e.g. when a parent is set to `Display::None`) + if uinode.size() == Vec2::ZERO { + continue; + } + let text_glyphs = &text_layout_info.glyphs; + let alignment_offset = (uinode.size() / -2.0).extend(0.0); + + let mut color = Color::WHITE; + let mut current_section = usize::MAX; + for text_glyph in text_glyphs { + if text_glyph.section_index != current_section { + color = text.sections[text_glyph.section_index] + .style + .color + .as_rgba_linear(); + current_section = text_glyph.section_index; + } + let atlas = texture_atlases + .get(&text_glyph.atlas_info.texture_atlas) + .unwrap(); + let texture = atlas.texture.clone_weak(); + let index = text_glyph.atlas_info.glyph_index as usize; + let rect = atlas.textures[index]; + let atlas_size = Some(atlas.size); + + // NOTE: Should match `bevy_text::text2d::extract_text2d_sprite` + let extracted_transform = global_transform.compute_matrix() + * Mat4::from_scale(Vec3::splat(scale_factor.recip())) + * Mat4::from_translation( + alignment_offset * scale_factor + text_glyph.position.extend(0.), + ); + + extracted_uinodes.uinodes.push(ExtractedUiNode { + stack_index, + transform: extracted_transform, + background_color: color, + rect, + image: texture, + atlas_size, + clip: clip.map(|clip| clip.clip), + scale_factor, + }); } - let atlas = texture_atlases - .get(&text_glyph.atlas_info.texture_atlas) - .unwrap(); - let texture = atlas.texture.clone_weak(); - let index = text_glyph.atlas_info.glyph_index; - let rect = atlas.textures[index]; - let atlas_size = Some(atlas.size); - - // NOTE: Should match `bevy_text::text2d::extract_text2d_sprite` - let extracted_transform = global_transform.compute_matrix() - * Mat4::from_scale(Vec3::splat(scale_factor.recip())) - * Mat4::from_translation( - alignment_offset * scale_factor + text_glyph.position.extend(0.), - ); - - extracted_uinodes.uinodes.push(ExtractedUiNode { - transform: extracted_transform, - background_color: color, - rect, - image: texture, - atlas_size, - clip: clip.map(|clip| clip.clip), - scale_factor, - }); } } } @@ -413,10 +423,10 @@ pub fn prepare_uinodes( ) { ui_meta.vertices.clear(); - // sort by increasing z for correct transparency + // sort by ui stack index, starting from the deepest node extracted_uinodes .uinodes - .sort_by(|a, b| FloatOrd(a.transform.w_axis[2]).cmp(&FloatOrd(b.transform.w_axis[2]))); + .sort_by_key(|node| node.stack_index); let mut start = 0; let mut end = 0; diff --git a/crates/bevy_ui/src/stack.rs b/crates/bevy_ui/src/stack.rs new file mode 100644 index 0000000000000..4df23559b8d6a --- /dev/null +++ b/crates/bevy_ui/src/stack.rs @@ -0,0 +1,220 @@ +//! This module contains the systems that update the stored UI nodes stack + +use bevy_ecs::prelude::*; +use bevy_hierarchy::prelude::*; + +use crate::{Node, ZIndex}; + +/// The current UI stack, which contains all UI nodes ordered by their depth. +/// +/// The first entry is the furthest node from the camera and is the first one to get rendered +/// while the last entry is the first node to receive interactions. +#[derive(Debug, Resource, Default)] +pub struct UiStack { + pub uinodes: Vec, +} + +#[derive(Default)] +struct StackingContext { + pub entries: Vec, +} + +struct StackingContextEntry { + pub z_index: i32, + pub entity: Entity, + pub stack: StackingContext, +} + +/// Generates the render stack for UI nodes. +pub fn ui_stack_system( + mut ui_stack: ResMut, + root_node_query: Query, Without)>, + zindex_query: Query<&ZIndex, With>, + children_query: Query<&Children>, +) { + let mut global_context = StackingContext::default(); + + let mut total_entry_count: usize = 0; + for entity in &root_node_query { + insert_context_hierarchy( + &zindex_query, + &children_query, + entity, + &mut global_context, + None, + &mut total_entry_count, + ); + } + + ui_stack.uinodes.clear(); + ui_stack.uinodes.reserve(total_entry_count); + fill_stack_recursively(&mut ui_stack.uinodes, &mut global_context); +} + +fn insert_context_hierarchy( + zindex_query: &Query<&ZIndex, With>, + children_query: &Query<&Children>, + entity: Entity, + global_context: &mut StackingContext, + parent_context: Option<&mut StackingContext>, + total_entry_count: &mut usize, +) { + let mut new_context = StackingContext::default(); + if let Ok(children) = children_query.get(entity) { + // reserve space for all children. in practice, some may not get pushed. + new_context.entries.reserve_exact(children.len()); + + for entity in children { + insert_context_hierarchy( + zindex_query, + children_query, + *entity, + global_context, + Some(&mut new_context), + total_entry_count, + ); + } + } + + let z_index = zindex_query.get(entity).unwrap_or(&ZIndex::Local(0)); + let (entity_context, z_index) = match z_index { + ZIndex::Local(value) => (parent_context.unwrap_or(global_context), *value), + ZIndex::Global(value) => (global_context, *value), + }; + + *total_entry_count += 1; + entity_context.entries.push(StackingContextEntry { + z_index, + entity, + stack: new_context, + }); +} + +fn fill_stack_recursively(result: &mut Vec, stack: &mut StackingContext) { + // sort entries by ascending z_index, while ensuring that siblings + // with the same local z_index will keep their ordering. + stack.entries.sort_by_key(|e| e.z_index); + + for entry in &mut stack.entries { + result.push(entry.entity); + fill_stack_recursively(result, &mut entry.stack); + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::{ + component::Component, + schedule::{Schedule, Stage, SystemStage}, + system::{CommandQueue, Commands}, + world::World, + }; + use bevy_hierarchy::BuildChildren; + + use crate::{Node, UiStack, ZIndex}; + + use super::ui_stack_system; + + #[derive(Component, PartialEq, Debug, Clone)] + struct Label(&'static str); + + fn node_with_zindex(name: &'static str, z_index: ZIndex) -> (Label, Node, ZIndex) { + (Label(name), Node::default(), z_index) + } + + fn node_without_zindex(name: &'static str) -> (Label, Node) { + (Label(name), Node::default()) + } + + /// Tests the UI Stack system. + /// + /// This tests for siblings default ordering according to their insertion order, but it + /// can't test the same thing for UI roots. UI roots having no parents, they do not have + /// a stable ordering that we can test against. If we test it, it may pass now and start + /// failing randomly in the future because of some unrelated `bevy_ecs` change. + #[test] + fn test_ui_stack_system() { + let mut world = World::default(); + world.init_resource::(); + + let mut queue = CommandQueue::default(); + let mut commands = Commands::new(&mut queue, &world); + commands.spawn(node_with_zindex("0", ZIndex::Global(2))); + + commands + .spawn(node_with_zindex("1", ZIndex::Local(1))) + .with_children(|parent| { + parent + .spawn(node_without_zindex("1-0")) + .with_children(|parent| { + parent.spawn(node_without_zindex("1-0-0")); + parent.spawn(node_without_zindex("1-0-1")); + parent.spawn(node_with_zindex("1-0-2", ZIndex::Local(-1))); + }); + parent.spawn(node_without_zindex("1-1")); + parent + .spawn(node_with_zindex("1-2", ZIndex::Global(-1))) + .with_children(|parent| { + parent.spawn(node_without_zindex("1-2-0")); + parent.spawn(node_with_zindex("1-2-1", ZIndex::Global(-3))); + parent + .spawn(node_without_zindex("1-2-2")) + .with_children(|_| ()); + parent.spawn(node_without_zindex("1-2-3")); + }); + parent.spawn(node_without_zindex("1-3")); + }); + + commands + .spawn(node_without_zindex("2")) + .with_children(|parent| { + parent + .spawn(node_without_zindex("2-0")) + .with_children(|_parent| ()); + parent + .spawn(node_without_zindex("2-1")) + .with_children(|parent| { + parent.spawn(node_without_zindex("2-1-0")); + }); + }); + + commands.spawn(node_with_zindex("3", ZIndex::Global(-2))); + + queue.apply(&mut world); + + let mut schedule = Schedule::default(); + let mut update_stage = SystemStage::parallel(); + update_stage.add_system(ui_stack_system); + schedule.add_stage("update", update_stage); + schedule.run(&mut world); + + let mut query = world.query::<&Label>(); + let ui_stack = world.resource::(); + let actual_result = ui_stack + .uinodes + .iter() + .map(|entity| query.get(&world, *entity).unwrap().clone()) + .collect::>(); + let expected_result = vec![ + (Label("1-2-1")), // ZIndex::Global(-3) + (Label("3")), // ZIndex::Global(-2) + (Label("1-2")), // ZIndex::Global(-1) + (Label("1-2-0")), + (Label("1-2-2")), + (Label("1-2-3")), + (Label("2")), + (Label("2-0")), + (Label("2-1")), + (Label("2-1-0")), + (Label("1")), // ZIndex::Local(1) + (Label("1-0")), + (Label("1-0-2")), // ZIndex::Local(-1) + (Label("1-0-0")), + (Label("1-0-1")), + (Label("1-1")), + (Label("1-3")), + (Label("0")), // ZIndex::Global(2) + ]; + assert_eq!(actual_result, expected_result); + } +} diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 501a1604160ed..af7aec01b4e92 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -479,6 +479,34 @@ pub struct CalculatedClip { pub clip: Rect, } +/// Indicates that this [`Node`] entity's front-to-back ordering is not controlled solely +/// by its location in the UI hierarchy. A node with a higher z-index will appear on top +/// of other nodes with a lower z-index. +/// +/// UI nodes that have the same z-index will appear according to the order in which they +/// appear in the UI hierarchy. In such a case, the last node to be added to its parent +/// will appear in front of this parent's other children. +/// +/// Internally, nodes with a global z-index share the stacking context of root UI nodes +/// (nodes that have no parent). Because of this, there is no difference between using +/// [`ZIndex::Local(n)`] and [`ZIndex::Global(n)`] for root nodes. +/// +/// Nodes without this component will be treated as if they had a value of [`ZIndex::Local(0)`]. +#[derive(Component, Copy, Clone, Debug, Reflect)] +pub enum ZIndex { + /// Indicates the order in which this node should be rendered relative to its siblings. + Local(i32), + /// Indicates the order in which this node should be rendered relative to root nodes and + /// all other nodes that have a global z-index. + Global(i32), +} + +impl Default for ZIndex { + fn default() -> Self { + Self::Local(0) + } +} + #[cfg(test)] mod tests { use crate::ValArithmeticError; diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index aac455bb16335..623add4a6a283 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -10,58 +10,7 @@ use bevy_ecs::{ }; use bevy_hierarchy::{Children, Parent}; use bevy_math::Rect; -use bevy_transform::components::{GlobalTransform, Transform}; - -/// The resolution of `Z` values for UI -pub const UI_Z_STEP: f32 = 0.001; - -/// Updates transforms of nodes to fit with the `Z` system -pub fn ui_z_system( - root_node_query: Query, Without)>, - mut node_query: Query<&mut Transform, With>, - children_query: Query<&Children>, -) { - let mut current_global_z = 0.0; - for entity in &root_node_query { - current_global_z = update_hierarchy( - &children_query, - &mut node_query, - entity, - current_global_z, - current_global_z, - ); - } -} - -fn update_hierarchy( - children_query: &Query<&Children>, - node_query: &mut Query<&mut Transform, With>, - entity: Entity, - parent_global_z: f32, - mut current_global_z: f32, -) -> f32 { - current_global_z += UI_Z_STEP; - if let Ok(mut transform) = node_query.get_mut(entity) { - let new_z = current_global_z - parent_global_z; - // only trigger change detection when the new value is different - if transform.translation.z != new_z { - transform.translation.z = new_z; - } - } - if let Ok(children) = children_query.get(entity) { - let current_parent_global_z = current_global_z; - for child in children.iter().cloned() { - current_global_z = update_hierarchy( - children_query, - node_query, - child, - current_parent_global_z, - current_global_z, - ); - } - } - current_global_z -} +use bevy_transform::components::GlobalTransform; /// Updates clipping for all nodes pub fn update_clipping_system( @@ -121,116 +70,3 @@ fn update_clipping( } } } - -#[cfg(test)] -mod tests { - use bevy_ecs::{ - component::Component, - schedule::{Schedule, Stage, StageLabel, SystemStage}, - system::{CommandQueue, Commands}, - world::World, - }; - use bevy_hierarchy::BuildChildren; - use bevy_transform::components::Transform; - - use crate::Node; - - use super::{ui_z_system, UI_Z_STEP}; - - #[derive(Component, PartialEq, Debug, Clone)] - struct Label(&'static str); - - fn node_with_transform(name: &'static str) -> (Label, Node, Transform) { - (Label(name), Node::default(), Transform::IDENTITY) - } - - fn node_without_transform(name: &'static str) -> (Label, Node) { - (Label(name), Node::default()) - } - - fn get_steps(transform: &Transform) -> u32 { - (transform.translation.z / UI_Z_STEP).round() as u32 - } - - #[derive(StageLabel)] - struct Update; - - #[test] - fn test_ui_z_system() { - let mut world = World::default(); - let mut queue = CommandQueue::default(); - let mut commands = Commands::new(&mut queue, &world); - commands.spawn(node_with_transform("0")); - - commands - .spawn(node_with_transform("1")) - .with_children(|parent| { - parent - .spawn(node_with_transform("1-0")) - .with_children(|parent| { - parent.spawn(node_with_transform("1-0-0")); - parent.spawn(node_without_transform("1-0-1")); - parent.spawn(node_with_transform("1-0-2")); - }); - parent.spawn(node_with_transform("1-1")); - parent - .spawn(node_without_transform("1-2")) - .with_children(|parent| { - parent.spawn(node_with_transform("1-2-0")); - parent.spawn(node_with_transform("1-2-1")); - parent - .spawn(node_with_transform("1-2-2")) - .with_children(|_| ()); - parent.spawn(node_with_transform("1-2-3")); - }); - parent.spawn(node_with_transform("1-3")); - }); - - commands - .spawn(node_without_transform("2")) - .with_children(|parent| { - parent - .spawn(node_with_transform("2-0")) - .with_children(|_parent| ()); - parent - .spawn(node_with_transform("2-1")) - .with_children(|parent| { - parent.spawn(node_with_transform("2-1-0")); - }); - }); - queue.apply(&mut world); - - let mut schedule = Schedule::default(); - let mut update_stage = SystemStage::parallel(); - update_stage.add_system(ui_z_system); - schedule.add_stage(Update, update_stage); - schedule.run(&mut world); - - let mut actual_result = world - .query::<(&Label, &Transform)>() - .iter(&world) - .map(|(name, transform)| (name.clone(), get_steps(transform))) - .collect::>(); - actual_result.sort_unstable_by_key(|(name, _)| name.0); - let expected_result = vec![ - (Label("0"), 1), - (Label("1"), 1), - (Label("1-0"), 1), - (Label("1-0-0"), 1), - // 1-0-1 has no transform - (Label("1-0-2"), 3), - (Label("1-1"), 5), - // 1-2 has no transform - (Label("1-2-0"), 1), - (Label("1-2-1"), 2), - (Label("1-2-2"), 3), - (Label("1-2-3"), 4), - (Label("1-3"), 11), - // 2 has no transform - (Label("2-0"), 1), - (Label("2-1"), 2), - (Label("2-1-0"), 1), - ]; - assert_eq!(actual_result, expected_result); - } -} diff --git a/examples/README.md b/examples/README.md index c2d00514e0c40..026197ead0d3e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -316,6 +316,7 @@ Example | Description [Transparency UI](../examples/ui/transparency_ui.rs) | Demonstrates transparency for UI [UI](../examples/ui/ui.rs) | Illustrates various features of Bevy UI [UI Scaling](../examples/ui/ui_scaling.rs) | Illustrates how to scale the UI +[UI Z-Index](../examples/ui/z_index.rs) | Demonstrates how to control the relative depth (z-position) of UI elements ## Window diff --git a/examples/ui/z_index.rs b/examples/ui/z_index.rs new file mode 100644 index 0000000000000..e469d2e30d107 --- /dev/null +++ b/examples/ui/z_index.rs @@ -0,0 +1,123 @@ +//! Demonstrates how to use the z-index component on UI nodes to control their relative depth +//! +//! It uses colored boxes with different z-index values to demonstrate how it can affect the order of +//! depth of nodes compared to their siblings, but also compared to the entire UI. + +use bevy::prelude::*; + +fn main() { + App::new() + .insert_resource(ClearColor(Color::BLACK)) + .add_plugins(DefaultPlugins) + .add_startup_system(setup) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2dBundle::default()); + + // spawn the container with default z-index. + // the default z-index value is `ZIndex::Local(0)`. + // because this is a root UI node, using local or global values will do the same thing. + commands + .spawn(NodeBundle { + background_color: Color::GRAY.into(), + style: Style { + size: Size::new(Val::Px(180.0), Val::Px(100.0)), + margin: UiRect::all(Val::Auto), + ..default() + }, + ..default() + }) + .with_children(|parent| { + // spawn a node with default z-index. + parent.spawn(NodeBundle { + background_color: Color::RED.into(), + style: Style { + position_type: PositionType::Absolute, + position: UiRect { + left: Val::Px(10.0), + bottom: Val::Px(40.0), + ..default() + }, + size: Size::new(Val::Px(100.0), Val::Px(50.0)), + ..default() + }, + ..default() + }); + + // spawn a node with a positive local z-index of 2. + // it will show above other nodes in the grey container. + parent.spawn(NodeBundle { + z_index: ZIndex::Local(2), + background_color: Color::BLUE.into(), + style: Style { + position_type: PositionType::Absolute, + position: UiRect { + left: Val::Px(45.0), + bottom: Val::Px(30.0), + ..default() + }, + size: Size::new(Val::Px(100.0), Val::Px(50.0)), + ..default() + }, + ..default() + }); + + // spawn a node with a negative local z-index. + // it will show under other nodes in the grey container. + parent.spawn(NodeBundle { + z_index: ZIndex::Local(-1), + background_color: Color::GREEN.into(), + style: Style { + position_type: PositionType::Absolute, + position: UiRect { + left: Val::Px(70.0), + bottom: Val::Px(20.0), + ..default() + }, + size: Size::new(Val::Px(100.0), Val::Px(75.0)), + ..default() + }, + ..default() + }); + + // spawn a node with a positive global z-index of 1. + // it will show above all other nodes, because it's the highest global z-index in this example. + // by default, boxes all share the global z-index of 0 that the grey container is added to. + parent.spawn(NodeBundle { + z_index: ZIndex::Global(1), + background_color: Color::PURPLE.into(), + style: Style { + position_type: PositionType::Absolute, + position: UiRect { + left: Val::Px(15.0), + bottom: Val::Px(10.0), + ..default() + }, + size: Size::new(Val::Px(100.0), Val::Px(60.0)), + ..default() + }, + ..default() + }); + + // spawn a node with a negative global z-index of -1. + // this will show under all other nodes including its parent, because it's the lowest global z-index + // in this example. + parent.spawn(NodeBundle { + z_index: ZIndex::Global(-1), + background_color: Color::YELLOW.into(), + style: Style { + position_type: PositionType::Absolute, + position: UiRect { + left: Val::Px(-15.0), + bottom: Val::Px(-15.0), + ..default() + }, + size: Size::new(Val::Px(100.0), Val::Px(125.0)), + ..default() + }, + ..default() + }); + }); +}