
# Objective Picking was changed in the UI transform PR to walk up the tree recursively to check if an interaction was on a clipped node. `OverrideClip` only affects a node's local clipping rect, so valid interactions can be ignored if a node has clipped ancestors. ## Solution Add a `Without<OverrideClip>` query filter to the picking systems' `child_of_query`s. ## Testing This modified `button` example can be used to test the change: ```rust //! This example illustrates how to create a button that changes color and text based on its //! interaction state. use bevy::{color::palettes::basic::*, input_focus::InputFocus, prelude::*, winit::WinitSettings}; fn main() { App::new() .add_plugins(DefaultPlugins) // Only run the app when there is user input. This will significantly reduce CPU/GPU use. .insert_resource(WinitSettings::desktop_app()) // `InputFocus` must be set for accessibility to recognize the button. .init_resource::<InputFocus>() .add_systems(Startup, setup) .add_systems(Update, button_system) .run(); } const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15); const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); fn button_system( mut input_focus: ResMut<InputFocus>, mut interaction_query: Query< ( Entity, &Interaction, &mut BackgroundColor, &mut BorderColor, &mut Button, &Children, ), Changed<Interaction>, >, mut text_query: Query<&mut Text>, ) { for (entity, interaction, mut color, mut border_color, mut button, children) in &mut interaction_query { let mut text = text_query.get_mut(children[0]).unwrap(); match *interaction { Interaction::Pressed => { input_focus.set(entity); **text = "Press".to_string(); *color = PRESSED_BUTTON.into(); *border_color = BorderColor::all(RED.into()); // The accessibility system's only update the button's state when the `Button` component is marked as changed. button.set_changed(); } Interaction::Hovered => { input_focus.set(entity); **text = "Hover".to_string(); *color = HOVERED_BUTTON.into(); *border_color = BorderColor::all(Color::WHITE); button.set_changed(); } Interaction::None => { input_focus.clear(); **text = "Button".to_string(); *color = NORMAL_BUTTON.into(); *border_color = BorderColor::all(Color::BLACK); } } } } fn setup(mut commands: Commands, assets: Res<AssetServer>) { // ui camera commands.spawn(Camera2d); commands.spawn(button(&assets)); } fn button(asset_server: &AssetServer) -> impl Bundle + use<> { ( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, children![( Node { width: Val::Px(0.), height: Val::Px(0.), overflow: Overflow::clip(), ..default() }, children![( //OverrideClip, Button, Node { position_type: PositionType::Absolute, width: Val::Px(150.0), height: Val::Px(65.0), border: UiRect::all(Val::Px(5.0)), // horizontally center child text justify_content: JustifyContent::Center, // vertically center child text align_items: AlignItems::Center, ..default() }, BorderColor::all(Color::WHITE), BorderRadius::MAX, BackgroundColor(Color::BLACK), children![( Text::new("Button"), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 33.0, ..default() }, TextColor(Color::srgb(0.9, 0.9, 0.9)), TextShadow::default(), )] )], )], ) } ``` On main the button ignores interactions, with this PR it should respond correctly. --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
259 lines
9.7 KiB
Rust
259 lines
9.7 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::{clip_check_recursive, 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, Without<OverrideClip>>,
|
|
) {
|
|
// 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() {
|
|
if !viewport.as_rect().contains(pointer_pos) {
|
|
// The pointer is outside the viewport, skip it
|
|
continue;
|
|
}
|
|
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));
|
|
}
|
|
}
|