Simplified ui_stack_system (#9889)

# Objective

`ui_stack_system` generates a tree of `StackingContexts` which it then
flattens to get the `UiStack`.

But there's no need to construct a new tree. We can query for nodes with
a global `ZIndex`, add those nodes to the root nodes list and then build
the `UiStack` from a walk of the existing layout tree, ignoring any
branches that have a global `Zindex`.

Fixes #9877

## Solution

Split the `ZIndex` enum into two separate components, `ZIndex` and
`GlobalZIndex`

Query for nodes with a `GlobalZIndex`, add those nodes to the root nodes
list and then build the `UiStack` from a walk of the existing layout
tree, filtering branches by `Without<GlobalZIndex>` so we don't revisit
nodes.

```
cargo run --profile stress-test --features trace_tracy --example many_buttons
```

<img width="672" alt="ui-stack-system-walk-split-enum"
src="https://github.com/bevyengine/bevy/assets/27962798/11e357a5-477f-4804-8ada-c4527c009421">

(Yellow is this PR, red is main)

---

## Changelog
`Zindex`
* The `ZIndex` enum has been split into two separate components `ZIndex`
(which replaces `ZIndex::Local`) and `GlobalZIndex` (which replaces
`ZIndex::Global`). An entity can have both a `ZIndex` and
`GlobalZIndex`, in comparisons `ZIndex` breaks ties if two
`GlobalZIndex` values are equal.

`ui_stack_system`
* Instead of generating a tree of `StackingContexts`, query for nodes
with a `GlobalZIndex`, add those nodes to the root nodes list and then
build the `UiStack` from a walk of the existing layout tree, filtering
branches by `Without<GlobalZIndex` so we don't revisit nodes.

## Migration Guide

The `ZIndex` enum has been split into two separate components `ZIndex`
(which replaces `ZIndex::Local`) and `GlobalZIndex` (which replaces
`ZIndex::Global`). An entity can have both a `ZIndex` and
`GlobalZIndex`, in comparisons `ZIndex` breaks ties if two
`GlobalZindex` values are equal.

---------

Co-authored-by: Gabriel Bourgeois <gabriel.bourgeoisv4si@gmail.com>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: UkoeHB <37489173+UkoeHB@users.noreply.github.com>
This commit is contained in:
ickshonpe 2024-09-30 19:43:57 +01:00 committed by GitHub
parent 93aa2a2cc4
commit c5742ff43e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 202 deletions

View file

@ -16,11 +16,11 @@ use bevy_render::view::Visibility;
use bevy_text::{Font, Text, TextSection, TextStyle};
use bevy_ui::{
node_bundles::{NodeBundle, TextBundle},
PositionType, Style, ZIndex,
GlobalZIndex, PositionType, Style,
};
use bevy_utils::default;
/// Global [`ZIndex`] used to render the fps overlay.
/// [`GlobalZIndex`] used to render the fps overlay.
///
/// We use a number slightly under `i32::MAX` so you can render on top of it if you really need to.
pub const FPS_OVERLAY_ZINDEX: i32 = i32::MAX - 32;
@ -83,16 +83,18 @@ struct FpsText;
fn setup(mut commands: Commands, overlay_config: Res<FpsOverlayConfig>) {
commands
.spawn(NodeBundle {
style: Style {
// We need to make sure the overlay doesn't affect the position of other UI nodes
position_type: PositionType::Absolute,
.spawn((
NodeBundle {
style: Style {
// We need to make sure the overlay doesn't affect the position of other UI nodes
position_type: PositionType::Absolute,
..default()
},
// Render overlay on top of everything
..default()
},
// Render overlay on top of everything
z_index: ZIndex::Global(FPS_OVERLAY_ZINDEX),
..default()
})
GlobalZIndex(FPS_OVERLAY_ZINDEX),
))
.with_children(|c| {
c.spawn((
TextBundle::from_sections([

View file

@ -3,7 +3,7 @@
use bevy_ecs::prelude::*;
use bevy_hierarchy::prelude::*;
use crate::{Node, ZIndex};
use crate::{GlobalZIndex, Node, ZIndex};
/// The current UI stack, which contains all UI nodes ordered by their depth (back-to-front).
///
@ -15,69 +15,75 @@ pub struct UiStack {
pub uinodes: Vec<Entity>,
}
/// Caches stacking context buffers for use in [`ui_stack_system`].
#[derive(Default)]
pub(crate) struct StackingContextCache {
inner: Vec<StackingContext>,
pub(crate) struct ChildBufferCache {
pub inner: Vec<Vec<(Entity, i32)>>,
}
impl StackingContextCache {
fn pop(&mut self) -> StackingContext {
impl ChildBufferCache {
fn pop(&mut self) -> Vec<(Entity, i32)> {
self.inner.pop().unwrap_or_default()
}
fn push(&mut self, mut context: StackingContext) {
for entry in context.entries.drain(..) {
self.push(entry.stack);
}
self.inner.push(context);
fn push(&mut self, vec: Vec<(Entity, i32)>) {
self.inner.push(vec);
}
}
#[derive(Default)]
struct StackingContext {
entries: Vec<StackingContextEntry>,
}
struct StackingContextEntry {
z_index: i32,
entity: Entity,
stack: StackingContext,
}
/// Generates the render stack for UI nodes.
///
/// First generate a UI node tree (`StackingContext`) based on z-index.
/// Then flatten that tree into back-to-front ordered `UiStack`.
pub(crate) fn ui_stack_system(
mut cache: Local<StackingContextCache>,
/// Create a list of root nodes from unparented entities and entities with a `GlobalZIndex` component.
/// Then build the `UiStack` from a walk of the existing layout trees starting from each root node,
/// filtering branches by `Without<GlobalZIndex>`so that we don't revisit nodes.
#[allow(clippy::too_many_arguments)]
pub fn ui_stack_system(
mut cache: Local<ChildBufferCache>,
mut root_nodes: Local<Vec<(Entity, (i32, i32))>>,
mut ui_stack: ResMut<UiStack>,
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
zindex_query: Query<&ZIndex, With<Node>>,
root_node_query: Query<
(Entity, Option<&GlobalZIndex>, Option<&ZIndex>),
(With<Node>, Without<Parent>),
>,
zindex_global_node_query: Query<
(Entity, &GlobalZIndex, Option<&ZIndex>),
(With<Node>, With<Parent>),
>,
children_query: Query<&Children>,
zindex_query: Query<Option<&ZIndex>, (With<Node>, Without<GlobalZIndex>)>,
mut update_query: Query<&mut Node>,
) {
// Generate `StackingContext` tree
let mut global_context = cache.pop();
let mut total_entry_count: usize = 0;
for entity in &root_node_query {
insert_context_hierarchy(
&mut cache,
&zindex_query,
&children_query,
entity,
&mut global_context,
None,
&mut total_entry_count,
);
ui_stack.uinodes.clear();
for (id, global_zindex, maybe_zindex) in zindex_global_node_query.iter() {
root_nodes.push((
id,
(
global_zindex.0,
maybe_zindex.map(|zindex| zindex.0).unwrap_or(0),
),
));
}
// Flatten `StackingContext` into `UiStack`
ui_stack.uinodes.clear();
ui_stack.uinodes.reserve(total_entry_count);
fill_stack_recursively(&mut cache, &mut ui_stack.uinodes, &mut global_context);
cache.push(global_context);
for (id, maybe_global_zindex, maybe_zindex) in root_node_query.iter() {
root_nodes.push((
id,
(
maybe_global_zindex.map(|zindex| zindex.0).unwrap_or(0),
maybe_zindex.map(|zindex| zindex.0).unwrap_or(0),
),
));
}
root_nodes.sort_by_key(|(_, z)| *z);
for (root_entity, _) in root_nodes.drain(..) {
update_uistack_recursive(
&mut cache,
root_entity,
&children_query,
&zindex_query,
&mut ui_stack.uinodes,
);
}
for (i, entity) in ui_stack.uinodes.iter().enumerate() {
if let Ok(mut node) = update_query.get_mut(*entity) {
@ -86,67 +92,28 @@ pub(crate) fn ui_stack_system(
}
}
/// Generate z-index based UI node tree
fn insert_context_hierarchy(
cache: &mut StackingContextCache,
zindex_query: &Query<&ZIndex, With<Node>>,
fn update_uistack_recursive(
cache: &mut ChildBufferCache,
node_entity: Entity,
children_query: &Query<&Children>,
entity: Entity,
global_context: &mut StackingContext,
parent_context: Option<&mut StackingContext>,
total_entry_count: &mut usize,
zindex_query: &Query<Option<&ZIndex>, (With<Node>, Without<GlobalZIndex>)>,
ui_stack: &mut Vec<Entity>,
) {
let mut new_context = cache.pop();
ui_stack.push(node_entity);
if let Ok(children) = children_query.get(entity) {
// Reserve space for all children. In practice, some may not get pushed since
// nodes with `ZIndex::Global` are pushed to the global (root) context.
new_context.entries.reserve_exact(children.len());
for entity in children {
insert_context_hierarchy(
cache,
zindex_query,
children_query,
*entity,
global_context,
Some(&mut new_context),
total_entry_count,
);
if let Ok(children) = children_query.get(node_entity) {
let mut child_buffer = cache.pop();
child_buffer.extend(children.iter().filter_map(|child_entity| {
zindex_query
.get(*child_entity)
.ok()
.map(|zindex| (*child_entity, zindex.map(|zindex| zindex.0).unwrap_or(0)))
}));
child_buffer.sort_by_key(|k| k.1);
for (child_entity, _) in child_buffer.drain(..) {
update_uistack_recursive(cache, child_entity, children_query, zindex_query, ui_stack);
}
}
// The node will be added either to global/parent based on its z-index type: global/local.
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,
});
}
/// Flatten `StackingContext` (z-index based UI node tree) into back-to-front entities list
fn fill_stack_recursively(
cache: &mut StackingContextCache,
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. This results
// in `back-to-front` ordering, low z_index = back; high z_index = front.
stack.entries.sort_by_key(|e| e.z_index);
for mut entry in stack.entries.drain(..) {
// Parent node renders before/behind child nodes
result.push(entry.entity);
fill_stack_recursively(cache, result, &mut entry.stack);
cache.push(entry.stack);
cache.push(child_buffer);
}
}
@ -160,15 +127,35 @@ mod tests {
};
use bevy_hierarchy::{BuildChildren, ChildBuild};
use crate::{Node, UiStack, ZIndex};
use crate::{GlobalZIndex, 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_with_global_and_local_zindex(
name: &'static str,
global_zindex: i32,
local_zindex: i32,
) -> (Label, Node, GlobalZIndex, ZIndex) {
(
Label(name),
Node::default(),
GlobalZIndex(global_zindex),
ZIndex(local_zindex),
)
}
fn node_with_global_zindex(
name: &'static str,
global_zindex: i32,
) -> (Label, Node, GlobalZIndex) {
(Label(name), Node::default(), GlobalZIndex(global_zindex))
}
fn node_with_zindex(name: &'static str, zindex: i32) -> (Label, Node, ZIndex) {
(Label(name), Node::default(), ZIndex(zindex))
}
fn node_without_zindex(name: &'static str) -> (Label, Node) {
@ -188,24 +175,24 @@ mod tests {
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_global_zindex("0", 2));
commands
.spawn(node_with_zindex("1", ZIndex::Local(1)))
.spawn(node_with_zindex("1", 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_with_zindex("1-0-2", -1));
});
parent.spawn(node_without_zindex("1-1"));
parent
.spawn(node_with_zindex("1-2", ZIndex::Global(-1)))
.spawn(node_with_global_zindex("1-2", -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_with_global_zindex("1-2-1", -3));
parent
.spawn(node_without_zindex("1-2-2"))
.with_children(|_| ());
@ -227,7 +214,7 @@ mod tests {
});
});
commands.spawn(node_with_zindex("3", ZIndex::Global(-2)));
commands.spawn(node_with_global_zindex("3", -2));
queue.apply(&mut world);
@ -243,25 +230,74 @@ mod tests {
.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)
(Label("1-2-1")), // GlobalZIndex(-3)
(Label("3")), // GlobalZIndex(-2)
(Label("1-2")), // GlobalZIndex(-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(1)
(Label("1-0")),
(Label("1-0-2")), // ZIndex(-1)
(Label("1-0-0")),
(Label("1-0-1")),
(Label("1-1")),
(Label("1-3")),
(Label("0")), // GlobalZIndex(2)
];
assert_eq!(actual_result, expected_result);
}
#[test]
fn test_with_equal_global_zindex_zindex_decides_order() {
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_global_and_local_zindex("0", -1, 1));
commands.spawn(node_with_global_and_local_zindex("1", -1, 2));
commands.spawn(node_with_global_and_local_zindex("2", 1, 3));
commands.spawn(node_with_global_and_local_zindex("3", 1, -3));
commands
.spawn(node_without_zindex("4"))
.with_children(|builder| {
builder.spawn(node_with_global_and_local_zindex("5", 0, -1));
builder.spawn(node_with_global_and_local_zindex("6", 0, 1));
builder.spawn(node_with_global_and_local_zindex("7", -1, -1));
builder.spawn(node_with_global_zindex("8", 1));
});
queue.apply(&mut world);
let mut schedule = Schedule::default();
schedule.add_systems(ui_stack_system);
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("7")),
(Label("0")),
(Label("1")),
(Label("5")),
(Label("4")),
(Label("6")),
(Label("3")),
(Label("8")),
(Label("2")),
];
assert_eq!(actual_result, expected_result);
}
}

View file

@ -2052,26 +2052,21 @@ pub struct CalculatedClip {
/// appear in the UI hierarchy. In such a case, the last node to be added to its parent
/// will appear in front of its siblings.
///
/// 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, PartialEq, Eq, Reflect)]
#[reflect(Component, Default, Debug, PartialEq)]
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)
}
}
/// Nodes without this component will be treated as if they had a value of [`ZIndex(0)`].
#[derive(Component, Copy, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Component, Default, Debug, PartialEq)]
pub struct ZIndex(pub i32);
/// `GlobalZIndex` allows a [`Node`] entity anywhere in the UI hierarchy to escape the implicit draw ordering of the UI's layout tree and
/// be rendered above or below other UI nodes.
/// Nodes with a `GlobalZIndex` of greater than 0 will be drawn on top of nodes without a `GlobalZIndex` or nodes with a lower `GlobalZIndex`.
/// Nodes with a `GlobalZIndex` of less than 0 will be drawn below nodes without a `GlobalZIndex` or nodes with a greater `GlobalZIndex`.
///
/// If two Nodes have the same `GlobalZIndex`, the node with the greater [`ZIndex`] will be drawn on top.
#[derive(Component, Copy, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Component, Default, Debug, PartialEq)]
pub struct GlobalZIndex(pub i32);
/// Used to add rounded corners to a UI node. You can set a UI node to have uniformly
/// rounded corners or specify different radii for each corner. If a given radius exceeds half

View file

@ -116,16 +116,18 @@ fn setup(
let style = TextStyle::default();
commands
.spawn(NodeBundle {
style: Style {
position_type: PositionType::Absolute,
padding: UiRect::all(Val::Px(5.0)),
.spawn((
NodeBundle {
style: Style {
position_type: PositionType::Absolute,
padding: UiRect::all(Val::Px(5.0)),
..default()
},
background_color: Color::BLACK.with_alpha(0.75).into(),
..default()
},
z_index: ZIndex::Global(i32::MAX),
background_color: Color::BLACK.with_alpha(0.75).into(),
..default()
})
GlobalZIndex(i32::MAX),
))
.with_children(|c| {
c.spawn(TextBundle::from_sections([
TextSection::new("Controls:\n", style.clone()),

View file

@ -270,16 +270,18 @@ fn setup(
commands.spawn(Camera2dBundle::default());
commands
.spawn(NodeBundle {
style: Style {
position_type: PositionType::Absolute,
padding: UiRect::all(Val::Px(5.0)),
.spawn((
NodeBundle {
style: Style {
position_type: PositionType::Absolute,
padding: UiRect::all(Val::Px(5.0)),
..default()
},
background_color: Color::BLACK.with_alpha(0.75).into(),
..default()
},
z_index: ZIndex::Global(i32::MAX),
background_color: Color::BLACK.with_alpha(0.75).into(),
..default()
})
GlobalZIndex(i32::MAX),
))
.with_children(|c| {
c.spawn((
TextBundle::from_sections([

View file

@ -62,7 +62,7 @@ fn setup(mut commands: Commands) {
// spawn a node with a positive local z-index of 2.
// it will show above other nodes in the gray container.
parent.spawn(NodeBundle {
z_index: ZIndex::Local(2),
z_index: ZIndex(2),
background_color: BLUE.into(),
style: Style {
position_type: PositionType::Absolute,
@ -78,7 +78,7 @@ fn setup(mut commands: Commands) {
// spawn a node with a negative local z-index.
// it will show under other nodes in the gray container.
parent.spawn(NodeBundle {
z_index: ZIndex::Local(-1),
z_index: ZIndex(-1),
background_color: LIME.into(),
style: Style {
position_type: PositionType::Absolute,
@ -94,36 +94,40 @@ fn setup(mut commands: Commands) {
// 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 gray container is added to.
parent.spawn(NodeBundle {
z_index: ZIndex::Global(1),
background_color: PURPLE.into(),
style: Style {
position_type: PositionType::Absolute,
left: Val::Px(15.0),
bottom: Val::Px(10.0),
width: Val::Px(100.),
height: Val::Px(60.),
..default()
parent.spawn((
NodeBundle {
background_color: PURPLE.into(),
style: Style {
position_type: PositionType::Absolute,
left: Val::Px(15.0),
bottom: Val::Px(10.0),
width: Val::Px(100.),
height: Val::Px(60.),
..default()
},
..Default::default()
},
..default()
});
GlobalZIndex(1),
));
// 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: YELLOW.into(),
style: Style {
position_type: PositionType::Absolute,
left: Val::Px(-15.0),
bottom: Val::Px(-15.0),
width: Val::Px(100.),
height: Val::Px(125.),
..default()
parent.spawn((
NodeBundle {
background_color: YELLOW.into(),
style: Style {
position_type: PositionType::Absolute,
left: Val::Px(-15.0),
bottom: Val::Px(-15.0),
width: Val::Px(100.),
height: Val::Px(125.),
..default()
},
..Default::default()
},
..default()
});
GlobalZIndex(-1),
));
});
});
}