Skip to content

Commit fac186f

Browse files
authored
UI stack partition (#20870)
# Objective In the UI picking and rendering systems, we first query for all the pickable or renderable UI nodes and then query per node for the details of the camera and its render target. But the typical application using Bevy UI will have hundreds of UI nodes and just one UI camera, so this is extremely inefficient. Instead, we can partition the UI stack into disjoint slices where all the UI nodes in each slice have the same camera target. Then perform any camera and render target lookups per slice, instead of per node. ## Solution Partition the UI stack into disjoint layers of nodes sharing the same camera target. * Add a `partition: Vec<Range<usize>>` field to `UiStack`. * Update the partitions in `ui_stack_system`. * Query for cameras per slice in `ui_focus_system` and `ui_picking`. Splitting the rendering changes off into their own PR. --- ## Testing Some basic checks have been added to the existing `test_ui_stack_system` and `test_with_equal_global_zindex_zindex_decides_order` tests. Examples like `ui_target_camera`, `viewport_node` and `ui_drag_and_drop` can be used to test the changes. ## Showcase yellow this PR, red main: ```cargo run --example many_buttons --release --features bevy/trace_tracy``` `ui_picking` <img width="500" alt="ui_picking" src="https://github.com/user-attachments/assets/a0e37205-dcb4-4228-8379-7d3fa4ea6adf" /> `ui_stack_system` <img width="500" alt="ui_stack" src="https://github.com/user-attachments/assets/7e5487b8-9dc4-4db8-ac0e-04cc162cc420" /> ---
1 parent cf19650 commit fac186f

File tree

3 files changed

+123
-63
lines changed

3 files changed

+123
-63
lines changed

crates/bevy_ui/src/focus.rs

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ pub struct NodeQuery {
147147
///
148148
/// Entities with a hidden [`InheritedVisibility`] are always treated as released.
149149
pub fn ui_focus_system(
150+
mut hovered_nodes: Local<Vec<Entity>>,
150151
mut state: Local<State>,
151152
camera_query: Query<(Entity, &Camera)>,
152153
primary_window: Query<Entity, With<PrimaryWindow>>,
@@ -215,33 +216,49 @@ pub fn ui_focus_system(
215216
// prepare an iterator that contains all the nodes that have the cursor in their rect,
216217
// from the top node to the bottom one. this will also reset the interaction to `None`
217218
// for all nodes encountered that are no longer hovered.
218-
let mut hovered_nodes = ui_stack
219-
.uinodes
219+
220+
hovered_nodes.clear();
221+
// reverse the iterator to traverse the tree from closest slice to furthest
222+
for uinodes in ui_stack
223+
.partition
220224
.iter()
221-
// reverse the iterator to traverse the tree from closest nodes to furthest
222225
.rev()
223-
.filter_map(|entity| {
224-
let Ok(node) = node_query.get_mut(*entity) else {
225-
return None;
226+
.map(|range| &ui_stack.uinodes[range.clone()])
227+
{
228+
// Retrieve the first node and resolve its camera target.
229+
// Only need to do this once per slice, as all the nodes in the slice share the same camera.
230+
let Ok(root_node) = node_query.get_mut(uinodes[0]) else {
231+
continue;
232+
};
233+
234+
let Some(camera_entity) = root_node.target_camera.get() else {
235+
continue;
236+
};
237+
238+
let cursor_position = camera_cursor_positions.get(&camera_entity);
239+
240+
for entity in uinodes.iter().rev().cloned() {
241+
let Ok(node) = node_query.get_mut(entity) else {
242+
continue;
243+
};
244+
245+
let Some(inherited_visibility) = node.inherited_visibility else {
246+
continue;
226247
};
227248

228-
let inherited_visibility = node.inherited_visibility?;
229249
// Nodes that are not rendered should not be interactable
230250
if !inherited_visibility.get() {
231251
// Reset their interaction to None to avoid strange stuck state
232252
if let Some(mut interaction) = node.interaction {
233253
// We cannot simply set the interaction to None, as that will trigger change detection repeatedly
234254
interaction.set_if_neq(Interaction::None);
235255
}
236-
return None;
256+
continue;
237257
}
238-
let camera_entity = node.target_camera.get()?;
239-
240-
let cursor_position = camera_cursor_positions.get(&camera_entity);
241258

242259
let contains_cursor = cursor_position.is_some_and(|point| {
243260
node.node.contains_point(*node.transform, *point)
244-
&& clip_check_recursive(*point, *entity, &clipping_query, &child_of_query)
261+
&& clip_check_recursive(*point, entity, &clipping_query, &child_of_query)
245262
});
246263

247264
// The mouse position relative to the node
@@ -270,23 +287,22 @@ pub fn ui_focus_system(
270287
}
271288

272289
if contains_cursor {
273-
Some(*entity)
290+
hovered_nodes.push(entity);
274291
} else {
275292
if let Some(mut interaction) = node.interaction
276293
&& (*interaction == Interaction::Hovered
277294
|| (normalized_cursor_position.is_none()))
278295
{
279296
interaction.set_if_neq(Interaction::None);
280297
}
281-
None
298+
continue;
282299
}
283-
})
284-
.collect::<Vec<Entity>>()
285-
.into_iter();
300+
}
301+
}
286302

287303
// set Pressed or Hovered on top nodes. as soon as a node with a `Block` focus policy is detected,
288304
// the iteration will stop on it because it "captures" the interaction.
289-
let mut iter = node_query.iter_many_mut(hovered_nodes.by_ref());
305+
let mut iter = node_query.iter_many_mut(hovered_nodes.iter());
290306
while let Some(node) = iter.fetch_next() {
291307
if let Some(mut interaction) = node.interaction {
292308
if mouse_clicked {
@@ -313,7 +329,7 @@ pub fn ui_focus_system(
313329
}
314330
// reset `Interaction` for the remaining lower nodes to `None`. those are the nodes that remain in
315331
// `moused_over_nodes` after the previous loop is exited.
316-
let mut iter = node_query.iter_many_mut(hovered_nodes);
332+
let mut iter = node_query.iter_many_mut(hovered_nodes.iter());
317333
while let Some(node) = iter.fetch_next() {
318334
if let Some(mut interaction) = node.interaction {
319335
// don't reset pressed nodes because they're handled separately

crates/bevy_ui/src/picking_backend.rs

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -157,59 +157,71 @@ pub fn ui_picking(
157157
// prepare an iterator that contains all the nodes that have the cursor in their rect,
158158
// from the top node to the bottom one. this will also reset the interaction to `None`
159159
// for all nodes encountered that are no longer hovered.
160-
for node_entity in ui_stack
161-
.uinodes
160+
// Reverse the iterator to traverse the tree from closest slice to furthest
161+
for uinodes in ui_stack
162+
.partition
162163
.iter()
163-
// reverse the iterator to traverse the tree from closest nodes to furthest
164164
.rev()
165+
.map(|range| &ui_stack.uinodes[range.clone()])
165166
{
166-
let Ok(node) = node_query.get(*node_entity) else {
167+
// Retrieve the first node and resolve its camera target.
168+
// Only need to do this once per slice, as all the nodes in the same slice share the same camera.
169+
let Ok(uinode) = node_query.get(uinodes[0]) else {
167170
continue;
168171
};
169172

170-
if settings.require_markers && node.pickable.is_none() {
171-
continue;
172-
}
173-
174-
// Nodes that are not rendered should not be interactable
175-
if node
176-
.inherited_visibility
177-
.map(|inherited_visibility| inherited_visibility.get())
178-
!= Some(true)
179-
{
180-
continue;
181-
}
182-
let Some(camera_entity) = node.target_camera.get() else {
173+
let Some(camera_entity) = uinode.target_camera.get() else {
183174
continue;
184175
};
185176

186-
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
187-
if node.node.size() == Vec2::ZERO {
188-
continue;
189-
}
190-
191177
let pointers_on_this_cam = pointer_pos_by_camera.get(&camera_entity);
192178

193-
// Find the normalized cursor position relative to the node.
194-
// (±0., 0.) is the center with the corners at points (±0.5, ±0.5).
195-
// Coordinates are relative to the entire node, not just the visible region.
196-
for (pointer_id, cursor_position) in pointers_on_this_cam.iter().flat_map(|h| h.iter()) {
197-
if node.node.contains_point(*node.transform, *cursor_position)
198-
&& clip_check_recursive(
199-
*cursor_position,
200-
*node_entity,
201-
&clipping_query,
202-
&child_of_query,
203-
)
179+
// Reverse the iterator to traverse the tree from closest nodes to furthest
180+
for node_entity in uinodes.iter().rev().cloned() {
181+
let Ok(node) = node_query.get(node_entity) else {
182+
continue;
183+
};
184+
185+
if settings.require_markers && node.pickable.is_none() {
186+
continue;
187+
}
188+
189+
// Nodes that are not rendered should not be interactable
190+
if node
191+
.inherited_visibility
192+
.map(|inherited_visibility| inherited_visibility.get())
193+
!= Some(true)
194+
{
195+
continue;
196+
}
197+
198+
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
199+
if node.node.size() == Vec2::ZERO {
200+
continue;
201+
}
202+
203+
// Find the normalized cursor position relative to the node.
204+
// (±0., 0.) is the center with the corners at points (±0.5, ±0.5).
205+
// Coordinates are relative to the entire node, not just the visible region.
206+
for (pointer_id, cursor_position) in pointers_on_this_cam.iter().flat_map(|h| h.iter())
204207
{
205-
hit_nodes
206-
.entry((camera_entity, *pointer_id))
207-
.or_default()
208-
.push((
209-
*node_entity,
210-
node.transform.inverse().transform_point2(*cursor_position)
211-
/ node.node.size(),
212-
));
208+
if node.node.contains_point(*node.transform, *cursor_position)
209+
&& clip_check_recursive(
210+
*cursor_position,
211+
node_entity,
212+
&clipping_query,
213+
&child_of_query,
214+
)
215+
{
216+
hit_nodes
217+
.entry((camera_entity, *pointer_id))
218+
.or_default()
219+
.push((
220+
node_entity,
221+
node.transform.inverse().transform_point2(*cursor_position)
222+
/ node.node.size(),
223+
));
224+
}
213225
}
214226
}
215227
}

crates/bevy_ui/src/stack.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
//! This module contains the systems that update the stored UI nodes stack
22
3-
use bevy_ecs::prelude::*;
4-
use bevy_platform::collections::HashSet;
5-
63
use crate::{
74
experimental::{UiChildren, UiRootNodes},
85
ComputedNode, GlobalZIndex, ZIndex,
96
};
7+
use bevy_ecs::prelude::*;
8+
use bevy_platform::collections::HashSet;
9+
use core::ops::Range;
1010

1111
/// The current UI stack, which contains all UI nodes ordered by their depth (back-to-front).
1212
///
1313
/// The first entry is the furthest node from the camera and is the first one to get rendered
1414
/// while the last entry is the first node to receive interactions.
1515
#[derive(Debug, Resource, Default)]
1616
pub struct UiStack {
17+
/// Partition of the `uinodes` list into disjoint slices of nodes that all share the same camera target.
18+
pub partition: Vec<Range<usize>>,
1719
/// List of UI nodes ordered from back-to-front
1820
pub uinodes: Vec<Entity>,
1921
}
@@ -50,6 +52,7 @@ pub fn ui_stack_system(
5052
zindex_query: Query<Option<&ZIndex>, (With<ComputedNode>, Without<GlobalZIndex>)>,
5153
mut update_query: Query<&mut ComputedNode>,
5254
) {
55+
ui_stack.partition.clear();
5356
ui_stack.uinodes.clear();
5457
visited_root_nodes.clear();
5558

@@ -81,13 +84,16 @@ pub fn ui_stack_system(
8184
root_nodes.sort_by_key(|(_, z)| *z);
8285

8386
for (root_entity, _) in root_nodes.drain(..) {
87+
let start = ui_stack.uinodes.len();
8488
update_uistack_recursive(
8589
&mut cache,
8690
root_entity,
8791
&ui_children,
8892
&zindex_query,
8993
&mut ui_stack.uinodes,
9094
);
95+
let end = ui_stack.uinodes.len();
96+
ui_stack.partition.push(start..end);
9197
}
9298

9399
for (i, entity) in ui_stack.uinodes.iter().enumerate() {
@@ -256,6 +262,27 @@ mod tests {
256262
(Label("0")), // GlobalZIndex(2)
257263
];
258264
assert_eq!(actual_result, expected_result);
265+
266+
// Test partitioning
267+
let last_part = ui_stack.partition.last().unwrap();
268+
assert_eq!(last_part.len(), 1);
269+
let last_entity = ui_stack.uinodes[last_part.start];
270+
assert_eq!(*query.get(&world, last_entity).unwrap(), Label("0"));
271+
272+
let actual_result = ui_stack.uinodes[ui_stack.partition[4].clone()]
273+
.iter()
274+
.map(|entity| query.get(&world, *entity).unwrap().clone())
275+
.collect::<Vec<_>>();
276+
let expected_result = vec![
277+
(Label("1")), // ZIndex(1)
278+
(Label("1-0")),
279+
(Label("1-0-2")), // ZIndex(-1)
280+
(Label("1-0-0")),
281+
(Label("1-0-1")),
282+
(Label("1-1")),
283+
(Label("1-3")),
284+
];
285+
assert_eq!(actual_result, expected_result);
259286
}
260287

261288
#[test]
@@ -305,5 +332,10 @@ mod tests {
305332
];
306333

307334
assert_eq!(actual_result, expected_result);
335+
336+
assert_eq!(ui_stack.partition.len(), expected_result.len());
337+
for (i, part) in ui_stack.partition.iter().enumerate() {
338+
assert_eq!(*part, i..i + 1);
339+
}
308340
}
309341
}

0 commit comments

Comments
 (0)