Skip to content

Commit 4836c78

Browse files
Specialized UI transform (#16615)
# Objective Add specialized UI transform `Component`s and fix some related problems: * Animating UI elements by modifying the `Transform` component of UI nodes doesn't work very well because `ui_layout_system` overwrites the translations each frame. The `overflow_debug` example uses a horrible hack where it copies the transform into the position that'll likely cause a panic if any users naively copy it. * Picking ignores rotation and scaling and assumes UI nodes are always axis aligned. * The clipping geometry stored in `CalculatedClip` is wrong for rotated and scaled elements. * Transform propagation is unnecessary for the UI, the transforms can be updated during layout updates. * The UI internals use both object-centered and top-left-corner-based coordinates systems for UI nodes. Depending on the context you have to add or subtract the half-size sometimes before transforming between coordinate spaces. We should just use one system consistantly so that the transform can always be directly applied. * `Transform` doesn't support responsive coordinates. ## Solution * Unrequire `Transform` from `Node`. * New components `UiTransform`, `UiGlobalTransform`: - `Node` requires `UiTransform`, `UiTransform` requires `UiGlobalTransform` - `UiTransform` is a 2d-only equivalent of `Transform` with a translation in `Val`s. - `UiGlobalTransform` newtypes `Affine2` and is updated in `ui_layout_system`. * New helper functions on `ComputedNode` for mapping between viewport and local node space. * The cursor position is transformed to local node space during picking so that it respects rotations and scalings. * To check if the cursor hovers a node recursively walk up the tree to the root checking if any of the ancestor nodes clip the point at the cursor. If the point is clipped the interaction is ignored. * Use object-centered coordinates for UI nodes. * `RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the the center of the node and the corners at (±0.5, ±0.5). * Replaced the `normalized_visible_node_rect: Rect` field of `RelativeCursorPosition` with `cursor_over: bool`, which is set to true when the cursor is over an unclipped point on the node. The visible area of the node is not necessarily a rectangle, so the previous implementation didn't work. This should fix all the logical bugs with non-axis aligned interactions and clipping. Rendering still needs changes but they are far outside the scope of this PR. Tried and abandoned two other approaches: * New `transform` field on `Node`, require `GlobalTransform` on `Node`, and unrequire `Transform` on `Node`. Unrequiring `Transform` opts out of transform propagation so there is then no conflict with updating the `GlobalTransform` in `ui_layout_system`. This was a nice change in its simplicity but potentially confusing for users I think, all the `GlobalTransform` docs mention `Transform` and having special rules for how it's updated just for the UI is unpleasently surprising. * New `transform` field on `Node`. Unrequire `Transform` on `Node`. New `transform: Affine2` field on `ComputedNode`. This was okay but I think most users want a separate specialized UI transform components. The fat `ComputedNode` doesn't work well with change detection. Fixes #18929, #18930 ## Testing There is an example you can look at: ``` cargo run --example ui_transform ``` Sometimes in the example if you press the rotate button couple of times the first glyph from the top label disappears , I'm not sure what's causing it yet but I don't think it's related to this PR. ## Migration Guide New specialized 2D UI transform components `UiTransform` and `UiGlobalTransform`. `UiTransform` is a 2d-only equivalent of `Transform` with a translation in `Val`s. `UiGlobalTransform` newtypes `Affine2` and is updated in `ui_layout_system`. `Node` now requires `UiTransform` instead of `Transform`. `UiTransform` requires `UiGlobalTransform`. In previous versions of Bevy `ui_layout_system` would overwrite UI node's `Transform::translation` each frame. `UiTransform`s aren't overwritten and there is no longer any need for systems that cache and rewrite the transform for translated UI elements. `RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the the center of the node and the corners at (±0.5, ±0.5). Its `normalized_visible_node_rect` field has been removed and replaced with a new `cursor_over: bool` field which is set to true when the cursor is hovering an unclipped area of the UI node. --------- Co-authored-by: Alice Cecile <[email protected]>
1 parent bf8868b commit 4836c78

24 files changed

+869
-267
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3543,6 +3543,17 @@ description = "Illustrates how to use 9 Slicing for TextureAtlases in UI"
35433543
category = "UI (User Interface)"
35443544
wasm = true
35453545

3546+
[[example]]
3547+
name = "ui_transform"
3548+
path = "examples/ui/ui_transform.rs"
3549+
doc-scrape-examples = true
3550+
3551+
[package.metadata.example.ui_transform]
3552+
name = "UI Transform"
3553+
description = "An example demonstrating how to translate, rotate and scale UI elements."
3554+
category = "UI (User Interface)"
3555+
wasm = true
3556+
35463557
[[example]]
35473558
name = "viewport_debug"
35483559
path = "examples/ui/viewport_debug.rs"

crates/bevy_ui/src/accessibility.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::{
22
experimental::UiChildren,
33
prelude::{Button, Label},
4+
ui_transform::UiGlobalTransform,
45
widget::{ImageNode, TextUiReader},
56
ComputedNode,
67
};
@@ -13,11 +14,9 @@ use bevy_ecs::{
1314
system::{Commands, Query},
1415
world::Ref,
1516
};
16-
use bevy_math::Vec3Swizzles;
17-
use bevy_render::camera::CameraUpdateSystems;
18-
use bevy_transform::prelude::GlobalTransform;
1917

2018
use accesskit::{Node, Rect, Role};
19+
use bevy_render::camera::CameraUpdateSystems;
2120

2221
fn calc_label(
2322
text_reader: &mut TextUiReader,
@@ -40,12 +39,12 @@ fn calc_bounds(
4039
mut nodes: Query<(
4140
&mut AccessibilityNode,
4241
Ref<ComputedNode>,
43-
Ref<GlobalTransform>,
42+
Ref<UiGlobalTransform>,
4443
)>,
4544
) {
4645
for (mut accessible, node, transform) in &mut nodes {
4746
if node.is_changed() || transform.is_changed() {
48-
let center = transform.translation().xy();
47+
let center = transform.translation;
4948
let half_size = 0.5 * node.size;
5049
let min = center - half_size;
5150
let max = center + half_size;

crates/bevy_ui/src/focus.rs

Lines changed: 28 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
use crate::{CalculatedClip, ComputedNode, ComputedNodeTarget, ResolvedBorderRadius, UiStack};
1+
use crate::{
2+
picking_backend::clip_check_recursive, ui_transform::UiGlobalTransform, ComputedNode,
3+
ComputedNodeTarget, Node, UiStack,
4+
};
25
use bevy_ecs::{
36
change_detection::DetectChangesMut,
47
entity::{ContainsEntity, Entity},
8+
hierarchy::ChildOf,
59
prelude::{Component, With},
610
query::QueryData,
711
reflect::ReflectComponent,
812
system::{Local, Query, Res},
913
};
1014
use bevy_input::{mouse::MouseButton, touch::Touches, ButtonInput};
11-
use bevy_math::{Rect, Vec2};
15+
use bevy_math::Vec2;
1216
use bevy_platform::collections::HashMap;
1317
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
1418
use bevy_render::{camera::NormalizedRenderTarget, prelude::Camera, view::InheritedVisibility};
15-
use bevy_transform::components::GlobalTransform;
1619
use bevy_window::{PrimaryWindow, Window};
1720

1821
use smallvec::SmallVec;
@@ -67,12 +70,12 @@ impl Default for Interaction {
6770
}
6871
}
6972

70-
/// A component storing the position of the mouse relative to the node, (0., 0.) being the top-left corner and (1., 1.) being the bottom-right
71-
/// If the mouse is not over the node, the value will go beyond the range of (0., 0.) to (1., 1.)
73+
/// A component storing the position of the mouse relative to the node, (0., 0.) being the center and (0.5, 0.5) being the bottom-right
74+
/// If the mouse is not over the node, the value will go beyond the range of (-0.5, -0.5) to (0.5, 0.5)
7275
///
7376
/// It can be used alongside [`Interaction`] to get the position of the press.
7477
///
75-
/// The component is updated when it is in the same entity with [`Node`](crate::Node).
78+
/// The component is updated when it is in the same entity with [`Node`].
7679
#[derive(Component, Copy, Clone, Default, PartialEq, Debug, Reflect)]
7780
#[reflect(Component, Default, PartialEq, Debug, Clone)]
7881
#[cfg_attr(
@@ -81,18 +84,17 @@ impl Default for Interaction {
8184
reflect(Serialize, Deserialize)
8285
)]
8386
pub struct RelativeCursorPosition {
84-
/// Visible area of the Node relative to the size of the entire Node.
85-
pub normalized_visible_node_rect: Rect,
87+
/// True if the cursor position is over an unclipped area of the Node.
88+
pub cursor_over: bool,
8689
/// Cursor position relative to the size and position of the Node.
8790
/// A None value indicates that the cursor position is unknown.
8891
pub normalized: Option<Vec2>,
8992
}
9093

9194
impl RelativeCursorPosition {
9295
/// A helper function to check if the mouse is over the node
93-
pub fn mouse_over(&self) -> bool {
94-
self.normalized
95-
.is_some_and(|position| self.normalized_visible_node_rect.contains(position))
96+
pub fn cursor_over(&self) -> bool {
97+
self.cursor_over
9698
}
9799
}
98100

@@ -133,11 +135,10 @@ pub struct State {
133135
pub struct NodeQuery {
134136
entity: Entity,
135137
node: &'static ComputedNode,
136-
global_transform: &'static GlobalTransform,
138+
transform: &'static UiGlobalTransform,
137139
interaction: Option<&'static mut Interaction>,
138140
relative_cursor_position: Option<&'static mut RelativeCursorPosition>,
139141
focus_policy: Option<&'static FocusPolicy>,
140-
calculated_clip: Option<&'static CalculatedClip>,
141142
inherited_visibility: Option<&'static InheritedVisibility>,
142143
target_camera: &'static ComputedNodeTarget,
143144
}
@@ -154,6 +155,8 @@ pub fn ui_focus_system(
154155
touches_input: Res<Touches>,
155156
ui_stack: Res<UiStack>,
156157
mut node_query: Query<NodeQuery>,
158+
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
159+
child_of_query: Query<&ChildOf>,
157160
) {
158161
let primary_window = primary_window.iter().next();
159162

@@ -234,46 +237,30 @@ pub fn ui_focus_system(
234237
}
235238
let camera_entity = node.target_camera.camera()?;
236239

237-
let node_rect = Rect::from_center_size(
238-
node.global_transform.translation().truncate(),
239-
node.node.size(),
240-
);
241-
242-
// Intersect with the calculated clip rect to find the bounds of the visible region of the node
243-
let visible_rect = node
244-
.calculated_clip
245-
.map(|clip| node_rect.intersect(clip.clip))
246-
.unwrap_or(node_rect);
247-
248240
let cursor_position = camera_cursor_positions.get(&camera_entity);
249241

242+
let contains_cursor = cursor_position.is_some_and(|point| {
243+
node.node.contains_point(*node.transform, *point)
244+
&& clip_check_recursive(*point, *entity, &clipping_query, &child_of_query)
245+
});
246+
250247
// The mouse position relative to the node
251-
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner
248+
// (-0.5, -0.5) is the top-left corner, (0.5, 0.5) is the bottom-right corner
252249
// Coordinates are relative to the entire node, not just the visible region.
253-
let relative_cursor_position = cursor_position.and_then(|cursor_position| {
250+
let normalized_cursor_position = cursor_position.and_then(|cursor_position| {
254251
// ensure node size is non-zero in all dimensions, otherwise relative position will be
255252
// +/-inf. if the node is hidden, the visible rect min/max will also be -inf leading to
256253
// false positives for mouse_over (#12395)
257-
(node_rect.size().cmpgt(Vec2::ZERO).all())
258-
.then_some((*cursor_position - node_rect.min) / node_rect.size())
254+
node.node.normalize_point(*node.transform, *cursor_position)
259255
});
260256

261257
// If the current cursor position is within the bounds of the node's visible area, consider it for
262258
// clicking
263259
let relative_cursor_position_component = RelativeCursorPosition {
264-
normalized_visible_node_rect: visible_rect.normalize(node_rect),
265-
normalized: relative_cursor_position,
260+
cursor_over: contains_cursor,
261+
normalized: normalized_cursor_position,
266262
};
267263

268-
let contains_cursor = relative_cursor_position_component.mouse_over()
269-
&& cursor_position.is_some_and(|point| {
270-
pick_rounded_rect(
271-
*point - node_rect.center(),
272-
node_rect.size(),
273-
node.node.border_radius,
274-
)
275-
});
276-
277264
// Save the relative cursor position to the correct component
278265
if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position
279266
{
@@ -284,7 +271,8 @@ pub fn ui_focus_system(
284271
Some(*entity)
285272
} else {
286273
if let Some(mut interaction) = node.interaction {
287-
if *interaction == Interaction::Hovered || (relative_cursor_position.is_none())
274+
if *interaction == Interaction::Hovered
275+
|| (normalized_cursor_position.is_none())
288276
{
289277
interaction.set_if_neq(Interaction::None);
290278
}
@@ -334,26 +322,3 @@ pub fn ui_focus_system(
334322
}
335323
}
336324
}
337-
338-
// Returns true if `point` (relative to the rectangle's center) is within the bounds of a rounded rectangle with
339-
// the given size and border radius.
340-
//
341-
// Matches the sdf function in `ui.wgsl` that is used by the UI renderer to draw rounded rectangles.
342-
pub(crate) fn pick_rounded_rect(
343-
point: Vec2,
344-
size: Vec2,
345-
border_radius: ResolvedBorderRadius,
346-
) -> bool {
347-
let [top, bottom] = if point.x < 0. {
348-
[border_radius.top_left, border_radius.bottom_left]
349-
} else {
350-
[border_radius.top_right, border_radius.bottom_right]
351-
};
352-
let r = if point.y < 0. { top } else { bottom };
353-
354-
let corner_to_point = point.abs() - 0.5 * size;
355-
let q = corner_to_point + r;
356-
let l = q.max(Vec2::ZERO).length();
357-
let m = q.max_element().min(0.);
358-
l + m - r < 0.
359-
}

crates/bevy_ui/src/layout/convert.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ impl RepeatedGridTrack {
448448

449449
#[cfg(test)]
450450
mod tests {
451+
use bevy_math::Vec2;
452+
451453
use super::*;
452454

453455
#[test]
@@ -523,7 +525,7 @@ mod tests {
523525
grid_column: GridPlacement::start(4),
524526
grid_row: GridPlacement::span(3),
525527
};
526-
let viewport_values = LayoutContext::new(1.0, bevy_math::Vec2::new(800., 600.));
528+
let viewport_values = LayoutContext::new(1.0, Vec2::new(800., 600.));
527529
let taffy_style = from_node(&node, &viewport_values, false);
528530
assert_eq!(taffy_style.display, taffy::style::Display::Flex);
529531
assert_eq!(taffy_style.box_sizing, taffy::style::BoxSizing::ContentBox);
@@ -661,7 +663,7 @@ mod tests {
661663
#[test]
662664
fn test_into_length_percentage() {
663665
use taffy::style::LengthPercentage;
664-
let context = LayoutContext::new(2.0, bevy_math::Vec2::new(800., 600.));
666+
let context = LayoutContext::new(2.0, Vec2::new(800., 600.));
665667
let cases = [
666668
(Val::Auto, LengthPercentage::Length(0.)),
667669
(Val::Percent(1.), LengthPercentage::Percent(0.01)),

0 commit comments

Comments
 (0)