
# 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 <alice.i.cecile@gmail.com>
279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
//! A picking backend for UI nodes.
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! This backend does not require markers on cameras or entities to function. It will look for any
|
|
//! pointers using the same render target as the UI camera, and run hit tests on the UI node tree.
|
|
//!
|
|
//! ## Important Note
|
|
//!
|
|
//! This backend completely ignores [`FocusPolicy`](crate::FocusPolicy). The design of `bevy_ui`'s
|
|
//! focus systems and the picking plugin are not compatible. Instead, use the optional [`Pickable`] component
|
|
//! to override how an entity responds to picking focus. Nodes without the [`Pickable`] component
|
|
//! will still trigger events and block items below it from being hovered.
|
|
//!
|
|
//! ## Implementation Notes
|
|
//!
|
|
//! - `bevy_ui` can only render to the primary window
|
|
//! - `bevy_ui` can render on any camera with a flag, it is special, and is not tied to a particular
|
|
//! camera.
|
|
//! - To correctly sort picks, the order of `bevy_ui` is set to be the camera order plus 0.5.
|
|
//! - The `position` reported in `HitData` is normalized relative to the node, with `(0.,0.,0.)` at
|
|
//! the top left and `(1., 1., 0.)` in the bottom right. Coordinates are relative to the entire
|
|
//! node, not just the visible region. This backend does not provide a `normal`.
|
|
|
|
#![deny(missing_docs)]
|
|
|
|
use crate::{prelude::*, ui_transform::UiGlobalTransform, UiStack};
|
|
use bevy_app::prelude::*;
|
|
use bevy_ecs::{prelude::*, query::QueryData};
|
|
use bevy_math::Vec2;
|
|
use bevy_platform::collections::HashMap;
|
|
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
|
|
use bevy_render::prelude::*;
|
|
use bevy_window::PrimaryWindow;
|
|
|
|
use bevy_picking::backend::prelude::*;
|
|
|
|
/// An optional component that marks cameras that should be used in the [`UiPickingPlugin`].
|
|
///
|
|
/// Only needed if [`UiPickingSettings::require_markers`] is set to `true`, and ignored
|
|
/// otherwise.
|
|
#[derive(Debug, Clone, Default, Component, Reflect)]
|
|
#[reflect(Debug, Default, Component)]
|
|
pub struct UiPickingCamera;
|
|
|
|
/// Runtime settings for the [`UiPickingPlugin`].
|
|
#[derive(Resource, Reflect)]
|
|
#[reflect(Resource, Default)]
|
|
pub struct UiPickingSettings {
|
|
/// When set to `true` UI picking will only consider cameras marked with
|
|
/// [`UiPickingCamera`] and entities marked with [`Pickable`]. `false` by default.
|
|
///
|
|
/// This setting is provided to give you fine-grained control over which cameras and entities
|
|
/// should be used by the UI picking backend at runtime.
|
|
pub require_markers: bool,
|
|
}
|
|
|
|
#[expect(
|
|
clippy::allow_attributes,
|
|
reason = "clippy::derivable_impls is not always linted"
|
|
)]
|
|
#[allow(
|
|
clippy::derivable_impls,
|
|
reason = "Known false positive with clippy: <https://github.com/rust-lang/rust-clippy/issues/13160>"
|
|
)]
|
|
impl Default for UiPickingSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
require_markers: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A plugin that adds picking support for UI nodes.
|
|
///
|
|
/// This is included by default in [`UiPlugin`](crate::UiPlugin).
|
|
#[derive(Clone)]
|
|
pub struct UiPickingPlugin;
|
|
impl Plugin for UiPickingPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<UiPickingSettings>()
|
|
.register_type::<(UiPickingCamera, UiPickingSettings)>()
|
|
.add_systems(PreUpdate, ui_picking.in_set(PickingSystems::Backend));
|
|
}
|
|
}
|
|
|
|
/// Main query from bevy's `ui_focus_system`
|
|
#[derive(QueryData)]
|
|
#[query_data(mutable)]
|
|
pub struct NodeQuery {
|
|
entity: Entity,
|
|
node: &'static ComputedNode,
|
|
transform: &'static UiGlobalTransform,
|
|
pickable: Option<&'static Pickable>,
|
|
inherited_visibility: Option<&'static InheritedVisibility>,
|
|
target_camera: &'static ComputedNodeTarget,
|
|
}
|
|
|
|
/// Computes the UI node entities under each pointer.
|
|
///
|
|
/// Bevy's [`UiStack`] orders all nodes in the order they will be rendered, which is the same order
|
|
/// we need for determining picking.
|
|
pub fn ui_picking(
|
|
pointers: Query<(&PointerId, &PointerLocation)>,
|
|
camera_query: Query<(Entity, &Camera, Has<UiPickingCamera>)>,
|
|
primary_window: Query<Entity, With<PrimaryWindow>>,
|
|
settings: Res<UiPickingSettings>,
|
|
ui_stack: Res<UiStack>,
|
|
node_query: Query<NodeQuery>,
|
|
mut output: EventWriter<PointerHits>,
|
|
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
|
|
child_of_query: Query<&ChildOf>,
|
|
) {
|
|
// For each camera, the pointer and its position
|
|
let mut pointer_pos_by_camera = HashMap::<Entity, HashMap<PointerId, Vec2>>::default();
|
|
|
|
for (pointer_id, pointer_location) in
|
|
pointers.iter().filter_map(|(pointer, pointer_location)| {
|
|
Some(*pointer).zip(pointer_location.location().cloned())
|
|
})
|
|
{
|
|
// This pointer is associated with a render target, which could be used by multiple
|
|
// cameras. We want to ensure we return all cameras with a matching target.
|
|
for camera in camera_query
|
|
.iter()
|
|
.filter(|(_, _, cam_can_pick)| !settings.require_markers || *cam_can_pick)
|
|
.map(|(entity, camera, _)| {
|
|
(
|
|
entity,
|
|
camera.target.normalize(primary_window.single().ok()),
|
|
)
|
|
})
|
|
.filter_map(|(entity, target)| Some(entity).zip(target))
|
|
.filter(|(_entity, target)| target == &pointer_location.target)
|
|
.map(|(cam_entity, _target)| cam_entity)
|
|
{
|
|
let Ok((_, camera_data, _)) = camera_query.get(camera) else {
|
|
continue;
|
|
};
|
|
let mut pointer_pos =
|
|
pointer_location.position * camera_data.target_scaling_factor().unwrap_or(1.);
|
|
if let Some(viewport) = camera_data.physical_viewport_rect() {
|
|
pointer_pos -= viewport.min.as_vec2();
|
|
}
|
|
pointer_pos_by_camera
|
|
.entry(camera)
|
|
.or_default()
|
|
.insert(pointer_id, pointer_pos);
|
|
}
|
|
}
|
|
|
|
// The list of node entities hovered for each (camera, pointer) combo
|
|
let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<(Entity, Vec2)>>::default();
|
|
|
|
// 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.
|
|
for node_entity in ui_stack
|
|
.uinodes
|
|
.iter()
|
|
// reverse the iterator to traverse the tree from closest nodes to furthest
|
|
.rev()
|
|
{
|
|
let Ok(node) = node_query.get(*node_entity) else {
|
|
continue;
|
|
};
|
|
|
|
if settings.require_markers && node.pickable.is_none() {
|
|
continue;
|
|
}
|
|
|
|
// Nodes that are not rendered should not be interactable
|
|
if node
|
|
.inherited_visibility
|
|
.map(|inherited_visibility| inherited_visibility.get())
|
|
!= Some(true)
|
|
{
|
|
continue;
|
|
}
|
|
let Some(camera_entity) = node.target_camera.camera() else {
|
|
continue;
|
|
};
|
|
|
|
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
|
|
if node.node.size() == Vec2::ZERO {
|
|
continue;
|
|
}
|
|
|
|
let pointers_on_this_cam = pointer_pos_by_camera.get(&camera_entity);
|
|
|
|
// Find the normalized cursor position relative to the node.
|
|
// (±0., 0.) is the center with the corners at points (±0.5, ±0.5).
|
|
// Coordinates are relative to the entire node, not just the visible region.
|
|
for (pointer_id, cursor_position) in pointers_on_this_cam.iter().flat_map(|h| h.iter()) {
|
|
if node.node.contains_point(*node.transform, *cursor_position)
|
|
&& clip_check_recursive(
|
|
*cursor_position,
|
|
*node_entity,
|
|
&clipping_query,
|
|
&child_of_query,
|
|
)
|
|
{
|
|
hit_nodes
|
|
.entry((camera_entity, *pointer_id))
|
|
.or_default()
|
|
.push((
|
|
*node_entity,
|
|
node.transform.inverse().transform_point2(*cursor_position)
|
|
/ node.node.size(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
for ((camera, pointer), hovered) in hit_nodes.iter() {
|
|
// 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 picks = Vec::new();
|
|
let mut depth = 0.0;
|
|
|
|
for (hovered_node, position) in hovered {
|
|
let node = node_query.get(*hovered_node).unwrap();
|
|
|
|
let Some(camera_entity) = node.target_camera.camera() else {
|
|
continue;
|
|
};
|
|
|
|
picks.push((
|
|
node.entity,
|
|
HitData::new(camera_entity, depth, Some(position.extend(0.0)), None),
|
|
));
|
|
|
|
if let Some(pickable) = node.pickable {
|
|
// If an entity has a `Pickable` component, we will use that as the source of truth.
|
|
if pickable.should_block_lower {
|
|
break;
|
|
}
|
|
} else {
|
|
// If the `Pickable` component doesn't exist, default behavior is to block.
|
|
break;
|
|
}
|
|
|
|
depth += 0.00001; // keep depth near 0 for precision
|
|
}
|
|
|
|
let order = camera_query
|
|
.get(*camera)
|
|
.map(|(_, cam, _)| cam.order)
|
|
.unwrap_or_default() as f32
|
|
+ 0.5; // bevy ui can run on any camera, it's a special case
|
|
|
|
output.write(PointerHits::new(*pointer, picks, order));
|
|
}
|
|
}
|
|
|
|
/// Walk up the tree child-to-parent checking that `point` is not clipped by any ancestor node.
|
|
pub fn clip_check_recursive(
|
|
point: Vec2,
|
|
entity: Entity,
|
|
clipping_query: &Query<'_, '_, (&ComputedNode, &UiGlobalTransform, &Node)>,
|
|
child_of_query: &Query<&ChildOf>,
|
|
) -> bool {
|
|
if let Ok(child_of) = child_of_query.get(entity) {
|
|
let parent = child_of.0;
|
|
if let Ok((computed_node, transform, node)) = clipping_query.get(parent) {
|
|
if !computed_node
|
|
.resolve_clip_rect(node.overflow, node.overflow_clip_margin)
|
|
.contains(transform.inverse().transform_point2(point))
|
|
{
|
|
// The point is clipped and should be ignored by picking
|
|
return false;
|
|
}
|
|
}
|
|
return clip_check_recursive(point, parent, clipping_query, child_of_query);
|
|
}
|
|
// Reached root, point unclipped by all ancestors
|
|
true
|
|
}
|