Ignore clicks on uinodes outside of rounded corners (#14957)

# Objective

Fixes #14941

## Solution
1. Add a `resolved_border_radius` field to `Node` to hold the resolved
border radius values.
2. Remove the border radius calculations from the UI's extraction
functions.
4. Compute the border radius during UI relayouts in `ui_layout_system`
and store them in `Node`.
5. New `pick_rounded_rect` function based on the border radius SDF from
`ui.wgsl`.
6. Use `pick_rounded_rect` in `focus` and `picking_backend` to check if
the pointer is hovering UI nodes with rounded corners.
---

## Showcase

```
cargo run --example button
```


https://github.com/user-attachments/assets/ea951a64-17ef-455e-b5c9-a2e6f6360648

## Testing

Modified button example with buttons with different corner radius:

```
use bevy::{color::palettes::basic::*, 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())
        .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 interaction_query: Query<
        (
            &Interaction,
            &mut BackgroundColor,
            &mut BorderColor,
            &Children,
        ),
        (Changed<Interaction>, With<Button>),
    >,
    mut text_query: Query<&mut Text>,
) {
    for (interaction, mut color, mut border_color, children) in &mut interaction_query {
        let mut text = text_query.get_mut(children[0]).unwrap();
        match *interaction {
            Interaction::Pressed => {
                text.sections[0].value = "Press".to_string();
                *color = PRESSED_BUTTON.into();
                border_color.0 = RED.into();
            }
            Interaction::Hovered => {
                text.sections[0].value = "Hover".to_string();
                *color = HOVERED_BUTTON.into();
                border_color.0 = Color::WHITE;
            }
            Interaction::None => {
                text.sections[0].value = "Button".to_string();
                *color = NORMAL_BUTTON.into();
                border_color.0 = Color::BLACK;
            }
        }
    }
}

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // ui camera
    commands.spawn(Camera2dBundle::default());
    commands
        .spawn(NodeBundle {
            style: Style {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                align_items: AlignItems::Center,
                justify_content: JustifyContent::Center,
                row_gap: Val::Px(10.),
                ..default()
            },
            ..default()
        })
        .with_children(|parent| {
            for border_radius in [
                BorderRadius {
                    top_left: Val::ZERO,
                    ..BorderRadius::MAX
                },
                BorderRadius {
                    top_right: Val::ZERO,
                    ..BorderRadius::MAX
                },
                BorderRadius {
                    bottom_right: Val::ZERO,
                    ..BorderRadius::MAX
                },
                BorderRadius {
                    bottom_left: Val::ZERO,
                    ..BorderRadius::MAX
                },
            ] {
                parent
                    .spawn(ButtonBundle {
                        style: Style {
                            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()
                        },
                        border_color: BorderColor(Color::BLACK),
                        border_radius,
                        background_color: NORMAL_BUTTON.into(),
                        ..default()
                    })
                    .with_child(TextBundle::from_section(
                        "Button",
                        TextStyle {
                            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                            font_size: 40.0,
                            color: Color::srgb(0.9, 0.9, 0.9),
                        },
                    ));
            }
        });
}
```

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Matty <weatherleymatthew@gmail.com>
This commit is contained in:
ickshonpe 2024-09-03 13:38:59 +01:00 committed by GitHub
parent 32f40f11b5
commit 4e9a62f094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 additions and 108 deletions

View file

@ -1,4 +1,7 @@
use crate::{CalculatedClip, DefaultUiCamera, Node, TargetCamera, UiScale, UiStack};
use crate::{
picking_backend::pick_rounded_rect, CalculatedClip, DefaultUiCamera, Node, TargetCamera,
UiScale, UiStack,
};
use bevy_ecs::{
change_detection::DetectChangesMut,
entity::Entity,
@ -249,19 +252,18 @@ pub fn ui_focus_system(
.map(|clip| node_rect.intersect(clip.clip))
.unwrap_or(node_rect);
let cursor_position = camera_cursor_positions.get(&camera_entity);
// The mouse position relative to the node
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner
// Coordinates are relative to the entire node, not just the visible region.
let relative_cursor_position =
camera_cursor_positions
.get(&camera_entity)
.and_then(|cursor_position| {
// ensure node size is non-zero in all dimensions, otherwise relative position will be
// +/-inf. if the node is hidden, the visible rect min/max will also be -inf leading to
// false positives for mouse_over (#12395)
(node_rect.size().cmpgt(Vec2::ZERO).all())
.then_some((*cursor_position - node_rect.min) / node_rect.size())
});
let relative_cursor_position = cursor_position.and_then(|cursor_position| {
// ensure node size is non-zero in all dimensions, otherwise relative position will be
// +/-inf. if the node is hidden, the visible rect min/max will also be -inf leading to
// false positives for mouse_over (#12395)
(node_rect.size().cmpgt(Vec2::ZERO).all())
.then_some((*cursor_position - node_rect.min) / node_rect.size())
});
// If the current cursor position is within the bounds of the node's visible area, consider it for
// clicking
@ -270,7 +272,16 @@ pub fn ui_focus_system(
normalized: relative_cursor_position,
};
let contains_cursor = relative_cursor_position_component.mouse_over();
let contains_cursor = relative_cursor_position_component.mouse_over()
&& cursor_position
.map(|point| {
pick_rounded_rect(
*point - node_rect.center(),
node_rect.size(),
node.node.border_radius,
)
})
.unwrap_or(false);
// Save the relative cursor position to the correct component
if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position

View file

@ -1,4 +1,6 @@
use crate::{ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale};
use crate::{
BorderRadius, ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale,
};
use bevy_ecs::{
change_detection::{DetectChanges, DetectChangesMut},
entity::{Entity, EntityHashMap, EntityHashSet},
@ -110,7 +112,12 @@ pub fn ui_layout_system(
children_query: Query<(Entity, Ref<Children>), With<Node>>,
just_children_query: Query<&Children>,
mut removed_components: UiLayoutSystemRemovedComponentParam,
mut node_transform_query: Query<(&mut Node, &mut Transform, Option<&Outline>)>,
mut node_transform_query: Query<(
&mut Node,
&mut Transform,
Option<&BorderRadius>,
Option<&Outline>,
)>,
#[cfg(feature = "bevy_text")] mut buffer_query: Query<&mut CosmicBuffer>,
#[cfg(feature = "bevy_text")] mut text_pipeline: ResMut<TextPipeline>,
) {
@ -280,13 +287,20 @@ pub fn ui_layout_system(
entity: Entity,
ui_surface: &UiSurface,
root_size: Option<Vec2>,
node_transform_query: &mut Query<(&mut Node, &mut Transform, Option<&Outline>)>,
node_transform_query: &mut Query<(
&mut Node,
&mut Transform,
Option<&BorderRadius>,
Option<&Outline>,
)>,
children_query: &Query<&Children>,
inverse_target_scale_factor: f32,
parent_size: Vec2,
mut absolute_location: Vec2,
) {
if let Ok((mut node, mut transform, outline)) = node_transform_query.get_mut(entity) {
if let Ok((mut node, mut transform, maybe_border_radius, maybe_outline)) =
node_transform_query.get_mut(entity)
{
let Ok(layout) = ui_surface.get_layout(entity) else {
return;
};
@ -311,7 +325,13 @@ pub fn ui_layout_system(
let viewport_size = root_size.unwrap_or(node.calculated_size);
if let Some(outline) = outline {
if let Some(border_radius) = maybe_border_radius {
// We don't trigger change detection for changes to border radius
node.bypass_change_detection().border_radius =
border_radius.resolve(node.calculated_size, viewport_size);
}
if let Some(outline) = maybe_outline {
// don't trigger change detection when only outlines are changed
let node = node.bypass_change_detection();
node.outline_width = outline

View file

@ -163,6 +163,11 @@ pub fn ui_picking(
if visible_rect
.normalize(node_rect)
.contains(relative_cursor_position)
&& pick_rounded_rect(
*cursor_position - node_rect.center(),
node_rect.size(),
node.node.border_radius,
)
{
hit_nodes
.entry((camera_entity, *pointer_id))
@ -212,3 +217,26 @@ pub fn ui_picking(
output.send(PointerHits::new(*pointer, picks, order));
}
}
// Returns true if `point` (relative to the rectangle's center) is within the bounds of a rounded rectangle with
// the given size and border radius.
//
// Matches the sdf function in `ui.wgsl` that is used by the UI renderer to draw rounded rectangles.
pub(crate) fn pick_rounded_rect(
point: Vec2,
size: Vec2,
border_radius: ResolvedBorderRadius,
) -> bool {
let s = point.signum();
let r = (border_radius.top_left * (1. - s.x) * (1. - s.y)
+ border_radius.top_right * (1. + s.x) * (1. - s.y)
+ border_radius.bottom_right * (1. + s.x) * (1. + s.y)
+ border_radius.bottom_left * (1. - s.x) * (1. + s.y))
/ 4.;
let corner_to_point = point.abs() - 0.5 * size;
let q = corner_to_point + r;
let l = q.max(Vec2::ZERO).length();
let m = q.max_element().min(0.);
l + m - r < 0.
}

View file

@ -24,8 +24,8 @@ use ui_texture_slice_pipeline::UiTextureSlicerPlugin;
use crate::graph::{NodeUi, SubGraphUi};
use crate::{
BackgroundColor, BorderColor, BorderRadius, CalculatedClip, ContentSize, DefaultUiCamera, Node,
Outline, Style, TargetCamera, UiImage, UiScale, Val,
BackgroundColor, BorderColor, CalculatedClip, ContentSize, DefaultUiCamera, Node, Outline,
Style, TargetCamera, UiImage, UiScale, Val,
};
use bevy_app::prelude::*;
@ -202,7 +202,6 @@ pub fn extract_uinode_background_colors(
Option<&CalculatedClip>,
Option<&TargetCamera>,
&BackgroundColor,
Option<&BorderRadius>,
&Style,
Option<&Parent>,
)>,
@ -217,7 +216,6 @@ pub fn extract_uinode_background_colors(
clip,
camera,
background_color,
border_radius,
style,
parent,
) in &uinode_query
@ -258,16 +256,13 @@ pub fn extract_uinode_background_colors(
let border = [left, top, right, bottom];
let border_radius = if let Some(border_radius) = border_radius {
resolve_border_radius(
border_radius,
uinode.size(),
ui_logical_viewport_size,
ui_scale.0,
)
} else {
[0.; 4]
};
let border_radius = [
uinode.border_radius.top_left,
uinode.border_radius.top_right,
uinode.border_radius.bottom_right,
uinode.border_radius.bottom_left,
]
.map(|r| r * ui_scale.0);
extracted_uinodes.uinodes.insert(
entity,
@ -311,7 +306,6 @@ pub fn extract_uinode_images(
Option<&TargetCamera>,
&UiImage,
Option<&TextureAtlas>,
Option<&BorderRadius>,
Option<&Parent>,
&Style,
),
@ -320,18 +314,8 @@ pub fn extract_uinode_images(
>,
node_query: Extract<Query<&Node>>,
) {
for (
uinode,
transform,
view_visibility,
clip,
camera,
image,
atlas,
border_radius,
parent,
style,
) in &uinode_query
for (uinode, transform, view_visibility, clip, camera, image, atlas, parent, style) in
&uinode_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else {
@ -393,16 +377,13 @@ pub fn extract_uinode_images(
let border = [left, top, right, bottom];
let border_radius = if let Some(border_radius) = border_radius {
resolve_border_radius(
border_radius,
uinode.size(),
ui_logical_viewport_size,
ui_scale.0,
)
} else {
[0.; 4]
};
let border_radius = [
uinode.border_radius.top_left,
uinode.border_radius.top_right,
uinode.border_radius.bottom_right,
uinode.border_radius.bottom_left,
]
.map(|r| r * ui_scale.0);
extracted_uinodes.uinodes.insert(
commands.spawn_empty().id(),
@ -437,33 +418,6 @@ pub(crate) fn resolve_border_thickness(value: Val, parent_width: f32, viewport_s
}
}
pub(crate) fn resolve_border_radius(
&values: &BorderRadius,
node_size: Vec2,
viewport_size: Vec2,
ui_scale: f32,
) -> [f32; 4] {
let max_radius = 0.5 * node_size.min_element() * ui_scale;
[
values.top_left,
values.top_right,
values.bottom_right,
values.bottom_left,
]
.map(|value| {
match value {
Val::Auto => 0.,
Val::Px(px) => ui_scale * px,
Val::Percent(percent) => node_size.min_element() * percent / 100.,
Val::Vw(percent) => viewport_size.x * percent / 100.,
Val::Vh(percent) => viewport_size.y * percent / 100.,
Val::VMin(percent) => viewport_size.min_element() * percent / 100.,
Val::VMax(percent) => viewport_size.max_element() * percent / 100.,
}
.clamp(0., max_radius)
})
}
#[inline]
fn clamp_corner(r: f32, size: Vec2, offset: Vec2) -> f32 {
let s = 0.5 * size + offset;
@ -503,7 +457,6 @@ pub fn extract_uinode_borders(
Option<&Parent>,
&Style,
&BorderColor,
Option<&BorderRadius>,
),
Without<ContentSize>,
>,
@ -512,17 +465,8 @@ pub fn extract_uinode_borders(
) {
let image = AssetId::<Image>::default();
for (
node,
global_transform,
view_visibility,
clip,
camera,
parent,
style,
border_color,
maybe_border_radius,
) in &uinode_query
for (uinode, global_transform, view_visibility, clip, camera, parent, style, border_color) in
&uinode_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else {
@ -532,8 +476,8 @@ pub fn extract_uinode_borders(
// Skip invisible borders
if !view_visibility.get()
|| border_color.0.is_fully_transparent()
|| node.size().x <= 0.
|| node.size().y <= 0.
|| uinode.size().x <= 0.
|| uinode.size().y <= 0.
{
continue;
}
@ -569,28 +513,26 @@ pub fn extract_uinode_borders(
continue;
}
let border_radius = if let Some(border_radius) = maybe_border_radius {
let resolved_radius = resolve_border_radius(
border_radius,
node.size(),
ui_logical_viewport_size,
ui_scale.0,
);
clamp_radius(resolved_radius, node.size(), border.into())
} else {
[0.; 4]
};
let border_radius = [
uinode.border_radius.top_left,
uinode.border_radius.top_right,
uinode.border_radius.bottom_right,
uinode.border_radius.bottom_left,
]
.map(|r| r * ui_scale.0);
let border_radius = clamp_radius(border_radius, uinode.size(), border.into());
let transform = global_transform.compute_matrix();
extracted_uinodes.uinodes.insert(
commands.spawn_empty().id(),
ExtractedUiNode {
stack_index: node.stack_index,
stack_index: uinode.stack_index,
// This translates the uinode's transform to the center of the current border rectangle
transform,
color: border_color.0.into(),
rect: Rect {
max: node.size(),
max: uinode.size(),
..Default::default()
},
image,

View file

@ -48,6 +48,11 @@ pub struct Node {
///
/// Automatically calculated by [`super::layout::ui_layout_system`].
pub(crate) unrounded_size: Vec2,
/// Resolved border radius values in logical pixels.
/// Border radius updates bypass change detection.
///
/// Automatically calculated by [`super::layout::ui_layout_system`].
pub(crate) border_radius: ResolvedBorderRadius,
}
impl Node {
@ -122,6 +127,7 @@ impl Node {
outline_width: 0.,
outline_offset: 0.,
unrounded_size: Vec2::ZERO,
border_radius: ResolvedBorderRadius::ZERO,
};
}
@ -2188,6 +2194,49 @@ impl BorderRadius {
self.bottom_right = radius;
self
}
/// Compute the logical border radius for a single corner from the given values
pub fn resolve_single_corner(radius: Val, node_size: Vec2, viewport_size: Vec2) -> f32 {
match radius {
Val::Auto => 0.,
Val::Px(px) => px,
Val::Percent(percent) => node_size.min_element() * percent / 100.,
Val::Vw(percent) => viewport_size.x * percent / 100.,
Val::Vh(percent) => viewport_size.y * percent / 100.,
Val::VMin(percent) => viewport_size.min_element() * percent / 100.,
Val::VMax(percent) => viewport_size.max_element() * percent / 100.,
}
.clamp(0., 0.5 * node_size.min_element())
}
pub fn resolve(&self, node_size: Vec2, viewport_size: Vec2) -> ResolvedBorderRadius {
ResolvedBorderRadius {
top_left: Self::resolve_single_corner(self.top_left, node_size, viewport_size),
top_right: Self::resolve_single_corner(self.top_right, node_size, viewport_size),
bottom_left: Self::resolve_single_corner(self.bottom_left, node_size, viewport_size),
bottom_right: Self::resolve_single_corner(self.bottom_right, node_size, viewport_size),
}
}
}
/// Represents the resolved border radius values for a UI node.
///
/// The values are in logical pixels.
#[derive(Copy, Clone, Debug, PartialEq, Reflect)]
pub struct ResolvedBorderRadius {
pub top_left: f32,
pub top_right: f32,
pub bottom_left: f32,
pub bottom_right: f32,
}
impl ResolvedBorderRadius {
pub const ZERO: Self = Self {
top_left: 0.,
top_right: 0.,
bottom_left: 0.,
bottom_right: 0.,
};
}
#[cfg(test)]