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 <mcanders1@gmail.com>
This commit is contained in:
Gabriel Bourgeois 2022-11-02 22:06:04 +00:00
parent 334e09892b
commit 4b5a33d970
10 changed files with 537 additions and 280 deletions

View file

@ -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"

View file

@ -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(),
}
}
}

View file

@ -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<Windows>,
mouse_button_input: Res<Input<MouseButton>>,
touches_input: Res<Touches>,
mut node_query: Query<(
Entity,
&Node,
&GlobalTransform,
Option<&mut Interaction>,
Option<&FocusPolicy>,
Option<&CalculatedClip>,
Option<&ComputedVisibility>,
)>,
ui_stack: Res<UiStack>,
mut node_query: Query<NodeQuery>,
) {
// 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::<Vec<_>>();
} else {
None
}
})
.collect::<Vec<Entity>>()
.into_iter();
moused_over_z_sorted_nodes.sort_by_key(|(_, _, _, z)| -*z);
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;

View file

@ -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::<UiCameraConfig>::default())
.init_resource::<FlexSurface>()
.init_resource::<UiScale>()
.init_resource::<UiStack>()
.register_type::<AlignContent>()
.register_type::<AlignItems>()
.register_type::<AlignSelf>()
@ -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,

View file

@ -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<ExtractedUiNodes>,
images: Extract<Res<Assets<Image>>>,
ui_stack: Extract<Res<UiStack>>,
windows: Extract<Res<Windows>>,
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;
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,
});
}
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 {
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<ExtractedUiNodes>,
texture_atlases: Extract<Res<Assets<TextureAtlas>>>,
windows: Extract<Res<Windows>>,
ui_stack: Extract<Res<UiStack>>,
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;
}
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);
// 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);
// 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.),
);
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);
extracted_uinodes.uinodes.push(ExtractedUiNode {
transform: extracted_transform,
background_color: color,
rect,
image: texture,
atlas_size,
clip: clip.map(|clip| clip.clip),
scale_factor,
});
// 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,
});
}
}
}
}
@ -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;

220
crates/bevy_ui/src/stack.rs Normal file
View file

@ -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<Entity>,
}
#[derive(Default)]
struct StackingContext {
pub entries: Vec<StackingContextEntry>,
}
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<UiStack>,
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
zindex_query: Query<&ZIndex, With<Node>>,
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<Node>>,
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<Entity>, 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::<UiStack>();
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::<UiStack>();
let actual_result = ui_stack
.uinodes
.iter()
.map(|entity| query.get(&world, *entity).unwrap().clone())
.collect::<Vec<_>>();
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);
}
}

View file

@ -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;

View file

@ -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<Entity, (With<Node>, Without<Parent>)>,
mut node_query: Query<&mut Transform, With<Node>>,
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<Node>>,
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::<Vec<(Label, u32)>>();
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);
}
}

View file

@ -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

123
examples/ui/z_index.rs Normal file
View file

@ -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()
});
});
}