From 50ad37376169546cca6d4d4bff51237ca730a2e5 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Thu, 5 Oct 2023 13:10:32 +0100 Subject: [PATCH] UI node outlines (#9931) # Objective Add support for drawing outlines outside the borders of UI nodes. ## Solution Add a new `Outline` component with `width`, `offset` and `color` fields. Added `outline_width` and `outline_offset` fields to `Node`. This is set after layout recomputation by the `resolve_outlines_system`. Properties of outlines: * Unlike borders, outlines have to be the same width on each edge. * Outlines do not occupy any space in the layout. * The `Outline` component won't be added to any of the UI node bundles, it needs to be inserted separately. * Outlines are drawn outside the node's border, so they are clipped using the clipping rect of their entity's parent UI node (if it exists). * `Val::Percent` outline widths are resolved based on the width of the outlined UI node. * The offset of the `Outline` adds space between an outline and the edge of its node. I was leaning towards adding an `outline` field to `Style` but a separate component seems more efficient for queries and change detection. The `Outline` component isn't added to bundles for the same reason. --- ## Examples * This image is from the `borders` example from the Bevy UI examples but modified to include outlines. The UI nodes are the dark red rectangles, the bright red rectangles are borders and the white lines offset from each node are the outlines. The yellow rectangles are separate nodes contained with the dark red nodes: outlines * This is from the same example but using a branch that implements border-radius. Here the the outlines are in orange and there is no offset applied. I broke the borders implementation somehow during the merge, which is why some of the borders from the first screenshot are missing :sweat_smile:. The outlines work nicely though (as long as you can forgive the lack of anti-aliasing): ![image](https://github.com/bevyengine/bevy/assets/27962798/d15560b6-6cd6-42e5-907b-56ccf2ad5e02) --- ## Notes As I explained above, I don't think the `Outline` component should be added to UI node bundles. We can have helper functions though, perhaps something as simple as: ```rust impl NodeBundle { pub fn with_outline(self, outline: Outline) -> (Self, Outline) { (self, outline) } } ``` I didn't include anything like this as I wanted to keep the PR's scope as narrow as possible. Maybe `with_outline` should be in a trait that we implement for each UI node bundle. --- ## Changelog Added support for outlines to Bevy UI. * The `Outline` component adds an outline to a UI node. * The `outline_width` field added to `Node` holds the resolved width of the outline, which is set by the `resolve_outlines_system` after layout recomputation. * Outlines are drawn by the system `extract_uinode_outlines`. --- crates/bevy_ui/src/layout/mod.rs | 32 ++++++++++- crates/bevy_ui/src/lib.rs | 6 ++ crates/bevy_ui/src/render/mod.rs | 95 ++++++++++++++++++++++++++++++++ crates/bevy_ui/src/ui_node.rs | 94 +++++++++++++++++++++++++++++++ examples/ui/borders.rs | 33 ++++++----- 5 files changed, 245 insertions(+), 15 deletions(-) diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index a38209e283a8a8..e94dae3fa34820 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,9 +1,9 @@ mod convert; pub mod debug; -use crate::{ContentSize, Node, Style, UiScale}; +use crate::{ContentSize, Node, Outline, Style, UiScale}; use bevy_ecs::{ - change_detection::DetectChanges, + change_detection::{DetectChanges, DetectChangesMut}, entity::Entity, event::EventReader, query::{With, Without}, @@ -386,6 +386,34 @@ pub fn ui_layout_system( } } +/// Resolve and update the widths of Node outlines +pub fn resolve_outlines_system( + primary_window: Query<&Window, With>, + ui_scale: Res, + mut outlines_query: Query<(&Outline, &mut Node)>, +) { + let viewport_size = primary_window + .get_single() + .map(|window| Vec2::new(window.resolution.width(), window.resolution.height())) + .unwrap_or(Vec2::ZERO) + / ui_scale.0 as f32; + + for (outline, mut node) in outlines_query.iter_mut() { + let node = node.bypass_change_detection(); + node.outline_width = outline + .width + .resolve(node.size().x, viewport_size) + .unwrap_or(0.) + .max(0.); + + node.outline_offset = outline + .width + .resolve(node.size().x, viewport_size) + .unwrap_or(0.) + .max(0.); + } +} + #[inline] /// Round `value` to the nearest whole integer, with ties (values with a fractional part equal to 0.5) rounded towards positive infinity. fn round_ties_up(value: f32) -> f32 { diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 1cee4a52a1c050..ddfe5f430f4463 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -67,6 +67,8 @@ pub enum UiSystem { Focus, /// After this label, the [`UiStack`] resource has been updated Stack, + /// After this label, node outline widths have been updated + Outlines, } /// The current scale of the UI. @@ -126,6 +128,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .add_systems( PreUpdate, ui_focus_system.in_set(UiSystem::Focus).after(InputSystem), @@ -180,6 +183,9 @@ impl Plugin for UiPlugin { ui_layout_system .in_set(UiSystem::Layout) .before(TransformSystem::TransformPropagate), + resolve_outlines_system + .in_set(UiSystem::Outlines) + .after(UiSystem::Layout), ui_stack_system.in_set(UiSystem::Stack), update_clipping_system.after(TransformSystem::TransformPropagate), ), diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index f962298b9b1d92..5c5aca45db17d1 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -10,6 +10,7 @@ use bevy_window::{PrimaryWindow, Window}; pub use pipeline::*; pub use render_pass::*; +use crate::Outline; use crate::{ prelude::UiCameraConfig, BackgroundColor, BorderColor, CalculatedClip, ContentSize, Node, Style, UiImage, UiScale, UiStack, UiTextureAtlasImage, Val, @@ -85,6 +86,7 @@ pub fn build_ui_render(app: &mut App) { extract_uinode_borders.after(RenderUiSystem::ExtractAtlasNode), #[cfg(feature = "bevy_text")] extract_text_uinodes.after(RenderUiSystem::ExtractAtlasNode), + extract_uinode_outlines.after(RenderUiSystem::ExtractAtlasNode), ), ) .add_systems( @@ -389,6 +391,99 @@ pub fn extract_uinode_borders( } } +pub fn extract_uinode_outlines( + mut commands: Commands, + mut extracted_uinodes: ResMut, + ui_stack: Extract>, + uinode_query: Extract< + Query<( + &Node, + &GlobalTransform, + &Outline, + &ViewVisibility, + Option<&Parent>, + )>, + >, + clip_query: Query<&CalculatedClip>, +) { + let image = AssetId::::default(); + + for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() { + if let Ok((node, global_transform, outline, view_visibility, maybe_parent)) = + uinode_query.get(*entity) + { + // Skip invisible outlines + if !view_visibility.get() || outline.color.a() == 0. || node.outline_width == 0. { + continue; + } + + // Outline's are drawn outside of a node's borders, so they are clipped using the clipping Rect of their UI node entity's parent. + let clip = maybe_parent + .and_then(|parent| clip_query.get(parent.get()).ok().map(|clip| clip.clip)); + + // Calculate the outline rects. + let inner_rect = + Rect::from_center_size(Vec2::ZERO, node.size() + 2. * node.outline_offset); + let outer_rect = inner_rect.inset(node.outline_width()); + let outline_edges = [ + // Left edge + Rect::new( + outer_rect.min.x, + outer_rect.min.y, + inner_rect.min.x, + outer_rect.max.y, + ), + // Right edge + Rect::new( + inner_rect.max.x, + outer_rect.min.y, + outer_rect.max.x, + outer_rect.max.y, + ), + // Top edge + Rect::new( + inner_rect.min.x, + outer_rect.min.y, + inner_rect.max.x, + inner_rect.min.y, + ), + // Bottom edge + Rect::new( + inner_rect.min.x, + inner_rect.max.y, + inner_rect.max.x, + outer_rect.max.y, + ), + ]; + + let transform = global_transform.compute_matrix(); + + for edge in outline_edges { + if edge.min.x < edge.max.x && edge.min.y < edge.max.y { + extracted_uinodes.uinodes.insert( + commands.spawn_empty().id(), + ExtractedUiNode { + stack_index, + // This translates the uinode's transform to the center of the current border rectangle + transform: transform * Mat4::from_translation(edge.center().extend(0.)), + color: outline.color, + rect: Rect { + max: edge.size(), + ..Default::default() + }, + image, + atlas_size: None, + clip, + flip_x: false, + flip_y: false, + }, + ); + } + } + } + } +} + pub fn extract_uinodes( mut extracted_uinodes: ResMut, images: Extract>>, diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 277ce3f760759a..7b6209c6858303 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -17,6 +17,12 @@ pub struct Node { /// The size of the node as width and height in logical pixels /// automatically calculated by [`super::layout::ui_layout_system`] pub(crate) calculated_size: Vec2, + /// The width of this node's outline + /// If this value is `Auto`, negative or `0.` then no outline will be rendered + /// automatically calculated by [`super::layout::resolve_outlines_system`] + pub(crate) outline_width: f32, + // The amount of space between the outline and the edge of the node + pub(crate) outline_offset: f32, /// The unrounded size of the node as width and height in logical pixels /// automatically calculated by [`super::layout::ui_layout_system`] pub(crate) unrounded_size: Vec2, @@ -70,11 +76,20 @@ impl Node { ), } } + + #[inline] + /// Returns the thickness of the UI node's outline. + /// If this value is negative or `0.` then no outline will be rendered. + pub fn outline_width(&self) -> f32 { + self.outline_width + } } impl Node { pub const DEFAULT: Self = Self { calculated_size: Vec2::ZERO, + outline_width: 0., + outline_offset: 0., unrounded_size: Vec2::ZERO, }; } @@ -1458,6 +1473,85 @@ impl Default for BorderColor { } } +#[derive(Component, Copy, Clone, Default, Debug, Reflect)] +#[reflect(Component, Default)] +/// The [`Outline`] component adds an outline outside the edge of a UI node. +/// Outlines do not take up space in the layout +/// +/// To add an [`Outline`] to a ui node you can spawn a `(NodeBundle, Outline)` tuple bundle: +/// ``` +/// # use bevy_ecs::prelude::*; +/// # use bevy_ui::prelude::*; +/// # use bevy_render::prelude::Color; +/// fn setup_ui(mut commands: Commands) { +/// commands.spawn(( +/// NodeBundle { +/// style: Style { +/// width: Val::Px(100.), +/// height: Val::Px(100.), +/// ..Default::default() +/// }, +/// background_color: Color::BLUE.into(), +/// ..Default::default() +/// }, +/// Outline::new(Val::Px(10.), Val::ZERO, Color::RED) +/// )); +/// } +/// ``` +/// +/// [`Outline`] components can also be added later to existing UI nodes: +/// ``` +/// # use bevy_ecs::prelude::*; +/// # use bevy_ui::prelude::*; +/// # use bevy_render::prelude::Color; +/// fn outline_hovered_button_system( +/// mut commands: Commands, +/// mut node_query: Query<(Entity, &Interaction, Option<&mut Outline>), Changed>, +/// ) { +/// for (entity, interaction, mut maybe_outline) in node_query.iter_mut() { +/// let outline_color = +/// if matches!(*interaction, Interaction::Hovered) { +/// Color::WHITE +/// } else { +/// Color::NONE +/// }; +/// if let Some(mut outline) = maybe_outline { +/// outline.color = outline_color; +/// } else { +/// commands.entity(entity).insert(Outline::new(Val::Px(10.), Val::ZERO, outline_color)); +/// } +/// } +/// } +/// ``` +/// Inserting and removing an [`Outline`] component repeatedly will result in table moves, so it is generally preferable to +/// set `Outline::color` to `Color::NONE` to hide an outline. +pub struct Outline { + /// The width of the outline. + /// + /// Percentage `Val` values are resolved based on the width of the outlined [`Node`] + pub width: Val, + /// The amount of space between a node's outline the edge of the node + /// + /// Percentage `Val` values are resolved based on the width of the outlined [`Node`] + pub offset: Val, + /// Color of the outline + /// + /// If you are frequently toggling outlines for a UI node on and off it is recommended to set `Color::None` to hide the outline. + /// This avoids the table moves that would occcur from the repeated insertion and removal of the `Outline` component. + pub color: Color, +} + +impl Outline { + /// Create a new outline + pub const fn new(width: Val, offset: Val, color: Color) -> Self { + Self { + width, + offset, + color, + } + } +} + /// The 2D texture displayed for this UI node #[derive(Component, Clone, Debug, Reflect, Default)] #[reflect(Component, Default)] diff --git a/examples/ui/borders.rs b/examples/ui/borders.rs index 9e6cf4e2b32894..32f43d7c6a0b2b 100644 --- a/examples/ui/borders.rs +++ b/examples/ui/borders.rs @@ -23,7 +23,7 @@ fn setup(mut commands: Commands) { align_content: AlignContent::FlexStart, ..Default::default() }, - background_color: BackgroundColor(Color::BLACK), + background_color: BackgroundColor(Color::DARK_GRAY), ..Default::default() }) .id(); @@ -97,20 +97,27 @@ fn setup(mut commands: Commands) { }) .id(); let bordered_node = commands - .spawn(NodeBundle { - style: Style { - width: Val::Px(50.), - height: Val::Px(50.), - border: borders[i % borders.len()], - margin: UiRect::all(Val::Px(2.)), - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, + .spawn(( + NodeBundle { + style: Style { + width: Val::Px(50.), + height: Val::Px(50.), + border: borders[i % borders.len()], + margin: UiRect::all(Val::Px(20.)), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..Default::default() + }, + background_color: Color::MAROON.into(), + border_color: Color::RED.into(), ..Default::default() }, - background_color: Color::BLUE.into(), - border_color: Color::WHITE.with_a(0.5).into(), - ..Default::default() - }) + Outline { + width: Val::Px(6.), + offset: Val::Px(6.), + color: Color::WHITE, + }, + )) .add_child(inner_spot) .id(); commands.entity(root).add_child(bordered_node);