Add border radius to UI nodes (adopted) (#12500)

# Objective

Implements border radius for UI nodes. Adopted from #8973, but excludes
shadows.

## Solution

- Add a component `BorderRadius` which contains a radius value for each
corner of the UI node.
- Use a fragment shader to generate the rounded corners using a signed
distance function.

<img width="50%"
src="https://github.com/bevyengine/bevy/assets/26204416/16b2ba95-e274-4ce7-adb2-34cc41a776a5"></img>

## Changelog

- `BorderRadius`: New component that holds the border radius values.
- `NodeBundle` & `ButtonBundle`: Added a `border_radius: BorderRadius`
field.
- `extract_uinode_borders`: Stripped down, most of the work is done in
the shader now. Borders are no longer assembled from multiple rects,
instead the shader uses a signed distance function to draw the border.
- `UiVertex`: Added size, border and radius fields.
- `UiPipeline`: Added three vertex attributes to the vertex buffer
layout, to accept the UI node's size, border thickness and border
radius.
- Examples: Added rounded corners to the UI element in the `button`
example, and a `rounded_borders` example.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Zachary Harrold <zac@harrold.com.au>
Co-authored-by: Pablo Reinhardt <126117294+pablo-lua@users.noreply.github.com>
This commit is contained in:
Antony 2024-03-19 18:44:00 -04:00 committed by GitHub
parent 7c7d1e8a64
commit e7a31d000e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 977 additions and 92 deletions

View file

@ -2340,6 +2340,17 @@ description = "Demonstrates how to create a node with a border"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "rounded_borders"
path = "examples/ui/rounded_borders.rs"
doc-scrape-examples = true
[package.metadata.example.rounded_borders]
name = "Rounded Borders"
description = "Demonstrates how to create a node with a rounded border"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "button"
path = "examples/ui/button.rs"

View file

@ -112,6 +112,7 @@ impl Plugin for UiPlugin {
.register_type::<UiRect>()
.register_type::<UiScale>()
.register_type::<BorderColor>()
.register_type::<BorderRadius>()
.register_type::<widget::Button>()
.register_type::<widget::Label>()
.register_type::<ZIndex>()

View file

@ -6,8 +6,8 @@
use crate::widget::TextFlags;
use crate::{
widget::{Button, UiImageSize},
BackgroundColor, BorderColor, ContentSize, FocusPolicy, Interaction, Node, Style, UiImage,
UiMaterial, ZIndex,
BackgroundColor, BorderColor, BorderRadius, ContentSize, FocusPolicy, Interaction, Node, Style,
UiImage, UiMaterial, ZIndex,
};
use bevy_asset::Handle;
use bevy_color::Color;
@ -34,6 +34,8 @@ pub struct NodeBundle {
pub background_color: BackgroundColor,
/// The color of the Node's border
pub border_color: BorderColor,
/// The border radius of the node
pub border_radius: BorderRadius,
/// Whether this node should block interaction with lower nodes
pub focus_policy: FocusPolicy,
/// The transform of the node
@ -62,6 +64,7 @@ impl Default for NodeBundle {
// Transparent background
background_color: Color::NONE.into(),
border_color: Color::NONE.into(),
border_radius: BorderRadius::default(),
node: Default::default(),
style: Default::default(),
focus_policy: Default::default(),
@ -314,6 +317,8 @@ pub struct ButtonBundle {
pub focus_policy: FocusPolicy,
/// The color of the Node's border
pub border_color: BorderColor,
/// The border radius of the node
pub border_radius: BorderRadius,
/// The image of the node
pub image: UiImage,
/// The transform of the node
@ -344,6 +349,7 @@ impl Default for ButtonBundle {
interaction: Default::default(),
focus_policy: FocusPolicy::Block,
border_color: BorderColor(Color::NONE),
border_radius: BorderRadius::default(),
image: Default::default(),
transform: Default::default(),
global_transform: Default::default(),

View file

@ -9,21 +9,23 @@ use bevy_core_pipeline::{core_2d::Camera2d, core_3d::Camera3d};
use bevy_hierarchy::Parent;
use bevy_render::{render_phase::PhaseItem, view::ViewVisibility, ExtractSchedule, Render};
use bevy_sprite::{SpriteAssetEvents, TextureAtlas};
use bevy_window::{PrimaryWindow, Window};
pub use pipeline::*;
pub use render_pass::*;
pub use ui_material_pipeline::*;
use crate::graph::{NodeUi, SubGraphUi};
use crate::{
texture_slice::ComputedTextureSlices, BackgroundColor, BorderColor, CalculatedClip,
ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiImage, UiScale, Val,
texture_slice::ComputedTextureSlices, BackgroundColor, BorderColor, BorderRadius,
CalculatedClip, ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiImage,
UiScale, Val,
};
use bevy_app::prelude::*;
use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle};
use bevy_ecs::entity::EntityHashMap;
use bevy_ecs::prelude::*;
use bevy_math::{Mat4, Rect, URect, UVec4, Vec2, Vec3, Vec4Swizzles};
use bevy_math::{Mat4, Rect, URect, UVec4, Vec2, Vec3, Vec3Swizzles, Vec4, Vec4Swizzles};
use bevy_render::{
camera::Camera,
render_asset::RenderAssets,
@ -141,6 +143,14 @@ fn get_ui_graph(render_app: &mut App) -> RenderGraph {
ui_graph
}
/// The type of UI node.
/// This is used to determine how to render the UI node.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NodeType {
Rect,
Border,
}
pub struct ExtractedUiNode {
pub stack_index: u32,
pub transform: Mat4,
@ -155,6 +165,13 @@ pub struct ExtractedUiNode {
// it is defaulted to a single camera if only one exists.
// Nodes with ambiguous camera will be ignored.
pub camera_entity: Entity,
/// Border radius of the UI node.
/// Ordering: top left, top right, bottom right, bottom left.
pub border_radius: [f32; 4],
/// Border thickness of the UI node.
/// Ordering: left, top, right, bottom.
pub border: [f32; 4],
pub node_type: NodeType,
}
#[derive(Resource, Default)]
@ -164,7 +181,9 @@ pub struct ExtractedUiNodes {
pub fn extract_uinode_background_colors(
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
windows: Extract<Query<&Window, With<PrimaryWindow>>>,
default_ui_camera: Extract<DefaultUiCamera>,
ui_scale: Extract<Res<UiScale>>,
uinode_query: Extract<
Query<(
Entity,
@ -174,11 +193,26 @@ pub fn extract_uinode_background_colors(
Option<&CalculatedClip>,
Option<&TargetCamera>,
&BackgroundColor,
&BorderRadius,
)>,
>,
) {
for (entity, uinode, transform, view_visibility, clip, camera, background_color) in
&uinode_query
let viewport_size = windows
.get_single()
.map(|window| window.resolution.size())
.unwrap_or(Vec2::ZERO)
* ui_scale.0;
for (
entity,
uinode,
transform,
view_visibility,
clip,
camera,
background_color,
border_radius,
) in &uinode_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else {
@ -190,6 +224,9 @@ pub fn extract_uinode_background_colors(
continue;
}
let border_radius =
resolve_border_radius(border_radius, uinode.size(), viewport_size, ui_scale.0);
extracted_uinodes.uinodes.insert(
entity,
ExtractedUiNode {
@ -206,6 +243,9 @@ pub fn extract_uinode_background_colors(
flip_x: false,
flip_y: false,
camera_entity,
border: [0.; 4],
border_radius,
node_type: NodeType::Rect,
},
);
}
@ -214,7 +254,9 @@ pub fn extract_uinode_background_colors(
pub fn extract_uinode_images(
mut commands: Commands,
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
windows: Extract<Query<&Window, With<PrimaryWindow>>>,
texture_atlases: Extract<Res<Assets<TextureAtlasLayout>>>,
ui_scale: Extract<Res<UiScale>>,
default_ui_camera: Extract<DefaultUiCamera>,
uinode_query: Extract<
Query<(
@ -226,10 +268,19 @@ pub fn extract_uinode_images(
&UiImage,
Option<&TextureAtlas>,
Option<&ComputedTextureSlices>,
&BorderRadius,
)>,
>,
) {
for (uinode, transform, view_visibility, clip, camera, image, atlas, slices) in &uinode_query {
let viewport_size = windows
.get_single()
.map(|window| window.resolution.size())
.unwrap_or(Vec2::ZERO)
* ui_scale.0;
for (uinode, transform, view_visibility, clip, camera, image, atlas, slices, border_radius) in
&uinode_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else {
continue;
@ -272,6 +323,9 @@ pub fn extract_uinode_images(
),
};
let border_radius =
resolve_border_radius(border_radius, uinode.size(), viewport_size, ui_scale.0);
extracted_uinodes.uinodes.insert(
commands.spawn_empty().id(),
ExtractedUiNode {
@ -285,6 +339,9 @@ pub fn extract_uinode_images(
flip_x: image.flip_x,
flip_y: image.flip_y,
camera_entity,
border: [0.; 4],
border_radius,
node_type: NodeType::Rect,
},
);
}
@ -302,6 +359,55 @@ 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;
let sm = s.x.min(s.y);
r.min(sm)
}
#[inline]
fn clamp_radius(
[top_left, top_right, bottom_right, bottom_left]: [f32; 4],
size: Vec2,
border: Vec4,
) -> [f32; 4] {
let s = size - border.xy() - border.zw();
[
clamp_corner(top_left, s, border.xy()),
clamp_corner(top_right, s, border.zy()),
clamp_corner(bottom_right, s, border.zw()),
clamp_corner(bottom_left, s, border.xw()),
]
}
pub fn extract_uinode_borders(
mut commands: Commands,
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
@ -319,6 +425,7 @@ pub fn extract_uinode_borders(
Option<&Parent>,
&Style,
&BorderColor,
&BorderRadius,
),
Without<ContentSize>,
>,
@ -327,8 +434,17 @@ pub fn extract_uinode_borders(
) {
let image = AssetId::<Image>::default();
for (node, global_transform, view_visibility, clip, camera, parent, style, border_color) in
&uinode_query
for (
node,
global_transform,
view_visibility,
clip,
camera,
parent,
style,
border_color,
border_radius,
) in &uinode_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else {
@ -368,48 +484,27 @@ pub fn extract_uinode_borders(
let bottom =
resolve_border_thickness(style.border.bottom, parent_width, ui_logical_viewport_size);
// Calculate the border rects, ensuring no overlap.
// The border occupies the space between the node's bounding rect and the node's bounding rect inset in each direction by the node's corresponding border value.
let max = 0.5 * node.size();
let min = -max;
let inner_min = min + Vec2::new(left, top);
let inner_max = (max - Vec2::new(right, bottom)).max(inner_min);
let border_rects = [
// Left border
Rect {
min,
max: Vec2::new(inner_min.x, max.y),
},
// Right border
Rect {
min: Vec2::new(inner_max.x, min.y),
max,
},
// Top border
Rect {
min: Vec2::new(inner_min.x, min.y),
max: Vec2::new(inner_max.x, inner_min.y),
},
// Bottom border
Rect {
min: Vec2::new(inner_min.x, inner_max.y),
max: Vec2::new(inner_max.x, max.y),
},
];
let border = [left, top, right, bottom];
let border_radius = resolve_border_radius(
border_radius,
node.size(),
ui_logical_viewport_size,
ui_scale.0,
);
let border_radius = clamp_radius(border_radius, node.size(), border.into());
let transform = global_transform.compute_matrix();
for edge in border_rects {
if edge.min.x < edge.max.x && edge.min.y < edge.max.y {
extracted_uinodes.uinodes.insert(
commands.spawn_empty().id(),
ExtractedUiNode {
stack_index: node.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.)),
transform,
color: border_color.0.into(),
rect: Rect {
max: edge.size(),
max: node.size(),
..Default::default()
},
image,
@ -418,12 +513,13 @@ pub fn extract_uinode_borders(
flip_x: false,
flip_y: false,
camera_entity,
border_radius,
border,
node_type: NodeType::Border,
},
);
}
}
}
}
pub fn extract_uinode_outlines(
mut commands: Commands,
@ -490,7 +586,6 @@ pub fn extract_uinode_outlines(
];
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(
@ -510,6 +605,9 @@ pub fn extract_uinode_outlines(
flip_x: false,
flip_y: false,
camera_entity,
border: [0.; 4],
border_radius: [0.; 4],
node_type: NodeType::Rect,
},
);
}
@ -680,6 +778,9 @@ pub fn extract_uinode_text(
flip_x: false,
flip_y: false,
camera_entity,
border: [0.; 4],
border_radius: [0.; 4],
node_type: NodeType::Rect,
},
);
}
@ -692,12 +793,23 @@ struct UiVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
pub color: [f32; 4],
pub mode: u32,
/// Shader flags to determine how to render the UI node.
/// See [`shader_flags`] for possible values.
pub flags: u32,
/// Border radius of the UI node.
/// Ordering: top left, top right, bottom right, bottom left.
pub radius: [f32; 4],
/// Border thickness of the UI node.
/// Ordering: left, top, right, bottom.
pub border: [f32; 4],
/// Size of the UI node.
pub size: [f32; 2],
}
#[derive(Resource)]
pub struct UiMeta {
vertices: BufferVec<UiVertex>,
indices: BufferVec<u32>,
view_bind_group: Option<BindGroup>,
}
@ -705,6 +817,7 @@ impl Default for UiMeta {
fn default() -> Self {
Self {
vertices: BufferVec::new(BufferUsages::VERTEX),
indices: BufferVec::new(BufferUsages::INDEX),
view_bind_group: None,
}
}
@ -726,8 +839,14 @@ pub struct UiBatch {
pub camera: Entity,
}
const TEXTURED_QUAD: u32 = 0;
const UNTEXTURED_QUAD: u32 = 1;
/// The values here should match the values for the constants in `ui.wgsl`
pub mod shader_flags {
pub const UNTEXTURED: u32 = 0;
pub const TEXTURED: u32 = 1;
/// Ordering: top left, top right, bottom right, bottom left.
pub const CORNERS: [u32; 4] = [0, 2, 2 | 4, 4];
pub const BORDER: u32 = 8;
}
#[allow(clippy::too_many_arguments)]
pub fn queue_uinodes(
@ -802,14 +921,17 @@ pub fn prepare_uinodes(
let mut batches: Vec<(Entity, UiBatch)> = Vec::with_capacity(*previous_len);
ui_meta.vertices.clear();
ui_meta.indices.clear();
ui_meta.view_bind_group = Some(render_device.create_bind_group(
"ui_view_bind_group",
&ui_pipeline.view_layout,
&BindGroupEntries::single(view_binding),
));
// Vertex buffer index
let mut index = 0;
// Buffer indexes
let mut vertices_index = 0;
let mut indices_index = 0;
for mut ui_phase in &mut phases {
let mut batch_item_index = 0;
let mut batch_image_handle = AssetId::invalid();
@ -832,7 +954,7 @@ pub fn prepare_uinodes(
batch_image_handle = extracted_uinode.image;
let new_batch = UiBatch {
range: index..index,
range: vertices_index..vertices_index,
image: extracted_uinode.image,
camera: extracted_uinode.camera_entity,
};
@ -882,10 +1004,10 @@ pub fn prepare_uinodes(
}
}
let mode = if extracted_uinode.image != AssetId::default() {
TEXTURED_QUAD
let mut flags = if extracted_uinode.image != AssetId::default() {
shader_flags::TEXTURED
} else {
UNTEXTURED_QUAD
shader_flags::UNTEXTURED
};
let mut uinode_rect = extracted_uinode.rect;
@ -946,7 +1068,7 @@ pub fn prepare_uinodes(
continue;
}
}
let uvs = if mode == UNTEXTURED_QUAD {
let uvs = if flags == shader_flags::UNTEXTURED {
[Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y]
} else {
let atlas_extent = extracted_uinode.atlas_size.unwrap_or(uinode_rect.max);
@ -986,16 +1108,30 @@ pub fn prepare_uinodes(
};
let color = extracted_uinode.color.to_f32_array();
for i in QUAD_INDICES {
if extracted_uinode.node_type == NodeType::Border {
flags |= shader_flags::BORDER;
}
for i in 0..4 {
ui_meta.vertices.push(UiVertex {
position: positions_clipped[i].into(),
uv: uvs[i].into(),
color,
mode,
flags: flags | shader_flags::CORNERS[i],
radius: extracted_uinode.border_radius,
border: extracted_uinode.border,
size: transformed_rect_size.xy().into(),
});
}
index += QUAD_INDICES.len() as u32;
existing_batch.unwrap().1.range.end = index;
for &i in &QUAD_INDICES {
ui_meta.indices.push(indices_index + i as u32);
}
vertices_index += 6;
indices_index += 4;
existing_batch.unwrap().1.range.end = vertices_index;
ui_phase.items[batch_item_index].batch_range_mut().end += 1;
} else {
batch_image_handle = AssetId::invalid();
@ -1003,6 +1139,7 @@ pub fn prepare_uinodes(
}
}
ui_meta.vertices.write_buffer(&render_device, &render_queue);
ui_meta.indices.write_buffer(&render_device, &render_queue);
*previous_len = batches.len();
commands.insert_or_spawn_batch(batches);
}

View file

@ -65,6 +65,12 @@ impl SpecializedRenderPipeline for UiPipeline {
VertexFormat::Float32x4,
// mode
VertexFormat::Uint32,
// border radius
VertexFormat::Float32x4,
// border thickness
VertexFormat::Float32x4,
// border size
VertexFormat::Float32x2,
],
);
let shader_defs = Vec::new();

View file

@ -215,8 +215,17 @@ impl<P: PhaseItem> RenderCommand<P> for DrawUiNode {
return RenderCommandResult::Failure;
};
pass.set_vertex_buffer(0, ui_meta.into_inner().vertices.buffer().unwrap().slice(..));
pass.draw(batch.range.clone(), 0..1);
let ui_meta = ui_meta.into_inner();
// Store the vertices
pass.set_vertex_buffer(0, ui_meta.vertices.buffer().unwrap().slice(..));
// Define how to "connect" the vertices
pass.set_index_buffer(
ui_meta.indices.buffer().unwrap().slice(..),
0,
bevy_render::render_resource::IndexFormat::Uint32,
);
// Draw the vertices
pass.draw_indexed(batch.range.clone(), 0, 0..1);
RenderCommandResult::Success
}
}

View file

@ -1,13 +1,27 @@
#import bevy_render::view::View
const TEXTURED_QUAD: u32 = 0u;
const TEXTURED = 1u;
const RIGHT_VERTEX = 2u;
const BOTTOM_VERTEX = 4u;
const BORDER: u32 = 8u;
fn enabled(flags: u32, mask: u32) -> bool {
return (flags & mask) != 0u;
}
@group(0) @binding(0) var<uniform> view: View;
struct VertexOutput {
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(3) @interpolate(flat) mode: u32,
@location(2) @interpolate(flat) size: vec2<f32>,
@location(3) @interpolate(flat) flags: u32,
@location(4) @interpolate(flat) radius: vec4<f32>,
@location(5) @interpolate(flat) border: vec4<f32>,
// Position relative to the center of the rectangle.
@location(6) point: vec2<f32>,
@builtin(position) position: vec4<f32>,
};
@ -16,27 +30,285 @@ fn vertex(
@location(0) vertex_position: vec3<f32>,
@location(1) vertex_uv: vec2<f32>,
@location(2) vertex_color: vec4<f32>,
@location(3) mode: u32,
@location(3) flags: u32,
// x: top left, y: top right, z: bottom right, w: bottom left.
@location(4) radius: vec4<f32>,
// x: left, y: top, z: right, w: bottom.
@location(5) border: vec4<f32>,
@location(6) size: vec2<f32>,
) -> VertexOutput {
var out: VertexOutput;
out.uv = vertex_uv;
out.position = view.view_proj * vec4<f32>(vertex_position, 1.0);
out.position = view.view_proj * vec4(vertex_position, 1.0);
out.color = vertex_color;
out.mode = mode;
out.flags = flags;
out.radius = radius;
out.size = size;
out.border = border;
var point = 0.49999 * size;
if (flags & RIGHT_VERTEX) == 0u {
point.x *= -1.;
}
if (flags & BOTTOM_VERTEX) == 0u {
point.y *= -1.;
}
out.point = point;
return out;
}
@group(1) @binding(0) var sprite_texture: texture_2d<f32>;
@group(1) @binding(1) var sprite_sampler: sampler;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// textureSample can only be called in unform control flow, not inside an if branch.
var color = textureSample(sprite_texture, sprite_sampler, in.uv);
if in.mode == TEXTURED_QUAD {
color = in.color * color;
} else {
color = in.color;
fn sigmoid(t: f32) -> f32 {
return 1.0 / (1.0 + exp(-t));
}
// The returned value is the shortest distance from the given point to the boundary of the rounded
// box.
//
// Negative values indicate that the point is inside the rounded box, positive values that the point
// is outside, and zero is exactly on the boundary.
//
// Arguments:
// - `point` -> The function will return the distance from this point to the closest point on
// the boundary.
// - `size` -> The maximum width and height of the box.
// - `corner_radii` -> The radius of each rounded corner. Ordered counter clockwise starting
// top left:
// x: top left, y: top right, z: bottom right, w: bottom left.
fn sd_rounded_box(point: vec2<f32>, size: vec2<f32>, corner_radii: vec4<f32>) -> f32 {
// If 0.0 < y then select bottom left (w) and bottom right corner radius (z).
// Else select top left (x) and top right corner radius (y).
let rs = select(corner_radii.xy, corner_radii.wz, 0.0 < point.y);
// w and z are swapped so that both pairs are in left to right order, otherwise this second
// select statement would return the incorrect value for the bottom pair.
let radius = select(rs.x, rs.y, 0.0 < point.x);
// Vector from the corner closest to the point, to the point.
let corner_to_point = abs(point) - 0.5 * size;
// Vector from the center of the radius circle to the point.
let q = corner_to_point + radius;
// Length from center of the radius circle to the point, zeros a component if the point is not
// within the quadrant of the radius circle that is part of the curved corner.
let l = length(max(q, vec2(0.0)));
let m = min(max(q.x, q.y), 0.0);
return l + m - radius;
}
fn sd_inset_rounded_box(point: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, inset: vec4<f32>) -> f32 {
let inner_size = size - inset.xy - inset.zw;
let inner_center = inset.xy + 0.5 * inner_size - 0.5 * size;
let inner_point = point - inner_center;
var r = radius;
// Top left corner.
r.x = r.x - max(inset.x, inset.y);
// Top right corner.
r.y = r.y - max(inset.z, inset.y);
// Bottom right corner.
r.z = r.z - max(inset.z, inset.w);
// Bottom left corner.
r.w = r.w - max(inset.x, inset.w);
let half_size = inner_size * 0.5;
let min = min(half_size.x, half_size.y);
r = min(max(r, vec4(0.0)), vec4<f32>(min));
return sd_rounded_box(inner_point, inner_size, r);
}
#ifdef CLAMP_INNER_CURVES
fn sd_inset_rounded_box(point: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, inset: vec4<f32>) -> f32 {
let inner_size = size - inset.xy - inset.zw;
let inner_center = inset.xy + 0.5 * inner_size - 0.5 * size;
let inner_point = point - inner_center;
var r = radius;
if 0. < min(inset.x, inset.y) || inset.x + inset.y <= 0. {
// Top left corner.
r.x = r.x - max(inset.x, inset.y);
} else {
r.x = 0.;
}
if 0. < min(inset.z, inset.y) || inset.z + inset.y <= 0. {
// Top right corner.
r.y = r.y - max(inset.z, inset.y);
} else {
r.y = 0.;
}
if 0. < min(inset.z, inset.w) || inset.z + inset.w <= 0. {
// Bottom right corner.
r.z = r.z - max(inset.z, inset.w);
} else {
r.z = 0.;
}
if 0. < min(inset.x, inset.w) || inset.x + inset.w <= 0. {
// Bottom left corner.
r.w = r.w - max(inset.x, inset.w);
} else {
r.w = 0.;
}
let half_size = inner_size * 0.5;
let min = min(half_size.x, half_size.y);
r = min(max(r, vec4<f32>(0.0)), vec4<f32>(min));
return sd_rounded_box(inner_point, inner_size, r);
}
#endif
const RED: vec4<f32> = vec4<f32>(1., 0., 0., 1.);
const GREEN: vec4<f32> = vec4<f32>(0., 1., 0., 1.);
const BLUE: vec4<f32> = vec4<f32>(0., 0., 1., 1.);
const WHITE = vec4<f32>(1., 1., 1., 1.);
const BLACK = vec4<f32>(0., 0., 0., 1.);
// Draw the border in white, rest of the rect black.
fn draw_border(in: VertexOutput) -> vec4<f32> {
// Distance from external border. Positive values outside.
let external_distance = sd_rounded_box(in.point, in.size, in.radius);
// Distance from internal border. Positive values inside.
let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border);
// Distance from border, positive values inside border.
let border = max(-internal_distance, external_distance);
if border < 0.0 {
return WHITE;
} else {
return BLACK;
}
}
// Draw just the interior in white, rest of the rect black.
fn draw_interior(in: VertexOutput) -> vec4<f32> {
// Distance from external border. Positive values outside.
let external_distance = sd_rounded_box(in.point, in.size, in.radius);
if external_distance < 0.0 {
return WHITE;
} else {
return BLACK;
}
}
// Draw all the geometry.
fn draw_test(in: VertexOutput) -> vec4<f32> {
// Distance from external border. Negative inside
let external_distance = sd_rounded_box(in.point, in.size, in.radius);
// Distance from internal border.
let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border);
// Distance from border.
let border = max(-internal_distance, external_distance);
// Draw the area outside the border in green.
if 0.0 < external_distance {
return GREEN;
}
// Draw the area inside the border in white.
if border < 0.0 {
return WHITE;
}
// Draw the interior in blue.
if internal_distance < 0.0 {
return BLUE;
}
// Fill anything else with red (the presence of any red is a bug).
return RED;
}
fn draw_no_aa(in: VertexOutput) -> vec4<f32> {
let texture_color = textureSample(sprite_texture, sprite_sampler, in.uv);
let color = select(in.color, in.color * texture_color, enabled(in.flags, TEXTURED));
// Negative value => point inside external border.
let external_distance = sd_rounded_box(in.point, in.size, in.radius);
// Negative value => point inside internal border.
let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border);
// Negative value => point inside border.
let border = max(external_distance, -internal_distance);
if enabled(in.flags, BORDER) {
if border < 0.0 {
return color;
} else {
return vec4(0.0);
}
}
if external_distance < 0.0 {
return color;
}
return vec4(0.0);
}
fn draw(in: VertexOutput) -> vec4<f32> {
let texture_color = textureSample(sprite_texture, sprite_sampler, in.uv);
// Only use the color sampled from the texture if the `TEXTURED` flag is enabled.
// This allows us to draw both textured and untextured shapes together in the same batch.
let color = select(in.color, in.color * texture_color, enabled(in.flags, TEXTURED));
// Signed distances. The magnitude is the distance of the point from the edge of the shape.
// * Negative values indicate that the point is inside the shape.
// * Zero values indicate the point is on on the edge of the shape.
// * Positive values indicate the point is outside the shape.
// Signed distance from the exterior boundary.
let external_distance = sd_rounded_box(in.point, in.size, in.radius);
// Signed distance from the border's internal edge (the signed distance is negative if the point
// is inside the rect but not on the border).
// If the border size is set to zero, this is the same as as the external distance.
let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border);
// Signed distance from the border (the intersection of the rect with its border).
// Points inside the border have negative signed distance. Any point outside the border, whether
// outside the outside edge, or inside the inner edge have positive signed distance.
let border_distance = max(external_distance, -internal_distance);
// The `fwidth` function returns an approximation of the rate of change of the signed distance
// value that is used to ensure that the smooth alpha transition created by smoothstep occurs
// over a range of distance values that is proportional to how quickly the distance is changing.
let fborder = fwidth(border_distance);
let fexternal = fwidth(external_distance);
if enabled(in.flags, BORDER) {
// The item is a border
// At external edges with no border, `border_distance` is equal to zero.
// This select statement ensures we only perform anti-aliasing where a non-zero width border
// is present, otherwise an outline about the external boundary would be drawn even without
// a border.
let t = 1. - select(step(0.0, border_distance), smoothstep(0.0, fborder, border_distance), external_distance < internal_distance);
return vec4(color.rgb * t * color.a, t * color.a);
}
// The item is a rectangle, draw normally with anti-aliasing at the edges.
let t = 1. - smoothstep(0.0, fexternal, external_distance);
return vec4(color.rgb * t * color.a, t * color.a);
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
return draw(in);
}

View file

@ -10,7 +10,7 @@ use bevy_sprite::{ImageScaleMode, TextureAtlas, TextureAtlasLayout, TextureSlice
use bevy_transform::prelude::*;
use bevy_utils::HashSet;
use crate::{CalculatedClip, ExtractedUiNode, Node, UiImage};
use crate::{CalculatedClip, ExtractedUiNode, Node, NodeType, UiImage};
/// Component storing texture slices for image nodes entities with a tiled or sliced [`ImageScaleMode`]
///
@ -68,6 +68,9 @@ impl ComputedTextureSlices {
atlas_size,
clip: clip.map(|clip| clip.clip),
camera_entity,
border: [0.; 4],
border_radius: [0.; 4],
node_type: NodeType::Rect,
}
})
}

View file

@ -1812,6 +1812,268 @@ impl Default for ZIndex {
}
}
/// Used to add rounded corners to a UI node. You can set a UI node to have uniformly
/// rounded corners or specify different radii for each corner. If a given radius exceeds half
/// the length of the smallest dimension between the node's height or width, the radius will
/// calculated as half the smallest dimension.
///
/// Elliptical nodes are not supported yet. Percentage values are based on the node's smallest
/// dimension, either width or height.
///
/// # Example
/// ```
/// # use bevy_ecs::prelude::*;
/// # use bevy_ui::prelude::*;
/// # use bevy_color::palettes::basic::{BLUE};
/// fn setup_ui(mut commands: Commands) {
/// commands.spawn((
/// NodeBundle {
/// style: Style {
/// width: Val::Px(100.),
/// height: Val::Px(100.),
/// border: UiRect::all(Val::Px(2.)),
/// ..Default::default()
/// },
/// background_color: BLUE.into(),
/// border_radius: BorderRadius::new(
/// // top left
/// Val::Px(10.),
/// // top right
/// Val::Px(20.),
/// // bottom right
/// Val::Px(30.),
/// // bottom left
/// Val::Px(40.),
/// ),
/// ..Default::default()
/// },
/// ));
/// }
/// ```
///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius>
#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)]
#[reflect(PartialEq, Default)]
#[cfg_attr(
feature = "serialize",
derive(serde::Serialize, serde::Deserialize),
reflect(Serialize, Deserialize)
)]
pub struct BorderRadius {
pub top_left: Val,
pub top_right: Val,
pub bottom_left: Val,
pub bottom_right: Val,
}
impl Default for BorderRadius {
fn default() -> Self {
Self::DEFAULT
}
}
impl BorderRadius {
pub const DEFAULT: Self = Self::ZERO;
/// Zero curvature. All the corners will be right-angled.
pub const ZERO: Self = Self::all(Val::Px(0.));
/// Maximum curvature. The UI Node will take a capsule shape or circular if width and height are equal.
pub const MAX: Self = Self::all(Val::Px(f32::MAX));
#[inline]
/// Set all four corners to the same curvature.
pub const fn all(radius: Val) -> Self {
Self {
top_left: radius,
top_right: radius,
bottom_left: radius,
bottom_right: radius,
}
}
#[inline]
pub const fn new(top_left: Val, top_right: Val, bottom_right: Val, bottom_left: Val) -> Self {
Self {
top_left,
top_right,
bottom_right,
bottom_left,
}
}
#[inline]
/// Sets the radii to logical pixel values.
pub const fn px(top_left: f32, top_right: f32, bottom_right: f32, bottom_left: f32) -> Self {
Self {
top_left: Val::Px(top_left),
top_right: Val::Px(top_right),
bottom_right: Val::Px(bottom_right),
bottom_left: Val::Px(bottom_left),
}
}
#[inline]
/// Sets the radii to percentage values.
pub const fn percent(
top_left: f32,
top_right: f32,
bottom_right: f32,
bottom_left: f32,
) -> Self {
Self {
top_left: Val::Px(top_left),
top_right: Val::Px(top_right),
bottom_right: Val::Px(bottom_right),
bottom_left: Val::Px(bottom_left),
}
}
#[inline]
/// Sets the radius for the top left corner.
/// Remaining corners will be right-angled.
pub const fn top_left(radius: Val) -> Self {
Self {
top_left: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radius for the top right corner.
/// Remaining corners will be right-angled.
pub const fn top_right(radius: Val) -> Self {
Self {
top_right: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radius for the bottom right corner.
/// Remaining corners will be right-angled.
pub const fn bottom_right(radius: Val) -> Self {
Self {
bottom_right: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radius for the bottom left corner.
/// Remaining corners will be right-angled.
pub const fn bottom_left(radius: Val) -> Self {
Self {
bottom_left: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radii for the top left and bottom left corners.
/// Remaining corners will be right-angled.
pub const fn left(radius: Val) -> Self {
Self {
top_left: radius,
bottom_left: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radii for the top right and bottom right corners.
/// Remaining corners will be right-angled.
pub const fn right(radius: Val) -> Self {
Self {
top_right: radius,
bottom_right: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radii for the top left and top right corners.
/// Remaining corners will be right-angled.
pub const fn top(radius: Val) -> Self {
Self {
top_left: radius,
top_right: radius,
..Self::DEFAULT
}
}
#[inline]
/// Sets the radii for the bottom left and bottom right corners.
/// Remaining corners will be right-angled.
pub const fn bottom(radius: Val) -> Self {
Self {
bottom_left: radius,
bottom_right: radius,
..Self::DEFAULT
}
}
/// Returns the [`BorderRadius`] with its `top_left` field set to the given value.
#[inline]
pub const fn with_top_left(mut self, radius: Val) -> Self {
self.top_left = radius;
self
}
/// Returns the [`BorderRadius`] with its `top_right` field set to the given value.
#[inline]
pub const fn with_top_right(mut self, radius: Val) -> Self {
self.top_right = radius;
self
}
/// Returns the [`BorderRadius`] with its `bottom_right` field set to the given value.
#[inline]
pub const fn with_bottom_right(mut self, radius: Val) -> Self {
self.bottom_right = radius;
self
}
/// Returns the [`BorderRadius`] with its `bottom_left` field set to the given value.
#[inline]
pub const fn with_bottom_left(mut self, radius: Val) -> Self {
self.bottom_left = radius;
self
}
/// Returns the [`BorderRadius`] with its `top_left` and `bottom_left` fields set to the given value.
#[inline]
pub const fn with_left(mut self, radius: Val) -> Self {
self.top_left = radius;
self.bottom_left = radius;
self
}
/// Returns the [`BorderRadius`] with its `top_right` and `bottom_right` fields set to the given value.
#[inline]
pub const fn with_right(mut self, radius: Val) -> Self {
self.top_right = radius;
self.bottom_right = radius;
self
}
/// Returns the [`BorderRadius`] with its `top_left` and `top_right` fields set to the given value.
#[inline]
pub const fn with_top(mut self, radius: Val) -> Self {
self.top_left = radius;
self.top_right = radius;
self
}
/// Returns the [`BorderRadius`] with its `bottom_left` and `bottom_right` fields set to the given value.
#[inline]
pub const fn with_bottom(mut self, radius: Val) -> Self {
self.bottom_left = radius;
self.bottom_right = radius;
self
}
}
#[cfg(test)]
mod tests {
use crate::GridPlacement;

View file

@ -407,6 +407,7 @@ Example | Description
[Overflow and Clipping Debug](../examples/ui/overflow_debug.rs) | An example to debug overflow and clipping behavior
[Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
[Rounded Borders](../examples/ui/rounded_borders.rs) | Demonstrates how to create a node with a rounded border
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
[Text](../examples/ui/text.rs) | Illustrates creating and updating text
[Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout

View file

@ -74,6 +74,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
},
border_color: BorderColor(Color::BLACK),
border_radius: BorderRadius::MAX,
image: UiImage::default().with_color(NORMAL_BUTTON),
..default()
})

View file

@ -0,0 +1,176 @@
//! Example demonstrating rounded bordered UI nodes
use bevy::{color::palettes::css::*, prelude::*};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
let root = commands
.spawn(NodeBundle {
style: Style {
margin: UiRect::all(Val::Px(25.0)),
align_self: AlignSelf::Stretch,
justify_self: JustifySelf::Stretch,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::FlexStart,
align_content: AlignContent::FlexStart,
..Default::default()
},
background_color: Color::srgb(0.25, 0.25, 0.25).into(),
..Default::default()
})
.id();
// labels for the different border edges
let border_labels = [
"None",
"All",
"Left",
"Right",
"Top",
"Bottom",
"Left Right",
"Top Bottom",
"Top Left",
"Bottom Left",
"Top Right",
"Bottom Right",
"Top Bottom Right",
"Top Bottom Left",
"Top Left Right",
"Bottom Left Right",
];
// all the different combinations of border edges
// these correspond to the labels above
let borders = [
UiRect::default(),
UiRect::all(Val::Px(10.)),
UiRect::left(Val::Px(10.)),
UiRect::right(Val::Px(10.)),
UiRect::top(Val::Px(10.)),
UiRect::bottom(Val::Px(10.)),
UiRect::horizontal(Val::Px(10.)),
UiRect::vertical(Val::Px(10.)),
UiRect {
left: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
right: Val::Px(10.),
top: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
top: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
right: Val::Px(10.),
top: Val::Px(10.),
..Default::default()
},
UiRect {
left: Val::Px(10.),
right: Val::Px(10.),
bottom: Val::Px(10.),
..Default::default()
},
];
for (label, border) in border_labels.into_iter().zip(borders) {
let inner_spot = commands
.spawn(NodeBundle {
style: Style {
width: Val::Px(10.),
height: Val::Px(10.),
..Default::default()
},
border_radius: BorderRadius::MAX,
background_color: YELLOW.into(),
..Default::default()
})
.id();
let non_zero = |x, y| x != Val::Px(0.) && y != Val::Px(0.);
let border_size = |x, y| if non_zero(x, y) { f32::MAX } else { 0. };
let border_radius = BorderRadius::px(
border_size(border.left, border.top),
border_size(border.right, border.top),
border_size(border.right, border.bottom),
border_size(border.left, border.bottom),
);
let border_node = commands
.spawn((
NodeBundle {
style: Style {
width: Val::Px(50.),
height: Val::Px(50.),
border,
margin: UiRect::all(Val::Px(20.)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..Default::default()
},
background_color: MAROON.into(),
border_color: RED.into(),
border_radius,
..Default::default()
},
Outline {
width: Val::Px(6.),
offset: Val::Px(6.),
color: Color::WHITE,
},
))
.add_child(inner_spot)
.id();
let label_node = commands
.spawn(TextBundle::from_section(
label,
TextStyle {
font_size: 9.0,
..Default::default()
},
))
.id();
let container = commands
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..Default::default()
},
..Default::default()
})
.push_children(&[border_node, label_node])
.id();
commands.entity(root).add_child(container);
}
}