UI material border radius (#15171)

# Objective

I wrote a box shadow UI material naively thinking I could use the border
widths attribute to hold the border radius but it
doesn't work as the border widths are automatically set in the
extraction function. Need to send border radius to the shader seperately
for it to be viable.

## Solution

Add a `border_radius` vertex attribute to the ui material.

This PR also removes the normalization of border widths for custom UI
materials. The regular UI shader doesn't do this so it's a bit confusing
and means you can't use the logic from `ui.wgsl` in your custom UI
materials.

## Testing / Showcase

Made a change to the `ui_material` example to display border radius:

```cargo run --example ui_material```

<img width="569" alt="corners" src="https://github.com/user-attachments/assets/36412736-a9ee-4042-aadd-68b9cafb17cb" />
This commit is contained in:
ickshonpe 2025-01-28 04:54:48 +00:00 committed by GitHub
parent 37893a3f9e
commit c0ccc87738
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 78 additions and 33 deletions

View File

@ -10,20 +10,42 @@
@fragment
fn fragment(in: UiVertexOutput) -> @location(0) vec4<f32> {
// normalized position relative to the center of the UI node
let r = in.uv - 0.5;
let output_color = textureSample(material_color_texture, material_color_sampler, in.uv) * color;
// normalized size of the border closest to the current position
// half size of the UI node
let half_size = 0.5 * in.size;
// position relative to the center of the UI node
let p = in.uv * in.size - half_size;
// thickness of the border closest to the current position
let b = vec2(
select(in.border_widths.x, in.border_widths.y, 0. < r.x),
select(in.border_widths.z, in.border_widths.w, 0. < r.y)
select(in.border_widths.x, in.border_widths.z, 0. < p.x),
select(in.border_widths.y, in.border_widths.w, 0. < p.y)
);
// select radius for the nearest corner
let rs = select(in.border_radius.xy, in.border_radius.wz, 0.0 < p.y);
let radius = select(rs.x, rs.y, 0.0 < p.x);
// distance along each axis from the corner
let d = half_size - abs(p);
// if the distance to the edge from the current position on any axis
// is less than the border width on that axis then the position is within
// the border and we return the border color
if any(0.5 - b < abs(r)) {
return border_color;
if d.x < b.x || d.y < b.y {
// select radius for the nearest corner
let rs = select(in.border_radius.xy, in.border_radius.wz, 0.0 < p.y);
let radius = select(rs.x, rs.y, 0.0 < p.x);
// determine if the point is inside the curved corner and return the corresponding color
let q = radius - d;
if radius < min(max(q.x, q.y), 0.0) + length(vec2(max(q.x, 0.0), max(q.y, 0.0))) {
return vec4(0.0);
} else {
return border_color;
}
}
// sample the texture at this position if it's to the left of the slider value

View File

@ -15,12 +15,14 @@ fn vertex(
@location(1) vertex_uv: vec2<f32>,
@location(2) size: vec2<f32>,
@location(3) border_widths: vec4<f32>,
@location(4) border_radius: vec4<f32>,
) -> UiVertexOutput {
var out: UiVertexOutput;
out.uv = vertex_uv;
out.position = view.clip_from_world * vec4<f32>(vertex_position, 1.0);
out.size = size;
out.border_widths = border_widths;
out.border_radius = border_radius;
return out;
}

View File

@ -13,7 +13,7 @@ use bevy_ecs::{
};
use bevy_image::BevyDefault as _;
use bevy_math::{FloatOrd, Mat4, Rect, Vec2, Vec4Swizzles};
use bevy_render::sync_world::MainEntity;
use bevy_render::sync_world::{MainEntity, TemporaryRenderEntity};
use bevy_render::{
extract_component::ExtractComponentPlugin,
globals::{GlobalsBuffer, GlobalsUniform},
@ -21,10 +21,11 @@ use bevy_render::{
render_phase::*,
render_resource::{binding_types::uniform_buffer, *},
renderer::{RenderDevice, RenderQueue},
sync_world::{RenderEntity, TemporaryRenderEntity},
sync_world::RenderEntity,
view::*,
Extract, ExtractSchedule, Render, RenderSet,
};
use bevy_sprite::BorderRect;
use bevy_transform::prelude::GlobalTransform;
use bytemuck::{Pod, Zeroable};
@ -116,7 +117,8 @@ pub struct UiMaterialVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
pub size: [f32; 2],
pub border_widths: [f32; 4],
pub border: [f32; 4],
pub radius: [f32; 4],
}
// in this [`UiMaterialPipeline`] there is (currently) no batching going on.
@ -154,7 +156,9 @@ where
VertexFormat::Float32x2,
// size
VertexFormat::Float32x2,
// border_widths
// border widths
VertexFormat::Float32x4,
// border radius
VertexFormat::Float32x4,
],
);
@ -335,7 +339,8 @@ pub struct ExtractedUiMaterialNode<M: UiMaterial> {
pub stack_index: u32,
pub transform: Mat4,
pub rect: Rect,
pub border: [f32; 4],
pub border: BorderRect,
pub border_radius: ResolvedBorderRadius,
pub material: AssetId<M>,
pub clip: Option<Rect>,
// Camera to render this UI node to. By the time it is extracted,
@ -379,7 +384,7 @@ pub fn extract_ui_material_nodes<M: UiMaterial>(
// If there is only one camera, we use it as default
let default_single_camera = default_ui_camera.get();
for (entity, uinode, transform, handle, inherited_visibility, clip, camera) in
for (entity, computed_node, transform, handle, inherited_visibility, clip, camera) in
uinode_query.iter()
{
let Some(camera_entity) = camera.map(UiTargetCamera::entity).or(default_single_camera)
@ -392,7 +397,7 @@ pub fn extract_ui_material_nodes<M: UiMaterial>(
};
// skip invisible nodes
if !inherited_visibility.get() || uinode.is_empty() {
if !inherited_visibility.get() || computed_node.is_empty() {
continue;
}
@ -401,24 +406,18 @@ pub fn extract_ui_material_nodes<M: UiMaterial>(
continue;
}
let border = [
uinode.border.left / uinode.size().x,
uinode.border.right / uinode.size().x,
uinode.border.top / uinode.size().y,
uinode.border.bottom / uinode.size().y,
];
extracted_uinodes.uinodes.insert(
commands.spawn(TemporaryRenderEntity).id(),
ExtractedUiMaterialNode {
stack_index: uinode.stack_index,
stack_index: computed_node.stack_index,
transform: transform.compute_matrix(),
material: handle.id(),
rect: Rect {
min: Vec2::ZERO,
max: uinode.size(),
max: computed_node.size(),
},
border,
border: computed_node.border(),
border_radius: computed_node.border_radius(),
clip: clip.map(|clip| clip.clip),
extracted_camera_entity,
main_entity: entity.into(),
@ -558,7 +557,18 @@ pub fn prepare_uimaterial_nodes<M: UiMaterial>(
position: positions_clipped[i].into(),
uv: uvs[i].into(),
size: extracted_uinode.rect.size().into(),
border_widths: extracted_uinode.border,
radius: [
extracted_uinode.border_radius.top_left,
extracted_uinode.border_radius.top_right,
extracted_uinode.border_radius.bottom_right,
extracted_uinode.border_radius.bottom_left,
],
border: [
extracted_uinode.border.left,
extracted_uinode.border.top,
extracted_uinode.border.right,
extracted_uinode.border.bottom,
],
});
}

View File

@ -5,7 +5,9 @@ struct UiVertexOutput {
@location(0) uv: vec2<f32>,
// The size of the borders in UV space. Order is Left, Right, Top, Bottom.
@location(1) border_widths: vec4<f32>,
// The size of the borders in pixels. Order is top left, top right, bottom right, bottom left.
@location(2) border_radius: vec4<f32>,
// The size of the node in pixels. Order is width, height.
@location(2) @interpolate(flat) size: vec2<f32>,
@location(3) @interpolate(flat) size: vec2<f32>,
@builtin(position) position: vec4<f32>,
};

View File

@ -1,6 +1,8 @@
//! Demonstrates the use of [`UiMaterials`](UiMaterial) and how to change material values
use bevy::{prelude::*, reflect::TypePath, render::render_resource::*};
use bevy::{
color::palettes::css::DARK_BLUE, prelude::*, reflect::TypePath, render::render_resource::*,
};
/// This example uses a shader source file from the assets subdirectory
const SHADER_ASSET_PATH: &str = "shaders/custom_ui_material.wgsl";
@ -33,18 +35,25 @@ fn setup(
.with_children(|parent| {
let banner_scale_factor = 0.5;
parent.spawn((
Node {
position_type: PositionType::Absolute,
width: Val::Px(905.0 * banner_scale_factor),
height: Val::Px(363.0 * banner_scale_factor),
border: UiRect::all(Val::Px(20.)),
..default()
},
MaterialNode(ui_materials.add(CustomUiMaterial {
color: LinearRgba::WHITE.to_f32_array().into(),
slider: 0.5,
color_texture: asset_server.load("branding/banner.png"),
border_color: LinearRgba::WHITE.to_f32_array().into(),
})),
Node {
position_type: PositionType::Absolute,
width: Val::Px(905.0 * banner_scale_factor),
height: Val::Px(363.0 * banner_scale_factor),
border: UiRect::all(Val::Px(10.)),
..default()
BorderRadius::all(Val::Px(20.)),
// UI material nodes can have outlines and shadows like any other UI node
Outline {
width: Val::Px(2.),
offset: Val::Px(100.),
color: DARK_BLUE.into(),
},
));
});