Skip to content

Commit 2e887b8

Browse files
authored
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: <img width="406" alt="outlines" src="https://github.com/bevyengine/bevy/assets/27962798/4e6f315a-019f-42a4-94ee-cca8e684d64a"> * 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 😅. 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`.
1 parent 202b9fc commit 2e887b8

File tree

5 files changed

+245
-15
lines changed

5 files changed

+245
-15
lines changed

crates/bevy_ui/src/layout/mod.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
mod convert;
22
pub mod debug;
33

4-
use crate::{ContentSize, Node, Style, UiScale};
4+
use crate::{ContentSize, Node, Outline, Style, UiScale};
55
use bevy_ecs::{
6-
change_detection::DetectChanges,
6+
change_detection::{DetectChanges, DetectChangesMut},
77
entity::Entity,
88
event::EventReader,
99
query::{With, Without},
@@ -386,6 +386,34 @@ pub fn ui_layout_system(
386386
}
387387
}
388388

389+
/// Resolve and update the widths of Node outlines
390+
pub fn resolve_outlines_system(
391+
primary_window: Query<&Window, With<PrimaryWindow>>,
392+
ui_scale: Res<UiScale>,
393+
mut outlines_query: Query<(&Outline, &mut Node)>,
394+
) {
395+
let viewport_size = primary_window
396+
.get_single()
397+
.map(|window| Vec2::new(window.resolution.width(), window.resolution.height()))
398+
.unwrap_or(Vec2::ZERO)
399+
/ ui_scale.0 as f32;
400+
401+
for (outline, mut node) in outlines_query.iter_mut() {
402+
let node = node.bypass_change_detection();
403+
node.outline_width = outline
404+
.width
405+
.resolve(node.size().x, viewport_size)
406+
.unwrap_or(0.)
407+
.max(0.);
408+
409+
node.outline_offset = outline
410+
.width
411+
.resolve(node.size().x, viewport_size)
412+
.unwrap_or(0.)
413+
.max(0.);
414+
}
415+
}
416+
389417
#[inline]
390418
/// Round `value` to the nearest whole integer, with ties (values with a fractional part equal to 0.5) rounded towards positive infinity.
391419
fn round_ties_up(value: f32) -> f32 {

crates/bevy_ui/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub enum UiSystem {
6767
Focus,
6868
/// After this label, the [`UiStack`] resource has been updated
6969
Stack,
70+
/// After this label, node outline widths have been updated
71+
Outlines,
7072
}
7173

7274
/// The current scale of the UI.
@@ -126,6 +128,7 @@ impl Plugin for UiPlugin {
126128
.register_type::<widget::Button>()
127129
.register_type::<widget::Label>()
128130
.register_type::<ZIndex>()
131+
.register_type::<Outline>()
129132
.add_systems(
130133
PreUpdate,
131134
ui_focus_system.in_set(UiSystem::Focus).after(InputSystem),
@@ -180,6 +183,9 @@ impl Plugin for UiPlugin {
180183
ui_layout_system
181184
.in_set(UiSystem::Layout)
182185
.before(TransformSystem::TransformPropagate),
186+
resolve_outlines_system
187+
.in_set(UiSystem::Outlines)
188+
.after(UiSystem::Layout),
183189
ui_stack_system.in_set(UiSystem::Stack),
184190
update_clipping_system.after(TransformSystem::TransformPropagate),
185191
),

crates/bevy_ui/src/render/mod.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use bevy_window::{PrimaryWindow, Window};
1010
pub use pipeline::*;
1111
pub use render_pass::*;
1212

13+
use crate::Outline;
1314
use crate::{
1415
prelude::UiCameraConfig, BackgroundColor, BorderColor, CalculatedClip, ContentSize, Node,
1516
Style, UiImage, UiScale, UiStack, UiTextureAtlasImage, Val,
@@ -85,6 +86,7 @@ pub fn build_ui_render(app: &mut App) {
8586
extract_uinode_borders.after(RenderUiSystem::ExtractAtlasNode),
8687
#[cfg(feature = "bevy_text")]
8788
extract_text_uinodes.after(RenderUiSystem::ExtractAtlasNode),
89+
extract_uinode_outlines.after(RenderUiSystem::ExtractAtlasNode),
8890
),
8991
)
9092
.add_systems(
@@ -389,6 +391,99 @@ pub fn extract_uinode_borders(
389391
}
390392
}
391393

394+
pub fn extract_uinode_outlines(
395+
mut commands: Commands,
396+
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
397+
ui_stack: Extract<Res<UiStack>>,
398+
uinode_query: Extract<
399+
Query<(
400+
&Node,
401+
&GlobalTransform,
402+
&Outline,
403+
&ViewVisibility,
404+
Option<&Parent>,
405+
)>,
406+
>,
407+
clip_query: Query<&CalculatedClip>,
408+
) {
409+
let image = AssetId::<Image>::default();
410+
411+
for (stack_index, entity) in ui_stack.uinodes.iter().enumerate() {
412+
if let Ok((node, global_transform, outline, view_visibility, maybe_parent)) =
413+
uinode_query.get(*entity)
414+
{
415+
// Skip invisible outlines
416+
if !view_visibility.get() || outline.color.a() == 0. || node.outline_width == 0. {
417+
continue;
418+
}
419+
420+
// 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.
421+
let clip = maybe_parent
422+
.and_then(|parent| clip_query.get(parent.get()).ok().map(|clip| clip.clip));
423+
424+
// Calculate the outline rects.
425+
let inner_rect =
426+
Rect::from_center_size(Vec2::ZERO, node.size() + 2. * node.outline_offset);
427+
let outer_rect = inner_rect.inset(node.outline_width());
428+
let outline_edges = [
429+
// Left edge
430+
Rect::new(
431+
outer_rect.min.x,
432+
outer_rect.min.y,
433+
inner_rect.min.x,
434+
outer_rect.max.y,
435+
),
436+
// Right edge
437+
Rect::new(
438+
inner_rect.max.x,
439+
outer_rect.min.y,
440+
outer_rect.max.x,
441+
outer_rect.max.y,
442+
),
443+
// Top edge
444+
Rect::new(
445+
inner_rect.min.x,
446+
outer_rect.min.y,
447+
inner_rect.max.x,
448+
inner_rect.min.y,
449+
),
450+
// Bottom edge
451+
Rect::new(
452+
inner_rect.min.x,
453+
inner_rect.max.y,
454+
inner_rect.max.x,
455+
outer_rect.max.y,
456+
),
457+
];
458+
459+
let transform = global_transform.compute_matrix();
460+
461+
for edge in outline_edges {
462+
if edge.min.x < edge.max.x && edge.min.y < edge.max.y {
463+
extracted_uinodes.uinodes.insert(
464+
commands.spawn_empty().id(),
465+
ExtractedUiNode {
466+
stack_index,
467+
// This translates the uinode's transform to the center of the current border rectangle
468+
transform: transform * Mat4::from_translation(edge.center().extend(0.)),
469+
color: outline.color,
470+
rect: Rect {
471+
max: edge.size(),
472+
..Default::default()
473+
},
474+
image,
475+
atlas_size: None,
476+
clip,
477+
flip_x: false,
478+
flip_y: false,
479+
},
480+
);
481+
}
482+
}
483+
}
484+
}
485+
}
486+
392487
pub fn extract_uinodes(
393488
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
394489
images: Extract<Res<Assets<Image>>>,

crates/bevy_ui/src/ui_node.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ pub struct Node {
1717
/// The size of the node as width and height in logical pixels
1818
/// automatically calculated by [`super::layout::ui_layout_system`]
1919
pub(crate) calculated_size: Vec2,
20+
/// The width of this node's outline
21+
/// If this value is `Auto`, negative or `0.` then no outline will be rendered
22+
/// automatically calculated by [`super::layout::resolve_outlines_system`]
23+
pub(crate) outline_width: f32,
24+
// The amount of space between the outline and the edge of the node
25+
pub(crate) outline_offset: f32,
2026
/// The unrounded size of the node as width and height in logical pixels
2127
/// automatically calculated by [`super::layout::ui_layout_system`]
2228
pub(crate) unrounded_size: Vec2,
@@ -70,11 +76,20 @@ impl Node {
7076
),
7177
}
7278
}
79+
80+
#[inline]
81+
/// Returns the thickness of the UI node's outline.
82+
/// If this value is negative or `0.` then no outline will be rendered.
83+
pub fn outline_width(&self) -> f32 {
84+
self.outline_width
85+
}
7386
}
7487

7588
impl Node {
7689
pub const DEFAULT: Self = Self {
7790
calculated_size: Vec2::ZERO,
91+
outline_width: 0.,
92+
outline_offset: 0.,
7893
unrounded_size: Vec2::ZERO,
7994
};
8095
}
@@ -1458,6 +1473,85 @@ impl Default for BorderColor {
14581473
}
14591474
}
14601475

1476+
#[derive(Component, Copy, Clone, Default, Debug, Reflect)]
1477+
#[reflect(Component, Default)]
1478+
/// The [`Outline`] component adds an outline outside the edge of a UI node.
1479+
/// Outlines do not take up space in the layout
1480+
///
1481+
/// To add an [`Outline`] to a ui node you can spawn a `(NodeBundle, Outline)` tuple bundle:
1482+
/// ```
1483+
/// # use bevy_ecs::prelude::*;
1484+
/// # use bevy_ui::prelude::*;
1485+
/// # use bevy_render::prelude::Color;
1486+
/// fn setup_ui(mut commands: Commands) {
1487+
/// commands.spawn((
1488+
/// NodeBundle {
1489+
/// style: Style {
1490+
/// width: Val::Px(100.),
1491+
/// height: Val::Px(100.),
1492+
/// ..Default::default()
1493+
/// },
1494+
/// background_color: Color::BLUE.into(),
1495+
/// ..Default::default()
1496+
/// },
1497+
/// Outline::new(Val::Px(10.), Val::ZERO, Color::RED)
1498+
/// ));
1499+
/// }
1500+
/// ```
1501+
///
1502+
/// [`Outline`] components can also be added later to existing UI nodes:
1503+
/// ```
1504+
/// # use bevy_ecs::prelude::*;
1505+
/// # use bevy_ui::prelude::*;
1506+
/// # use bevy_render::prelude::Color;
1507+
/// fn outline_hovered_button_system(
1508+
/// mut commands: Commands,
1509+
/// mut node_query: Query<(Entity, &Interaction, Option<&mut Outline>), Changed<Interaction>>,
1510+
/// ) {
1511+
/// for (entity, interaction, mut maybe_outline) in node_query.iter_mut() {
1512+
/// let outline_color =
1513+
/// if matches!(*interaction, Interaction::Hovered) {
1514+
/// Color::WHITE
1515+
/// } else {
1516+
/// Color::NONE
1517+
/// };
1518+
/// if let Some(mut outline) = maybe_outline {
1519+
/// outline.color = outline_color;
1520+
/// } else {
1521+
/// commands.entity(entity).insert(Outline::new(Val::Px(10.), Val::ZERO, outline_color));
1522+
/// }
1523+
/// }
1524+
/// }
1525+
/// ```
1526+
/// Inserting and removing an [`Outline`] component repeatedly will result in table moves, so it is generally preferable to
1527+
/// set `Outline::color` to `Color::NONE` to hide an outline.
1528+
pub struct Outline {
1529+
/// The width of the outline.
1530+
///
1531+
/// Percentage `Val` values are resolved based on the width of the outlined [`Node`]
1532+
pub width: Val,
1533+
/// The amount of space between a node's outline the edge of the node
1534+
///
1535+
/// Percentage `Val` values are resolved based on the width of the outlined [`Node`]
1536+
pub offset: Val,
1537+
/// Color of the outline
1538+
///
1539+
/// If you are frequently toggling outlines for a UI node on and off it is recommended to set `Color::None` to hide the outline.
1540+
/// This avoids the table moves that would occcur from the repeated insertion and removal of the `Outline` component.
1541+
pub color: Color,
1542+
}
1543+
1544+
impl Outline {
1545+
/// Create a new outline
1546+
pub const fn new(width: Val, offset: Val, color: Color) -> Self {
1547+
Self {
1548+
width,
1549+
offset,
1550+
color,
1551+
}
1552+
}
1553+
}
1554+
14611555
/// The 2D texture displayed for this UI node
14621556
#[derive(Component, Clone, Debug, Reflect, Default)]
14631557
#[reflect(Component, Default)]

