diff --git a/Cargo.toml b/Cargo.toml index ab9da009fd4d5..7cf1c5c164885 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2329,6 +2329,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" diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 486919a8b2d5d..7d6e8376c2a0e 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -112,6 +112,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/crates/bevy_ui/src/node_bundles.rs b/crates/bevy_ui/src/node_bundles.rs index 57b2bb4857d22..17dddacc87253 100644 --- a/crates/bevy_ui/src/node_bundles.rs +++ b/crates/bevy_ui/src/node_bundles.rs @@ -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(), diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index f2fa85a71f3d5..9dccddc64325e 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -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, + windows: Extract>>, default_ui_camera: Extract, + ui_scale: Extract>, 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, + windows: Extract>>, texture_atlases: Extract>>, + ui_scale: Extract>, default_ui_camera: Extract, 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, @@ -319,6 +425,7 @@ pub fn extract_uinode_borders( Option<&Parent>, &Style, &BorderColor, + &BorderRadius, ), Without, >, @@ -327,8 +434,17 @@ pub fn extract_uinode_borders( ) { let image = AssetId::::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,60 +484,40 @@ 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.)), - color: border_color.0.into(), - rect: Rect { - max: edge.size(), - ..Default::default() - }, - image, - atlas_size: None, - clip: clip.map(|clip| clip.clip), - flip_x: false, - flip_y: false, - camera_entity, - }, - ); - } - } + 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, + color: border_color.0.into(), + rect: Rect { + max: node.size(), + ..Default::default() + }, + image, + atlas_size: None, + clip: clip.map(|clip| clip.clip), + flip_x: false, + flip_y: false, + camera_entity, + border_radius, + border, + node_type: NodeType::Border, + }, + ); } } @@ -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, }, ); } @@ -677,6 +775,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, }, ); } @@ -689,12 +790,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, + indices: BufferVec, view_bind_group: Option, } @@ -702,6 +814,7 @@ impl Default for UiMeta { fn default() -> Self { Self { vertices: BufferVec::new(BufferUsages::VERTEX), + indices: BufferVec::new(BufferUsages::INDEX), view_bind_group: None, } } @@ -723,8 +836,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( @@ -799,14 +918,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(); @@ -829,7 +951,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, }; @@ -879,10 +1001,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; @@ -943,7 +1065,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); @@ -983,16 +1105,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(); @@ -1000,6 +1136,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); } diff --git a/crates/bevy_ui/src/render/pipeline.rs b/crates/bevy_ui/src/render/pipeline.rs index 6dad2b104c3bb..e31dc3bcfbba9 100644 --- a/crates/bevy_ui/src/render/pipeline.rs +++ b/crates/bevy_ui/src/render/pipeline.rs @@ -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(); diff --git a/crates/bevy_ui/src/render/render_pass.rs b/crates/bevy_ui/src/render/render_pass.rs index 1f3ffb0d20dea..892186478e678 100644 --- a/crates/bevy_ui/src/render/render_pass.rs +++ b/crates/bevy_ui/src/render/render_pass.rs @@ -215,8 +215,17 @@ impl RenderCommand

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 } } diff --git a/crates/bevy_ui/src/render/ui.wgsl b/crates/bevy_ui/src/render/ui.wgsl index aeb57aad81358..7a73382a650ce 100644 --- a/crates/bevy_ui/src/render/ui.wgsl +++ b/crates/bevy_ui/src/render/ui.wgsl @@ -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 view: View; struct VertexOutput { @location(0) uv: vec2, @location(1) color: vec4, - @location(3) @interpolate(flat) mode: u32, + + @location(2) @interpolate(flat) size: vec2, + @location(3) @interpolate(flat) flags: u32, + @location(4) @interpolate(flat) radius: vec4, + @location(5) @interpolate(flat) border: vec4, + + // Position relative to the center of the rectangle. + @location(6) point: vec2, @builtin(position) position: vec4, }; @@ -16,27 +30,285 @@ fn vertex( @location(0) vertex_position: vec3, @location(1) vertex_uv: vec2, @location(2) vertex_color: vec4, - @location(3) mode: u32, + @location(3) flags: u32, + + // x: top left, y: top right, z: bottom right, w: bottom left. + @location(4) radius: vec4, + + // x: left, y: top, z: right, w: bottom. + @location(5) border: vec4, + @location(6) size: vec2, ) -> VertexOutput { var out: VertexOutput; out.uv = vertex_uv; - out.position = view.view_proj * vec4(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; @group(1) @binding(1) var sprite_sampler: sampler; -@fragment -fn fragment(in: VertexOutput) -> @location(0) vec4 { - // 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; +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, size: vec2, corner_radii: vec4) -> 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, size: vec2, radius: vec4, inset: vec4) -> 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(min)); + + return sd_rounded_box(inner_point, inner_size, r); +} + +#ifdef CLAMP_INNER_CURVES +fn sd_inset_rounded_box(point: vec2, size: vec2, radius: vec4, inset: vec4) -> 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 { - color = in.color; + r.z = 0.; } - return color; + + 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(0.0)), vec4(min)); + + return sd_rounded_box(inner_point, inner_size, r); +} +#endif + +const RED: vec4 = vec4(1., 0., 0., 1.); +const GREEN: vec4 = vec4(0., 1., 0., 1.); +const BLUE: vec4 = vec4(0., 0., 1., 1.); +const WHITE = vec4(1., 1., 1., 1.); +const BLACK = vec4(0., 0., 0., 1.); + +// Draw the border in white, rest of the rect black. +fn draw_border(in: VertexOutput) -> vec4 { + // 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 { + // 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 { + // 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 { + 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 { + 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 { + return draw(in); } diff --git a/crates/bevy_ui/src/texture_slice.rs b/crates/bevy_ui/src/texture_slice.rs index c0836a3b870bb..16cfacff76670 100644 --- a/crates/bevy_ui/src/texture_slice.rs +++ b/crates/bevy_ui/src/texture_slice.rs @@ -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, } }) } diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 36db16ca60614..e85f51e070401 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -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() +/// }, +/// )); +/// } +/// ``` +/// +/// +#[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; diff --git a/examples/README.md b/examples/README.md index 47c59cb9069d0..5a08ee9d120e4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -406,6 +406,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 diff --git a/examples/ui/button.rs b/examples/ui/button.rs index c64a00ccbead9..c00242fc88a2a 100644 --- a/examples/ui/button.rs +++ b/examples/ui/button.rs @@ -74,6 +74,7 @@ fn setup(mut commands: Commands, asset_server: Res) { ..default() }, border_color: BorderColor(Color::BLACK), + border_radius: BorderRadius::MAX, image: UiImage::default().with_color(NORMAL_BUTTON), ..default() }) diff --git a/examples/ui/rounded_borders.rs b/examples/ui/rounded_borders.rs new file mode 100644 index 0000000000000..427f73455a751 --- /dev/null +++ b/examples/ui/rounded_borders.rs @@ -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); + } +}