bevy/examples/testbed/full_ui.rs
ickshonpe bf20c630a8
UI Node Gradients (#18139)
# Objective

Allowing drawing of UI nodes with a gradient instead of a flat color.

## Solution

The are three gradient structs corresponding to the three types of
gradients supported: `LinearGradient`, `ConicGradient` and
`RadialGradient`. These are then wrapped in a `Gradient` enum
discriminator which has `Linear`, `Conic` and `Radial` variants.

Each gradient type consists of the geometric properties for that
gradient and a list of color stops.
Color stops consist of a color, a position or angle and an optional
hint. If no position is specified for a stop, it's evenly spaced between
the previous and following stops. Color stop positions are absolute, if
you specify a list of stops:
```vec![vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(Color::GREEN, Val::Percent(10.))```
the colors will be reordered and the gradient will transition from green at 10% to red at 90%. 

Colors are interpolated between the stops in SRGB space. The hint is a normalized value that can be used to shift the mid-point where the colors are mixed 50-50.  between the stop with the hint and the following stop.

For sharp stops with no interpolated transition, place two stops at the same position.

`ConicGradient`s and RadialGradient`s have a center which is set using the new `Position` type. `Position` consists of a normalized (relative to the UI node) `Vec2` anchor point and a responsive x, y offset.

To draw a UI node with a gradient you insert the components `BackgroundGradient` and `BorderGradient`, which both newtype a vector of `Gradient`s. If you set a background color, the background color is drawn first and the gradient(s) are drawn on top.

The implementation is deliberately simple and self contained. The shader draws the gradient in multiple passes which is quite inefficient for gradients with a very large number of color stops. It's simple though and there won't be any compatibility issues. We could make gradients a specialization for `UiPipeline` but I used a separate pipeline plugin for now to ensure that these changes don't break anything. 

#### Not supported in this PR
* Interpolation in other color spaces besides SRGB. 
* Images and text: This would need some breaking changes like a `UiColor` enum type with `Color` and `Gradient` variants, to enable `BorderColor`, `TextColor`, `BackgroundColor` and `ImageNode::color` to take either a `Color` or a gradient.
* Repeating gradients

## Testing

Includes three examples that can be used for testing:
```
cargo run --example linear_gradients
cargo run --example stacked_gradients
cargo run --example radial_gradients
```

Most of the code except the components API is contained within the `bevy_ui/src/render/linear_gradients` module.
There are no changes to any existing systems or plugins except for the addition of the gradients rendering systems to the render world schedule and the `Val` changes from #18164 . 

## Showcase

![gradients](https://github.com/user-attachments/assets/a09c5bb2-f9dc-4bc5-9d17-21a6338519d3)
![stacked](https://github.com/user-attachments/assets/7a1ad28e-8ae0-41d5-85b2-aa62647aef03)
![rad](https://github.com/user-attachments/assets/48609cf1-52aa-453c-afba-3b4845f3ddec)

Conic gradients can be used to draw simple pie charts like in CSS:
![PIE](https://github.com/user-attachments/assets/4594b96f-52ab-4974-911a-16d065d213bc)
2025-05-20 14:45:22 +00:00

458 lines
20 KiB
Rust

//! This example illustrates the various features of Bevy UI.
use std::f32::consts::PI;
use accesskit::{Node as Accessible, Role};
use bevy::{
a11y::AccessibilityNode,
color::palettes::{
basic::LIME,
css::{DARK_GRAY, NAVY},
},
input::mouse::{MouseScrollUnit, MouseWheel},
picking::hover::HoverMap,
prelude::*,
ui::widget::NodeImageMode,
};
fn main() {
let mut app = App::new();
app.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, update_scroll_position);
#[cfg(feature = "bevy_ui_debug")]
app.add_systems(Update, toggle_debug_overlay);
app.run();
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// Camera
commands.spawn((Camera2d, IsDefaultUiCamera, BoxShadowSamples(6)));
// root node
commands
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::SpaceBetween,
..default()
})
.insert(Pickable::IGNORE)
.with_children(|parent| {
// left vertical fill (border)
parent
.spawn((
Node {
width: Val::Px(200.),
border: UiRect::all(Val::Px(2.)),
..default()
},
BackgroundColor(Color::srgb(0.65, 0.65, 0.65)),
))
.with_children(|parent| {
// left vertical fill (content)
parent
.spawn((
Node {
width: Val::Percent(100.),
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(5.)),
row_gap: Val::Px(5.),
..default()
},
BackgroundColor(Color::srgb(0.15, 0.15, 0.15)),
Visibility::Visible,
))
.with_children(|parent| {
// text
parent.spawn((
Text::new("Text Example"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 25.0,
..default()
},
// Because this is a distinct label widget and
// not button/list item text, this is necessary
// for accessibility to treat the text accordingly.
Label,
));
#[cfg(feature = "bevy_ui_debug")]
{
// Debug overlay text
parent.spawn((
Text::new("Press Space to toggle debug outlines."),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
..default()
},
Label,
));
parent.spawn((
Text::new("V: toggle UI root's visibility"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 12.,
..default()
},
Label,
));
parent.spawn((
Text::new("S: toggle outlines for hidden nodes"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 12.,
..default()
},
Label,
));
parent.spawn((
Text::new("C: toggle outlines for clipped nodes"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 12.,
..default()
},
Label,
));
}
#[cfg(not(feature = "bevy_ui_debug"))]
parent.spawn((
Text::new("Try enabling feature \"bevy_ui_debug\"."),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
..default()
},
Label,
));
});
});
// right vertical fill
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
width: Val::Px(200.),
..default()
})
.with_children(|parent| {
// Title
parent.spawn((
Text::new("Scrolling list"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 21.,
..default()
},
Label,
));
// Scrolling list
parent
.spawn((
Node {
flex_direction: FlexDirection::Column,
align_self: AlignSelf::Stretch,
height: Val::Percent(50.),
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
))
.with_children(|parent| {
parent
.spawn((
Node {
flex_direction: FlexDirection::Column,
..Default::default()
},
BackgroundGradient::from(LinearGradient::to_bottom(vec![
ColorStop::auto(NAVY),
ColorStop::auto(Color::BLACK),
])),
Pickable {
should_block_lower: false,
..Default::default()
},
))
.with_children(|parent| {
// List items
for i in 0..25 {
parent
.spawn((
Text(format!("Item {i}")),
TextFont {
font: asset_server
.load("fonts/FiraSans-Bold.ttf"),
..default()
},
Label,
AccessibilityNode(Accessible::new(Role::ListItem)),
))
.insert(Pickable {
should_block_lower: false,
..default()
});
}
});
});
});
parent
.spawn(Node {
left: Val::Px(210.),
bottom: Val::Px(10.),
position_type: PositionType::Absolute,
..default()
})
.with_children(|parent| {
parent
.spawn((
Node {
width: Val::Px(200.0),
height: Val::Px(200.0),
border: UiRect::all(Val::Px(20.)),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
..default()
},
BorderColor(LIME.into()),
BackgroundColor(Color::srgb(0.8, 0.8, 1.)),
))
.with_children(|parent| {
parent.spawn((
ImageNode::new(asset_server.load("branding/bevy_logo_light.png")),
// Uses the transform to rotate the logo image by 45 degrees
Transform::from_rotation(Quat::from_rotation_z(0.25 * PI)),
BorderRadius::all(Val::Px(10.)),
Outline {
width: Val::Px(2.),
offset: Val::Px(4.),
color: DARK_GRAY.into(),
},
));
});
});
let shadow_style = ShadowStyle {
color: Color::BLACK.with_alpha(0.5),
blur_radius: Val::Px(2.),
x_offset: Val::Px(10.),
y_offset: Val::Px(10.),
..default()
};
// render order test: reddest in the back, whitest in the front (flex center)
parent
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
})
.insert(Pickable::IGNORE)
.with_children(|parent| {
parent
.spawn((
Node {
width: Val::Px(100.0),
height: Val::Px(100.0),
..default()
},
BackgroundColor(Color::srgb(1.0, 0.0, 0.)),
BoxShadow::from(shadow_style),
))
.with_children(|parent| {
parent.spawn((
Node {
// Take the size of the parent node.
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
left: Val::Px(20.),
bottom: Val::Px(20.),
..default()
},
BackgroundColor(Color::srgb(1.0, 0.3, 0.3)),
BoxShadow::from(shadow_style),
));
parent.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
left: Val::Px(40.),
bottom: Val::Px(40.),
..default()
},
BackgroundColor(Color::srgb(1.0, 0.5, 0.5)),
BoxShadow::from(shadow_style),
));
parent.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
left: Val::Px(60.),
bottom: Val::Px(60.),
..default()
},
BackgroundColor(Color::srgb(0.0, 0.7, 0.7)),
BoxShadow::from(shadow_style),
));
// alpha test
parent.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
left: Val::Px(80.),
bottom: Val::Px(80.),
..default()
},
BackgroundColor(Color::srgba(1.0, 0.9, 0.9, 0.4)),
BoxShadow::from(ShadowStyle {
color: Color::BLACK.with_alpha(0.3),
..shadow_style
}),
));
});
});
// bevy logo (flex center)
parent
.spawn(Node {
width: Val::Percent(100.0),
position_type: PositionType::Absolute,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart,
..default()
})
.with_children(|parent| {
// bevy logo (image)
parent
.spawn((
ImageNode::new(asset_server.load("branding/bevy_logo_dark_big.png"))
.with_mode(NodeImageMode::Stretch),
Node {
width: Val::Px(500.0),
height: Val::Px(125.0),
margin: UiRect::top(Val::VMin(5.)),
..default()
},
))
.with_children(|parent| {
// alt text
// This UI node takes up no space in the layout and the `Text` component is used by the accessibility module
// and is not rendered.
parent.spawn((
Node {
display: Display::None,
..default()
},
Text::new("Bevy logo"),
));
});
});
// four bevy icons demonstrating image flipping
parent
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
justify_content: JustifyContent::Center,
align_items: AlignItems::FlexEnd,
column_gap: Val::Px(10.),
padding: UiRect::all(Val::Px(10.)),
..default()
})
.insert(Pickable::IGNORE)
.with_children(|parent| {
for (flip_x, flip_y) in
[(false, false), (false, true), (true, true), (true, false)]
{
parent.spawn((
ImageNode {
image: asset_server.load("branding/icon.png"),
flip_x,
flip_y,
..default()
},
Node {
// The height will be chosen automatically to preserve the image's aspect ratio
width: Val::Px(75.),
..default()
},
));
}
});
});
}
#[cfg(feature = "bevy_ui_debug")]
// The system that will enable/disable the debug outlines around the nodes
fn toggle_debug_overlay(
input: Res<ButtonInput<KeyCode>>,
mut debug_options: ResMut<UiDebugOptions>,
mut root_node_query: Query<&mut Visibility, (With<Node>, Without<ChildOf>)>,
) {
info_once!("The debug outlines are enabled, press Space to turn them on/off");
if input.just_pressed(KeyCode::Space) {
// The toggle method will enable the debug overlay if disabled and disable if enabled
debug_options.toggle();
}
if input.just_pressed(KeyCode::KeyS) {
// Toggle debug outlines for nodes with `ViewVisibility` set to false.
debug_options.show_hidden = !debug_options.show_hidden;
}
if input.just_pressed(KeyCode::KeyC) {
// Toggle outlines for clipped UI nodes.
debug_options.show_clipped = !debug_options.show_clipped;
}
if input.just_pressed(KeyCode::KeyV) {
for mut visibility in root_node_query.iter_mut() {
// Toggle the UI root node's visibility
visibility.toggle_inherited_hidden();
}
}
}
/// Updates the scroll position of scrollable nodes in response to mouse input
pub fn update_scroll_position(
mut mouse_wheel_events: EventReader<MouseWheel>,
hover_map: Res<HoverMap>,
mut scrolled_node_query: Query<&mut ScrollPosition>,
keyboard_input: Res<ButtonInput<KeyCode>>,
) {
for mouse_wheel_event in mouse_wheel_events.read() {
let (mut dx, mut dy) = match mouse_wheel_event.unit {
MouseScrollUnit::Line => (mouse_wheel_event.x * 20., mouse_wheel_event.y * 20.),
MouseScrollUnit::Pixel => (mouse_wheel_event.x, mouse_wheel_event.y),
};
if keyboard_input.pressed(KeyCode::ShiftLeft) || keyboard_input.pressed(KeyCode::ShiftRight)
{
std::mem::swap(&mut dx, &mut dy);
}
for (_pointer, pointer_map) in hover_map.iter() {
for (entity, _hit) in pointer_map.iter() {
if let Ok(mut scroll_position) = scrolled_node_query.get_mut(*entity) {
scroll_position.offset_x -= dx;
scroll_position.offset_y -= dy;
}
}
}
}
}