examples/ui/borders.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ fn setup(mut commands: Commands) {
2323
align_content: AlignContent::FlexStart,
2424
..Default::default()
2525
},
26-
background_color: BackgroundColor(Color::BLACK),
26+
background_color: BackgroundColor(Color::DARK_GRAY),
2727
..Default::default()
2828
})
2929
.id();
@@ -97,20 +97,27 @@ fn setup(mut commands: Commands) {
9797
})
9898
.id();
9999
let bordered_node = commands
100-
.spawn(NodeBundle {
101-
style: Style {
102-
width: Val::Px(50.),
103-
height: Val::Px(50.),
104-
border: borders[i % borders.len()],
105-
margin: UiRect::all(Val::Px(2.)),
106-
align_items: AlignItems::Center,
107-
justify_content: JustifyContent::Center,
100+
.spawn((
101+
NodeBundle {
102+
style: Style {
103+
width: Val::Px(50.),
104+
height: Val::Px(50.),
105+
border: borders[i % borders.len()],
106+
margin: UiRect::all(Val::Px(20.)),
107+
align_items: AlignItems::Center,
108+
justify_content: JustifyContent::Center,
109+
..Default::default()
110+
},
111+
background_color: Color::MAROON.into(),
112+
border_color: Color::RED.into(),
108113
..Default::default()
109114
},
110-
background_color: Color::BLUE.into(),
111-
border_color: Color::WHITE.with_a(0.5).into(),
112-
..Default::default()
113-
})
115+
Outline {
116+
width: Val::Px(6.),
117+
offset: Val::Px(6.),
118+
color: Color::WHITE,
119+
},
120+
))
114121
.add_child(inner_spot)
115122
.id();
116123
commands.entity(root).add_child(bordered_node);

0 commit comments

Comments
 (0)