From c62ca1a0d36c064e82f25c10b820b9dcdc319a87 Mon Sep 17 00:00:00 2001 From: DaoLendaye Date: Tue, 20 May 2025 22:45:04 +0800 Subject: [PATCH 001/286] Use material name for mesh entity's Name when available (#19287) # Objective Fixes #19286 ## Solution Use material name for mesh entity's Name when available ## Testing Test code, modified from examples/load_gltf.rs ```rust //! Loads and renders a glTF file as a scene. use bevy::{gltf::GltfMaterialName, prelude::*, scene::SceneInstanceReady}; fn main() { App::new() .add_plugins(DefaultPlugins) .add_systems(Startup, setup) .add_observer(on_scene_load) .run(); } fn setup(mut commands: Commands, asset_server: Res) { commands.spawn(( Camera3d::default(), Transform::from_xyz(0.7, 0.7, 1.0).looking_at(Vec3::new(0.0, 0.3, 0.0), Vec3::Y), )); commands.spawn((DirectionalLight { shadows_enabled: true, ..default() },)); commands.spawn(SceneRoot(asset_server.load( GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf"), ))); } fn on_scene_load( trigger: Trigger, children: Query<&Children>, names: Query<&Name>, material_names: Query<&GltfMaterialName>, ) { let target = trigger.target(); for child in children.iter_descendants(target) { let name = if let Ok(name) = names.get(child) { Some(name.to_string()) } else { None }; let material_name = if let Ok(name) = material_names.get(child) { Some(name.0.clone()) } else { None }; info!("Entity name:{:?} | material name:{:?}", name, material_name); } } ``` --- ## Showcase Run log: Image --------- Co-authored-by: Alice Cecile Co-authored-by: Greeble <166992735+greeble-dev@users.noreply.github.com> --- crates/bevy_gltf/src/loader/gltf_ext/mesh.rs | 12 ++++++++---- crates/bevy_gltf/src/loader/mod.rs | 5 +++-- .../rename_spawn_gltf_material_name.md | 9 +++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 release-content/migration-guides/rename_spawn_gltf_material_name.md diff --git a/crates/bevy_gltf/src/loader/gltf_ext/mesh.rs b/crates/bevy_gltf/src/loader/gltf_ext/mesh.rs index ef719891a4..60a153fed3 100644 --- a/crates/bevy_gltf/src/loader/gltf_ext/mesh.rs +++ b/crates/bevy_gltf/src/loader/gltf_ext/mesh.rs @@ -1,13 +1,17 @@ use bevy_mesh::PrimitiveTopology; -use gltf::mesh::{Mesh, Mode, Primitive}; +use gltf::{ + mesh::{Mesh, Mode}, + Material, +}; use crate::GltfError; -pub(crate) fn primitive_name(mesh: &Mesh<'_>, primitive: &Primitive) -> String { +pub(crate) fn primitive_name(mesh: &Mesh<'_>, material: &Material) -> String { let mesh_name = mesh.name().unwrap_or("Mesh"); - if mesh.primitives().len() > 1 { - format!("{}.{}", mesh_name, primitive.index()) + + if let Some(material_name) = material.name() { + format!("{}.{}", mesh_name, material_name) } else { mesh_name.to_string() } diff --git a/crates/bevy_gltf/src/loader/mod.rs b/crates/bevy_gltf/src/loader/mod.rs index f85a739b2e..b65e4bf81a 100644 --- a/crates/bevy_gltf/src/loader/mod.rs +++ b/crates/bevy_gltf/src/loader/mod.rs @@ -1464,10 +1464,11 @@ fn load_node( } if let Some(name) = material.name() { - mesh_entity.insert(GltfMaterialName(String::from(name))); + mesh_entity.insert(GltfMaterialName(name.to_string())); } - mesh_entity.insert(Name::new(primitive_name(&mesh, &primitive))); + mesh_entity.insert(Name::new(primitive_name(&mesh, &material))); + // Mark for adding skinned mesh if let Some(skin) = gltf_node.skin() { entity_to_skin_index_map.insert(mesh_entity.id(), skin.index()); diff --git a/release-content/migration-guides/rename_spawn_gltf_material_name.md b/release-content/migration-guides/rename_spawn_gltf_material_name.md new file mode 100644 index 0000000000..630697b5fd --- /dev/null +++ b/release-content/migration-guides/rename_spawn_gltf_material_name.md @@ -0,0 +1,9 @@ +--- +title: Use Gltf material names for spawned primitive entities +authors: ["@rendaoer"] +pull_requests: [19287] +--- + +When loading a Gltf scene in Bevy, each mesh primitive will generate an entity and store a `GltfMaterialName` component and `Name` component. + +The `Name` components were previously stored as mesh name plus primitive index - for example, `MeshName.0` and `MeshName.1`. To make it easier to view these entities in Inspector-style tools, they are now stored as mesh name plus material name - for example, `MeshName.Material1Name` and `MeshName.Material2Name`. From bf20c630a828c823c766d3384a65f59764329085 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 20 May 2025 15:45:22 +0100 Subject: [PATCH 002/286] 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) --- Cargo.toml | 33 + crates/bevy_ui/src/geometry.rs | 242 ++++- crates/bevy_ui/src/gradients.rs | 575 +++++++++++ crates/bevy_ui/src/layout/mod.rs | 46 +- crates/bevy_ui/src/lib.rs | 10 + crates/bevy_ui/src/render/gradient.rs | 916 ++++++++++++++++++ crates/bevy_ui/src/render/gradient.wgsl | 193 ++++ crates/bevy_ui/src/render/mod.rs | 9 + crates/bevy_ui/src/render/ui.wgsl | 38 +- crates/bevy_ui/src/ui_node.rs | 101 +- examples/README.md | 3 + examples/testbed/full_ui.rs | 55 +- examples/ui/gradients.rs | 186 ++++ examples/ui/radial_gradients.rs | 98 ++ examples/ui/stacked_gradients.rs | 87 ++ release-content/release-notes/ui_gradients.md | 26 + 16 files changed, 2487 insertions(+), 131 deletions(-) create mode 100644 crates/bevy_ui/src/gradients.rs create mode 100644 crates/bevy_ui/src/render/gradient.rs create mode 100644 crates/bevy_ui/src/render/gradient.wgsl create mode 100644 examples/ui/gradients.rs create mode 100644 examples/ui/radial_gradients.rs create mode 100644 examples/ui/stacked_gradients.rs create mode 100644 release-content/release-notes/ui_gradients.md diff --git a/Cargo.toml b/Cargo.toml index 7cf1334e81..3cd87c291c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3418,6 +3418,39 @@ description = "An example for CSS Grid layout" category = "UI (User Interface)" wasm = true +[[example]] +name = "gradients" +path = "examples/ui/gradients.rs" +doc-scrape-examples = true + +[package.metadata.example.gradients] +name = "Gradients" +description = "An example demonstrating gradients" +category = "UI (User Interface)" +wasm = true + +[[example]] +name = "stacked_gradients" +path = "examples/ui/stacked_gradients.rs" +doc-scrape-examples = true + +[package.metadata.example.stacked_gradients] +name = "Stacked Gradients" +description = "An example demonstrating stacked gradients" +category = "UI (User Interface)" +wasm = true + +[[example]] +name = "radial_gradients" +path = "examples/ui/radial_gradients.rs" +doc-scrape-examples = true + +[package.metadata.example.radial_gradients] +name = "Radial Gradients" +description = "An example demonstrating radial gradients" +category = "UI (User Interface)" +wasm = true + [[example]] name = "scroll" path = "examples/ui/scroll.rs" diff --git a/crates/bevy_ui/src/geometry.rs b/crates/bevy_ui/src/geometry.rs index 2e11075bca..674c85525b 100644 --- a/crates/bevy_ui/src/geometry.rs +++ b/crates/bevy_ui/src/geometry.rs @@ -1,5 +1,6 @@ use bevy_math::Vec2; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; +use bevy_utils::default; use core::ops::{Div, DivAssign, Mul, MulAssign, Neg}; use thiserror::Error; @@ -255,19 +256,23 @@ pub enum ValArithmeticError { } impl Val { - /// Resolves a [`Val`] from the given context values and returns this as an [`f32`]. - /// The [`Val::Px`] value (if present), `parent_size` and `viewport_size` should all be in the same coordinate space. - /// Returns a [`ValArithmeticError::NonEvaluable`] if the [`Val`] is impossible to resolve into a concrete value. + /// Resolves this [`Val`] to a value in physical pixels from the given `scale_factor`, `physical_base_value`, + /// and `physical_target_size` context values. /// - /// **Note:** If a [`Val::Px`] is resolved, its inner value is returned unchanged. - pub fn resolve(self, parent_size: f32, viewport_size: Vec2) -> Result { + /// Returns a [`ValArithmeticError::NonEvaluable`] if the [`Val`] is impossible to resolve into a concrete value. + pub fn resolve( + self, + scale_factor: f32, + physical_base_value: f32, + physical_target_size: Vec2, + ) -> Result { match self { - Val::Percent(value) => Ok(parent_size * value / 100.0), - Val::Px(value) => Ok(value), - Val::Vw(value) => Ok(viewport_size.x * value / 100.0), - Val::Vh(value) => Ok(viewport_size.y * value / 100.0), - Val::VMin(value) => Ok(viewport_size.min_element() * value / 100.0), - Val::VMax(value) => Ok(viewport_size.max_element() * value / 100.0), + Val::Percent(value) => Ok(physical_base_value * value / 100.0), + Val::Px(value) => Ok(value * scale_factor), + Val::Vw(value) => Ok(physical_target_size.x * value / 100.0), + Val::Vh(value) => Ok(physical_target_size.y * value / 100.0), + Val::VMin(value) => Ok(physical_target_size.min_element() * value / 100.0), + Val::VMax(value) => Ok(physical_target_size.max_element() * value / 100.0), Val::Auto => Err(ValArithmeticError::NonEvaluable), } } @@ -678,6 +683,179 @@ impl Default for UiRect { } } +#[derive(Debug, Clone, Copy, PartialEq, Reflect)] +#[reflect(Default, Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +/// Responsive position relative to a UI node. +pub struct Position { + /// Normalized anchor point + pub anchor: Vec2, + /// Responsive horizontal position relative to the anchor point + pub x: Val, + /// Responsive vertical position relative to the anchor point + pub y: Val, +} + +impl Default for Position { + fn default() -> Self { + Self::CENTER + } +} + +impl Position { + /// Position at the given normalized anchor point + pub const fn anchor(anchor: Vec2) -> Self { + Self { + anchor, + x: Val::ZERO, + y: Val::ZERO, + } + } + + /// Position at the top-left corner + pub const TOP_LEFT: Self = Self::anchor(Vec2::new(-0.5, -0.5)); + + /// Position at the center of the left edge + pub const LEFT: Self = Self::anchor(Vec2::new(-0.5, 0.0)); + + /// Position at the bottom-left corner + pub const BOTTOM_LEFT: Self = Self::anchor(Vec2::new(-0.5, 0.5)); + + /// Position at the center of the top edge + pub const TOP: Self = Self::anchor(Vec2::new(0.0, -0.5)); + + /// Position at the center of the element + pub const CENTER: Self = Self::anchor(Vec2::new(0.0, 0.0)); + + /// Position at the center of the bottom edge + pub const BOTTOM: Self = Self::anchor(Vec2::new(0.0, 0.5)); + + /// Position at the top-right corner + pub const TOP_RIGHT: Self = Self::anchor(Vec2::new(0.5, -0.5)); + + /// Position at the center of the right edge + pub const RIGHT: Self = Self::anchor(Vec2::new(0.5, 0.0)); + + /// Position at the bottom-right corner + pub const BOTTOM_RIGHT: Self = Self::anchor(Vec2::new(0.5, 0.5)); + + /// Create a new position + pub const fn new(anchor: Vec2, x: Val, y: Val) -> Self { + Self { anchor, x, y } + } + + /// Creates a position from self with the given `x` and `y` coordinates + pub const fn at(self, x: Val, y: Val) -> Self { + Self { x, y, ..self } + } + + /// Creates a position from self with the given `x` coordinate + pub const fn at_x(self, x: Val) -> Self { + Self { x, ..self } + } + + /// Creates a position from self with the given `y` coordinate + pub const fn at_y(self, y: Val) -> Self { + Self { y, ..self } + } + + /// Creates a position in logical pixels from self with the given `x` and `y` coordinates + pub const fn at_px(self, x: f32, y: f32) -> Self { + self.at(Val::Px(x), Val::Px(y)) + } + + /// Creates a percentage position from self with the given `x` and `y` coordinates + pub const fn at_percent(self, x: f32, y: f32) -> Self { + self.at(Val::Percent(x), Val::Percent(y)) + } + + /// Creates a position from self with the given `anchor` point + pub const fn with_anchor(self, anchor: Vec2) -> Self { + Self { anchor, ..self } + } + + /// Position relative to the top-left corner + pub const fn top_left(x: Val, y: Val) -> Self { + Self::TOP_LEFT.at(x, y) + } + + /// Position relative to the left edge + pub const fn left(x: Val, y: Val) -> Self { + Self::LEFT.at(x, y) + } + + /// Position relative to the bottom-left corner + pub const fn bottom_left(x: Val, y: Val) -> Self { + Self::BOTTOM_LEFT.at(x, y) + } + + /// Position relative to the top edge + pub const fn top(x: Val, y: Val) -> Self { + Self::TOP.at(x, y) + } + + /// Position relative to the center + pub const fn center(x: Val, y: Val) -> Self { + Self::CENTER.at(x, y) + } + + /// Position relative to the bottom edge + pub const fn bottom(x: Val, y: Val) -> Self { + Self::BOTTOM.at(x, y) + } + + /// Position relative to the top-right corner + pub const fn top_right(x: Val, y: Val) -> Self { + Self::TOP_RIGHT.at(x, y) + } + + /// Position relative to the right edge + pub const fn right(x: Val, y: Val) -> Self { + Self::RIGHT.at(x, y) + } + + /// Position relative to the bottom-right corner + pub const fn bottom_right(x: Val, y: Val) -> Self { + Self::BOTTOM_RIGHT.at(x, y) + } + + /// Resolves the `Position` into physical coordinates. + pub fn resolve( + self, + scale_factor: f32, + physical_size: Vec2, + physical_target_size: Vec2, + ) -> Vec2 { + let d = self.anchor.map(|p| if 0. < p { -1. } else { 1. }); + + physical_size * self.anchor + + d * Vec2::new( + self.x + .resolve(scale_factor, physical_size.x, physical_target_size) + .unwrap_or(0.), + self.y + .resolve(scale_factor, physical_size.y, physical_target_size) + .unwrap_or(0.), + ) + } +} + +impl From for Position { + fn from(x: Val) -> Self { + Self { x, ..default() } + } +} + +impl From<(Val, Val)> for Position { + fn from((x, y): (Val, Val)) -> Self { + Self { x, y, ..default() } + } +} + #[cfg(test)] mod tests { use crate::geometry::*; @@ -687,7 +865,7 @@ mod tests { fn val_evaluate() { let size = 250.; let viewport_size = vec2(1000., 500.); - let result = Val::Percent(80.).resolve(size, viewport_size).unwrap(); + let result = Val::Percent(80.).resolve(1., size, viewport_size).unwrap(); assert_eq!(result, size * 0.8); } @@ -696,7 +874,7 @@ mod tests { fn val_resolve_px() { let size = 250.; let viewport_size = vec2(1000., 500.); - let result = Val::Px(10.).resolve(size, viewport_size).unwrap(); + let result = Val::Px(10.).resolve(1., size, viewport_size).unwrap(); assert_eq!(result, 10.); } @@ -709,33 +887,45 @@ mod tests { for value in (-10..10).map(|value| value as f32) { // for a square viewport there should be no difference between `Vw` and `Vh` and between `Vmin` and `Vmax`. assert_eq!( - Val::Vw(value).resolve(size, viewport_size), - Val::Vh(value).resolve(size, viewport_size) + Val::Vw(value).resolve(1., size, viewport_size), + Val::Vh(value).resolve(1., size, viewport_size) ); assert_eq!( - Val::VMin(value).resolve(size, viewport_size), - Val::VMax(value).resolve(size, viewport_size) + Val::VMin(value).resolve(1., size, viewport_size), + Val::VMax(value).resolve(1., size, viewport_size) ); assert_eq!( - Val::VMin(value).resolve(size, viewport_size), - Val::Vw(value).resolve(size, viewport_size) + Val::VMin(value).resolve(1., size, viewport_size), + Val::Vw(value).resolve(1., size, viewport_size) ); } let viewport_size = vec2(1000., 500.); - assert_eq!(Val::Vw(100.).resolve(size, viewport_size).unwrap(), 1000.); - assert_eq!(Val::Vh(100.).resolve(size, viewport_size).unwrap(), 500.); - assert_eq!(Val::Vw(60.).resolve(size, viewport_size).unwrap(), 600.); - assert_eq!(Val::Vh(40.).resolve(size, viewport_size).unwrap(), 200.); - assert_eq!(Val::VMin(50.).resolve(size, viewport_size).unwrap(), 250.); - assert_eq!(Val::VMax(75.).resolve(size, viewport_size).unwrap(), 750.); + assert_eq!( + Val::Vw(100.).resolve(1., size, viewport_size).unwrap(), + 1000. + ); + assert_eq!( + Val::Vh(100.).resolve(1., size, viewport_size).unwrap(), + 500. + ); + assert_eq!(Val::Vw(60.).resolve(1., size, viewport_size).unwrap(), 600.); + assert_eq!(Val::Vh(40.).resolve(1., size, viewport_size).unwrap(), 200.); + assert_eq!( + Val::VMin(50.).resolve(1., size, viewport_size).unwrap(), + 250. + ); + assert_eq!( + Val::VMax(75.).resolve(1., size, viewport_size).unwrap(), + 750. + ); } #[test] fn val_auto_is_non_evaluable() { let size = 250.; let viewport_size = vec2(1000., 500.); - let resolve_auto = Val::Auto.resolve(size, viewport_size); + let resolve_auto = Val::Auto.resolve(1., size, viewport_size); assert_eq!(resolve_auto, Err(ValArithmeticError::NonEvaluable)); } diff --git a/crates/bevy_ui/src/gradients.rs b/crates/bevy_ui/src/gradients.rs new file mode 100644 index 0000000000..a8dc670bc2 --- /dev/null +++ b/crates/bevy_ui/src/gradients.rs @@ -0,0 +1,575 @@ +use crate::{Position, Val}; +use bevy_color::{Color, Srgba}; +use bevy_ecs::component::Component; +use bevy_math::Vec2; +use bevy_reflect::prelude::*; +use core::{f32, f32::consts::TAU}; + +/// A color stop for a gradient +#[derive(Debug, Copy, Clone, PartialEq, Reflect)] +#[reflect(Default, PartialEq, Debug)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct ColorStop { + /// Color + pub color: Color, + /// Logical position along the gradient line. + /// Stop positions are relative to the start of the gradient and not other stops. + pub point: Val, + /// Normalized position between this and the following stop of the interpolation midpoint. + pub hint: f32, +} + +impl ColorStop { + /// Create a new color stop + pub fn new(color: impl Into, point: Val) -> Self { + Self { + color: color.into(), + point, + hint: 0.5, + } + } + + /// An automatic color stop. + /// The positions of automatic stops are interpolated evenly between explicit stops. + pub fn auto(color: impl Into) -> Self { + Self { + color: color.into(), + point: Val::Auto, + hint: 0.5, + } + } + + // Set the interpolation midpoint between this and and the following stop + pub fn with_hint(mut self, hint: f32) -> Self { + self.hint = hint; + self + } +} + +impl From<(Color, Val)> for ColorStop { + fn from((color, stop): (Color, Val)) -> Self { + Self { + color, + point: stop, + hint: 0.5, + } + } +} + +impl From for ColorStop { + fn from(color: Color) -> Self { + Self { + color, + point: Val::Auto, + hint: 0.5, + } + } +} + +impl From for ColorStop { + fn from(color: Srgba) -> Self { + Self { + color: color.into(), + point: Val::Auto, + hint: 0.5, + } + } +} + +impl Default for ColorStop { + fn default() -> Self { + Self { + color: Color::WHITE, + point: Val::Auto, + hint: 0.5, + } + } +} + +/// An angular color stop for a conic gradient +#[derive(Debug, Copy, Clone, PartialEq, Reflect)] +#[reflect(Default, PartialEq, Debug)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct AngularColorStop { + /// Color of the stop + pub color: Color, + /// The angle of the stop. + /// Angles are relative to the start of the gradient and not other stops. + /// If set to `None` the angle of the stop will be interpolated between the explicit stops or 0 and 2 PI degrees if there no explicit stops. + /// Given angles are clamped to between `0.`, and [`TAU`]. + /// This means that a list of stops: + /// ``` + /// # use std::f32::consts::TAU; + /// # use bevy_ui::AngularColorStop; + /// # use bevy_color::{Color, palettes::css::{RED, BLUE}}; + /// let stops = [ + /// AngularColorStop::new(Color::WHITE, 0.), + /// AngularColorStop::new(Color::BLACK, -1.), + /// AngularColorStop::new(RED, 2. * TAU), + /// AngularColorStop::new(BLUE, TAU), + /// ]; + /// ``` + /// is equivalent to: + /// ``` + /// # use std::f32::consts::TAU; + /// # use bevy_ui::AngularColorStop; + /// # use bevy_color::{Color, palettes::css::{RED, BLUE}}; + /// let stops = [ + /// AngularColorStop::new(Color::WHITE, 0.), + /// AngularColorStop::new(Color::BLACK, 0.), + /// AngularColorStop::new(RED, TAU), + /// AngularColorStop::new(BLUE, TAU), + /// ]; + /// ``` + /// Resulting in a black to red gradient, not white to blue. + pub angle: Option, + /// Normalized angle between this and the following stop of the interpolation midpoint. + pub hint: f32, +} + +impl AngularColorStop { + // Create a new color stop + pub fn new(color: impl Into, angle: f32) -> Self { + Self { + color: color.into(), + angle: Some(angle), + hint: 0.5, + } + } + + /// An angular stop without an explicit angle. The angles of automatic stops + /// are interpolated evenly between explicit stops. + pub fn auto(color: impl Into) -> Self { + Self { + color: color.into(), + angle: None, + hint: 0.5, + } + } + + // Set the interpolation midpoint between this and and the following stop + pub fn with_hint(mut self, hint: f32) -> Self { + self.hint = hint; + self + } +} + +impl From<(Color, f32)> for AngularColorStop { + fn from((color, angle): (Color, f32)) -> Self { + Self { + color, + angle: Some(angle), + hint: 0.5, + } + } +} + +impl From for AngularColorStop { + fn from(color: Color) -> Self { + Self { + color, + angle: None, + hint: 0.5, + } + } +} + +impl From for AngularColorStop { + fn from(color: Srgba) -> Self { + Self { + color: color.into(), + angle: None, + hint: 0.5, + } + } +} + +impl Default for AngularColorStop { + fn default() -> Self { + Self { + color: Color::WHITE, + angle: None, + hint: 0.5, + } + } +} + +/// A linear gradient +/// +/// +#[derive(Clone, PartialEq, Debug, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct LinearGradient { + /// The direction of the gradient. + /// An angle of `0.` points upward, angles increasing clockwise. + pub angle: f32, + /// The list of color stops + pub stops: Vec, +} + +impl LinearGradient { + /// Angle of a linear gradient transitioning from bottom to top + pub const TO_TOP: f32 = 0.; + /// Angle of a linear gradient transitioning from bottom-left to top-right + pub const TO_TOP_RIGHT: f32 = TAU / 8.; + /// Angle of a linear gradient transitioning from left to right + pub const TO_RIGHT: f32 = 2. * Self::TO_TOP_RIGHT; + /// Angle of a linear gradient transitioning from top-left to bottom-right + pub const TO_BOTTOM_RIGHT: f32 = 3. * Self::TO_TOP_RIGHT; + /// Angle of a linear gradient transitioning from top to bottom + pub const TO_BOTTOM: f32 = 4. * Self::TO_TOP_RIGHT; + /// Angle of a linear gradient transitioning from top-right to bottom-left + pub const TO_BOTTOM_LEFT: f32 = 5. * Self::TO_TOP_RIGHT; + /// Angle of a linear gradient transitioning from right to left + pub const TO_LEFT: f32 = 6. * Self::TO_TOP_RIGHT; + /// Angle of a linear gradient transitioning from bottom-right to top-left + pub const TO_TOP_LEFT: f32 = 7. * Self::TO_TOP_RIGHT; + + /// Create a new linear gradient + pub fn new(angle: f32, stops: Vec) -> Self { + Self { angle, stops } + } + + /// A linear gradient transitioning from bottom to top + pub fn to_top(stops: Vec) -> Self { + Self { + angle: Self::TO_TOP, + stops, + } + } + + /// A linear gradient transitioning from bottom-left to top-right + pub fn to_top_right(stops: Vec) -> Self { + Self { + angle: Self::TO_TOP_RIGHT, + stops, + } + } + + /// A linear gradient transitioning from left to right + pub fn to_right(stops: Vec) -> Self { + Self { + angle: Self::TO_RIGHT, + stops, + } + } + + /// A linear gradient transitioning from top-left to bottom-right + pub fn to_bottom_right(stops: Vec) -> Self { + Self { + angle: Self::TO_BOTTOM_RIGHT, + stops, + } + } + + /// A linear gradient transitioning from top to bottom + pub fn to_bottom(stops: Vec) -> Self { + Self { + angle: Self::TO_BOTTOM, + stops, + } + } + + /// A linear gradient transitioning from top-right to bottom-left + pub fn to_bottom_left(stops: Vec) -> Self { + Self { + angle: Self::TO_BOTTOM_LEFT, + stops, + } + } + + /// A linear gradient transitioning from right to left + pub fn to_left(stops: Vec) -> Self { + Self { + angle: Self::TO_LEFT, + stops, + } + } + + /// A linear gradient transitioning from bottom-right to top-left + pub fn to_top_left(stops: Vec) -> Self { + Self { + angle: Self::TO_TOP_LEFT, + stops, + } + } + + /// A linear gradient with the given angle in degrees + pub fn degrees(degrees: f32, stops: Vec) -> Self { + Self { + angle: degrees.to_radians(), + stops, + } + } +} + +/// A radial gradient +/// +/// +#[derive(Clone, PartialEq, Debug, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct RadialGradient { + /// The center of the radial gradient + pub position: Position, + /// Defines the end shape of the radial gradient + pub shape: RadialGradientShape, + /// The list of color stops + pub stops: Vec, +} + +impl RadialGradient { + /// Create a new radial gradient + pub fn new(position: Position, shape: RadialGradientShape, stops: Vec) -> Self { + Self { + position, + shape, + stops, + } + } +} + +impl Default for RadialGradient { + fn default() -> Self { + Self { + position: Position::CENTER, + shape: RadialGradientShape::ClosestCorner, + stops: Vec::new(), + } + } +} + +/// A conic gradient +/// +/// +#[derive(Clone, PartialEq, Debug, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct ConicGradient { + /// The starting angle of the gradient in radians + pub start: f32, + /// The center of the conic gradient + pub position: Position, + /// The list of color stops + pub stops: Vec, +} + +impl ConicGradient { + /// create a new conic gradient + pub fn new(position: Position, stops: Vec) -> Self { + Self { + start: 0., + position, + stops, + } + } + + /// Sets the starting angle of the gradient + pub fn with_start(mut self, start: f32) -> Self { + self.start = start; + self + } + + /// Sets the position of the gradient + pub fn with_position(mut self, position: Position) -> Self { + self.position = position; + self + } +} + +#[derive(Clone, PartialEq, Debug, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum Gradient { + /// A linear gradient + /// + /// + Linear(LinearGradient), + /// A radial gradient + /// + /// + Radial(RadialGradient), + /// A conic gradient + /// + /// + Conic(ConicGradient), +} + +impl Gradient { + /// Returns true if the gradient has no stops. + pub fn is_empty(&self) -> bool { + match self { + Gradient::Linear(gradient) => gradient.stops.is_empty(), + Gradient::Radial(gradient) => gradient.stops.is_empty(), + Gradient::Conic(gradient) => gradient.stops.is_empty(), + } + } + + /// If the gradient has only a single color stop `get_single` returns its color. + pub fn get_single(&self) -> Option { + match self { + Gradient::Linear(gradient) => gradient + .stops + .first() + .and_then(|stop| (gradient.stops.len() == 1).then_some(stop.color)), + Gradient::Radial(gradient) => gradient + .stops + .first() + .and_then(|stop| (gradient.stops.len() == 1).then_some(stop.color)), + Gradient::Conic(gradient) => gradient + .stops + .first() + .and_then(|stop| (gradient.stops.len() == 1).then_some(stop.color)), + } + } +} + +impl From for Gradient { + fn from(value: LinearGradient) -> Self { + Self::Linear(value) + } +} + +impl From for Gradient { + fn from(value: RadialGradient) -> Self { + Self::Radial(value) + } +} + +impl From for Gradient { + fn from(value: ConicGradient) -> Self { + Self::Conic(value) + } +} + +#[derive(Component, Clone, PartialEq, Debug, Default, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +/// A UI node that displays a gradient +pub struct BackgroundGradient(pub Vec); + +impl> From for BackgroundGradient { + fn from(value: T) -> Self { + Self(vec![value.into()]) + } +} + +#[derive(Component, Clone, PartialEq, Debug, Default, Reflect)] +#[reflect(PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +/// A UI node border that displays a gradient +pub struct BorderGradient(pub Vec); + +impl> From for BorderGradient { + fn from(value: T) -> Self { + Self(vec![value.into()]) + } +} + +#[derive(Default, Copy, Clone, PartialEq, Debug, Reflect)] +#[reflect(PartialEq, Default)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum RadialGradientShape { + /// A circle with radius equal to the distance from its center to the closest side + ClosestSide, + /// A circle with radius equal to the distance from its center to the farthest side + FarthestSide, + /// An ellipse with extents equal to the distance from its center to the nearest corner + #[default] + ClosestCorner, + /// An ellipse with extents equal to the distance from its center to the farthest corner + FarthestCorner, + /// A circle + Circle(Val), + /// An ellipse + Ellipse(Val, Val), +} + +fn close_side(p: f32, h: f32) -> f32 { + (-h - p).abs().min((h - p).abs()) +} + +fn far_side(p: f32, h: f32) -> f32 { + (-h - p).abs().max((h - p).abs()) +} + +fn close_side2(p: Vec2, h: Vec2) -> f32 { + close_side(p.x, h.x).min(close_side(p.y, h.y)) +} + +fn far_side2(p: Vec2, h: Vec2) -> f32 { + far_side(p.x, h.x).max(far_side(p.y, h.y)) +} + +impl RadialGradientShape { + /// Resolve the physical dimensions of the end shape of the radial gradient + pub fn resolve( + self, + position: Vec2, + scale_factor: f32, + physical_size: Vec2, + physical_target_size: Vec2, + ) -> Vec2 { + let half_size = 0.5 * physical_size; + match self { + RadialGradientShape::ClosestSide => Vec2::splat(close_side2(position, half_size)), + RadialGradientShape::FarthestSide => Vec2::splat(far_side2(position, half_size)), + RadialGradientShape::ClosestCorner => Vec2::new( + close_side(position.x, half_size.x), + close_side(position.y, half_size.y), + ), + RadialGradientShape::FarthestCorner => Vec2::new( + far_side(position.x, half_size.x), + far_side(position.y, half_size.y), + ), + RadialGradientShape::Circle(radius) => Vec2::splat( + radius + .resolve(scale_factor, physical_size.x, physical_target_size) + .unwrap_or(0.), + ), + RadialGradientShape::Ellipse(x, y) => Vec2::new( + x.resolve(scale_factor, physical_size.x, physical_target_size) + .unwrap_or(0.), + y.resolve(scale_factor, physical_size.y, physical_target_size) + .unwrap_or(0.), + ), + } + } +} diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index 9e6906b1f7..b38241a95a 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,7 +1,7 @@ use crate::{ experimental::{UiChildren, UiRootNodes}, BorderRadius, ComputedNode, ComputedNodeTarget, ContentSize, Display, LayoutConfig, Node, - Outline, OverflowAxis, ScrollPosition, Val, + Outline, OverflowAxis, ScrollPosition, }; use bevy_ecs::{ change_detection::{DetectChanges, DetectChangesMut}, @@ -174,7 +174,7 @@ with UI components as a child of an entity without UI components, your UI layout ui_root_entity, &mut ui_surface, true, - None, + computed_target.physical_size().as_vec2(), &mut node_transform_query, &ui_children, computed_target.scale_factor.recip(), @@ -189,7 +189,7 @@ with UI components as a child of an entity without UI components, your UI layout entity: Entity, ui_surface: &mut UiSurface, inherited_use_rounding: bool, - root_size: Option, + target_size: Vec2, node_transform_query: &mut Query<( &mut ComputedNode, &mut Transform, @@ -253,14 +253,12 @@ with UI components as a child of an entity without UI components, your UI layout node.bypass_change_detection().border = taffy_rect_to_border_rect(layout.border); node.bypass_change_detection().padding = taffy_rect_to_border_rect(layout.padding); - let viewport_size = root_size.unwrap_or(node.size); - if let Some(border_radius) = maybe_border_radius { // We don't trigger change detection for changes to border radius node.bypass_change_detection().border_radius = border_radius.resolve( - node.size, - viewport_size, inverse_target_scale_factor.recip(), + node.size, + target_size, ); } @@ -268,24 +266,28 @@ with UI components as a child of an entity without UI components, your UI layout // don't trigger change detection when only outlines are changed let node = node.bypass_change_detection(); node.outline_width = if style.display != Display::None { - match outline.width { - Val::Px(w) => Val::Px(w / inverse_target_scale_factor), - width => width, - } - .resolve(node.size().x, viewport_size) - .unwrap_or(0.) - .max(0.) + outline + .width + .resolve( + inverse_target_scale_factor.recip(), + node.size().x, + target_size, + ) + .unwrap_or(0.) + .max(0.) } else { 0. }; - node.outline_offset = match outline.offset { - Val::Px(offset) => Val::Px(offset / inverse_target_scale_factor), - offset => offset, - } - .resolve(node.size().x, viewport_size) - .unwrap_or(0.) - .max(0.); + node.outline_offset = outline + .offset + .resolve( + inverse_target_scale_factor.recip(), + node.size().x, + target_size, + ) + .unwrap_or(0.) + .max(0.); } if transform.translation.truncate() != node_center { @@ -330,7 +332,7 @@ with UI components as a child of an entity without UI components, your UI layout child_uinode, ui_surface, use_rounding, - Some(viewport_size), + target_size, node_transform_query, ui_children, inverse_target_scale_factor, diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index ae54ffe607..5aef92453d 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -15,6 +15,7 @@ pub mod ui_material; pub mod update; pub mod widget; +pub mod gradients; #[cfg(feature = "bevy_ui_picking_backend")] pub mod picking_backend; @@ -35,6 +36,7 @@ mod ui_node; pub use focus::*; pub use geometry::*; +pub use gradients::*; pub use layout::*; pub use measurement::*; pub use render::*; @@ -59,6 +61,7 @@ pub mod prelude { pub use { crate::{ geometry::*, + gradients::*, ui_material::*, ui_node::*, widget::{Button, ImageNode, Label, NodeImageMode, ViewportNode}, @@ -176,6 +179,13 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() .register_type::() .configure_sets( PostUpdate, diff --git a/crates/bevy_ui/src/render/gradient.rs b/crates/bevy_ui/src/render/gradient.rs new file mode 100644 index 0000000000..b908e148e9 --- /dev/null +++ b/crates/bevy_ui/src/render/gradient.rs @@ -0,0 +1,916 @@ +use core::{ + f32::consts::{FRAC_PI_2, TAU}, + hash::Hash, + ops::Range, +}; + +use crate::*; +use bevy_asset::*; +use bevy_color::{ColorToComponents, LinearRgba}; +use bevy_ecs::{ + prelude::Component, + system::{ + lifetimeless::{Read, SRes}, + *, + }, +}; +use bevy_image::prelude::*; +use bevy_math::{ + ops::{cos, sin}, + FloatOrd, Mat4, Rect, Vec2, Vec3Swizzles, Vec4Swizzles, +}; +use bevy_render::sync_world::MainEntity; +use bevy_render::{ + render_phase::*, + render_resource::{binding_types::uniform_buffer, *}, + renderer::{RenderDevice, RenderQueue}, + sync_world::TemporaryRenderEntity, + view::*, + Extract, ExtractSchedule, Render, RenderSystems, +}; +use bevy_sprite::BorderRect; +use bevy_transform::prelude::GlobalTransform; +use bytemuck::{Pod, Zeroable}; + +pub const UI_GRADIENT_SHADER_HANDLE: Handle = + weak_handle!("10116113-aac4-47fa-91c8-35cbe80dddcb"); + +pub struct GradientPlugin; + +impl Plugin for GradientPlugin { + fn build(&self, app: &mut App) { + load_internal_asset!( + app, + UI_GRADIENT_SHADER_HANDLE, + "gradient.wgsl", + Shader::from_wgsl + ); + + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app + .add_render_command::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::>() + .add_systems( + ExtractSchedule, + extract_gradients + .in_set(RenderUiSystems::ExtractGradient) + .after(extract_uinode_background_colors), + ) + .add_systems( + Render, + ( + queue_gradient.in_set(RenderSystems::Queue), + prepare_gradient.in_set(RenderSystems::PrepareBindGroups), + ), + ); + } + } + + fn finish(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +#[derive(Component)] +pub struct GradientBatch { + pub range: Range, +} + +#[derive(Resource)] +pub struct GradientMeta { + vertices: RawBufferVec, + indices: RawBufferVec, + view_bind_group: Option, +} + +impl Default for GradientMeta { + fn default() -> Self { + Self { + vertices: RawBufferVec::new(BufferUsages::VERTEX), + indices: RawBufferVec::new(BufferUsages::INDEX), + view_bind_group: None, + } + } +} + +#[derive(Resource)] +pub struct GradientPipeline { + pub view_layout: BindGroupLayout, +} + +impl FromWorld for GradientPipeline { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + + let view_layout = render_device.create_bind_group_layout( + "ui_gradient_view_layout", + &BindGroupLayoutEntries::single( + ShaderStages::VERTEX_FRAGMENT, + uniform_buffer::(true), + ), + ); + + GradientPipeline { view_layout } + } +} + +pub fn compute_gradient_line_length(angle: f32, size: Vec2) -> f32 { + let center = 0.5 * size; + let v = Vec2::new(sin(angle), -cos(angle)); + + let (pos_corner, neg_corner) = if v.x >= 0.0 && v.y <= 0.0 { + (size.with_y(0.), size.with_x(0.)) + } else if v.x >= 0.0 && v.y > 0.0 { + (size, Vec2::ZERO) + } else if v.x < 0.0 && v.y <= 0.0 { + (Vec2::ZERO, size) + } else { + (size.with_x(0.), size.with_y(0.)) + }; + + let t_pos = (pos_corner - center).dot(v); + let t_neg = (neg_corner - center).dot(v); + + (t_pos - t_neg).abs() +} + +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +pub struct UiGradientPipelineKey { + anti_alias: bool, + pub hdr: bool, +} + +impl SpecializedRenderPipeline for GradientPipeline { + type Key = UiGradientPipelineKey; + + fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let vertex_layout = VertexBufferLayout::from_vertex_formats( + VertexStepMode::Vertex, + vec![ + // position + VertexFormat::Float32x3, + // uv + VertexFormat::Float32x2, + // flags + VertexFormat::Uint32, + // radius + VertexFormat::Float32x4, + // border + VertexFormat::Float32x4, + // size + VertexFormat::Float32x2, + // point + VertexFormat::Float32x2, + // start_point + VertexFormat::Float32x2, + // dir + VertexFormat::Float32x2, + // start_color + VertexFormat::Float32x4, + // start_len + VertexFormat::Float32, + // end_len + VertexFormat::Float32, + // end color + VertexFormat::Float32x4, + // hint + VertexFormat::Float32, + ], + ); + let shader_defs = if key.anti_alias { + vec!["ANTI_ALIAS".into()] + } else { + Vec::new() + }; + + RenderPipelineDescriptor { + vertex: VertexState { + shader: UI_GRADIENT_SHADER_HANDLE, + entry_point: "vertex".into(), + shader_defs: shader_defs.clone(), + buffers: vec![vertex_layout], + }, + fragment: Some(FragmentState { + shader: UI_GRADIENT_SHADER_HANDLE, + shader_defs, + entry_point: "fragment".into(), + targets: vec![Some(ColorTargetState { + format: if key.hdr { + ViewTarget::TEXTURE_FORMAT_HDR + } else { + TextureFormat::bevy_default() + }, + blend: Some(BlendState::ALPHA_BLENDING), + write_mask: ColorWrites::ALL, + })], + }), + layout: vec![self.view_layout.clone()], + push_constant_ranges: Vec::new(), + primitive: PrimitiveState { + front_face: FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: PolygonMode::Fill, + conservative: false, + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + }, + depth_stencil: None, + multisample: MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + label: Some("ui_gradient_pipeline".into()), + zero_initialize_workgroup_memory: false, + } + } +} + +pub enum ResolvedGradient { + Linear { angle: f32 }, + Conic { center: Vec2, start: f32 }, + Radial { center: Vec2, size: Vec2 }, +} + +pub struct ExtractedGradient { + pub stack_index: u32, + pub transform: Mat4, + pub rect: Rect, + pub clip: Option, + pub extracted_camera_entity: Entity, + /// range into `ExtractedColorStops` + pub stops_range: Range, + pub node_type: NodeType, + pub main_entity: MainEntity, + pub render_entity: Entity, + /// Border radius of the UI node. + /// Ordering: top left, top right, bottom right, bottom left. + pub border_radius: ResolvedBorderRadius, + /// Border thickness of the UI node. + /// Ordering: left, top, right, bottom. + pub border: BorderRect, + pub resolved_gradient: ResolvedGradient, +} + +#[derive(Resource, Default)] +pub struct ExtractedGradients { + pub items: Vec, +} + +#[derive(Resource, Default)] +pub struct ExtractedColorStops(pub Vec<(LinearRgba, f32, f32)>); + +// Interpolate implicit stops (where position is `f32::NAN`) +// If the first and last stops are implicit set them to the `min` and `max` values +// so that we always have explicit start and end points to interpolate between. +fn interpolate_color_stops(stops: &mut [(LinearRgba, f32, f32)], min: f32, max: f32) { + if stops[0].1.is_nan() { + stops[0].1 = min; + } + if stops.last().unwrap().1.is_nan() { + stops.last_mut().unwrap().1 = max; + } + + let mut i = 1; + + while i < stops.len() - 1 { + let point = stops[i].1; + if point.is_nan() { + let start = i; + let mut end = i + 1; + while end < stops.len() - 1 && stops[end].1.is_nan() { + end += 1; + } + let start_point = stops[start - 1].1; + let end_point = stops[end].1; + let steps = end - start; + let step = (end_point - start_point) / (steps + 1) as f32; + for j in 0..steps { + stops[i + j].1 = start_point + step * (j + 1) as f32; + } + i = end; + } + i += 1; + } +} + +fn compute_color_stops( + stops: &[ColorStop], + scale_factor: f32, + length: f32, + target_size: Vec2, + scratch: &mut Vec<(LinearRgba, f32, f32)>, + extracted_color_stops: &mut Vec<(LinearRgba, f32, f32)>, +) { + // resolve the physical distances of explicit stops and sort them + scratch.extend(stops.iter().filter_map(|stop| { + stop.point + .resolve(scale_factor, length, target_size) + .ok() + .map(|physical_point| (stop.color.to_linear(), physical_point, stop.hint)) + })); + scratch.sort_by_key(|(_, point, _)| FloatOrd(*point)); + + let min = scratch + .first() + .map(|(_, min, _)| *min) + .unwrap_or(0.) + .min(0.); + + // get the position of the last explicit stop and use the full length of the gradient if no explicit stops + let max = scratch + .last() + .map(|(_, max, _)| *max) + .unwrap_or(length) + .max(length); + + let mut sorted_stops_drain = scratch.drain(..); + + let range_start = extracted_color_stops.len(); + + // Fill the extracted color stops buffer + extracted_color_stops.extend(stops.iter().map(|stop| { + if stop.point == Val::Auto { + (stop.color.to_linear(), f32::NAN, stop.hint) + } else { + sorted_stops_drain.next().unwrap() + } + })); + + interpolate_color_stops(&mut extracted_color_stops[range_start..], min, max); +} + +pub fn extract_gradients( + mut commands: Commands, + mut extracted_gradients: ResMut, + mut extracted_color_stops: ResMut, + mut extracted_uinodes: ResMut, + gradients_query: Extract< + Query<( + Entity, + &ComputedNode, + &ComputedNodeTarget, + &GlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + AnyOf<(&BackgroundGradient, &BorderGradient)>, + )>, + >, + camera_map: Extract, +) { + let mut camera_mapper = camera_map.get_mapper(); + let mut sorted_stops = vec![]; + + for ( + entity, + uinode, + target, + transform, + inherited_visibility, + clip, + (gradient, gradient_border), + ) in &gradients_query + { + // Skip invisible images + if !inherited_visibility.get() { + continue; + } + + let Some(extracted_camera_entity) = camera_mapper.map(target) else { + continue; + }; + + for (gradients, node_type) in [ + (gradient.map(|g| &g.0), NodeType::Rect), + (gradient_border.map(|g| &g.0), NodeType::Border), + ] + .iter() + .filter_map(|(g, n)| g.map(|g| (g, *n))) + { + for gradient in gradients.iter() { + if gradient.is_empty() { + continue; + } + if let Some(color) = gradient.get_single() { + // With a single color stop there's no gradient, fill the node with the color + extracted_uinodes.uinodes.push(ExtractedUiNode { + stack_index: uinode.stack_index, + color: color.into(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + image: AssetId::default(), + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + item: ExtractedUiItem::Node { + atlas_scaling: None, + flip_x: false, + flip_y: false, + border_radius: uinode.border_radius, + border: uinode.border, + node_type, + transform: transform.compute_matrix(), + }, + main_entity: entity.into(), + render_entity: commands.spawn(TemporaryRenderEntity).id(), + }); + continue; + } + match gradient { + Gradient::Linear(LinearGradient { angle, stops }) => { + let length = compute_gradient_line_length(*angle, uinode.size); + + let range_start = extracted_color_stops.0.len(); + + compute_color_stops( + stops, + target.scale_factor, + length, + target.physical_size.as_vec2(), + &mut sorted_stops, + &mut extracted_color_stops.0, + ); + + extracted_gradients.items.push(ExtractedGradient { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + stack_index: uinode.stack_index, + transform: transform.compute_matrix(), + stops_range: range_start..extracted_color_stops.0.len(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + main_entity: entity.into(), + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Linear { angle: *angle }, + }); + } + Gradient::Radial(RadialGradient { + position: center, + shape, + stops, + }) => { + let c = center.resolve( + target.scale_factor, + uinode.size, + target.physical_size.as_vec2(), + ); + + let size = shape.resolve( + c, + target.scale_factor, + uinode.size, + target.physical_size.as_vec2(), + ); + + let length = size.x; + + let range_start = extracted_color_stops.0.len(); + compute_color_stops( + stops, + target.scale_factor, + length, + target.physical_size.as_vec2(), + &mut sorted_stops, + &mut extracted_color_stops.0, + ); + + extracted_gradients.items.push(ExtractedGradient { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + stack_index: uinode.stack_index, + transform: transform.compute_matrix(), + stops_range: range_start..extracted_color_stops.0.len(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + main_entity: entity.into(), + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Radial { center: c, size }, + }); + } + Gradient::Conic(ConicGradient { + start, + position: center, + stops, + }) => { + let g_start = center.resolve( + target.scale_factor(), + uinode.size, + target.physical_size().as_vec2(), + ); + let range_start = extracted_color_stops.0.len(); + + // sort the explicit stops + sorted_stops.extend(stops.iter().filter_map(|stop| { + stop.angle.map(|angle| { + (stop.color.to_linear(), angle.clamp(0., TAU), stop.hint) + }) + })); + sorted_stops.sort_by_key(|(_, angle, _)| FloatOrd(*angle)); + let mut sorted_stops_drain = sorted_stops.drain(..); + + // fill the extracted stops buffer + extracted_color_stops.0.extend(stops.iter().map(|stop| { + if stop.angle.is_none() { + (stop.color.to_linear(), f32::NAN, stop.hint) + } else { + sorted_stops_drain.next().unwrap() + } + })); + + interpolate_color_stops( + &mut extracted_color_stops.0[range_start..], + 0., + TAU, + ); + + extracted_gradients.items.push(ExtractedGradient { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + stack_index: uinode.stack_index, + transform: transform.compute_matrix(), + stops_range: range_start..extracted_color_stops.0.len(), + rect: Rect { + min: Vec2::ZERO, + max: uinode.size, + }, + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + main_entity: entity.into(), + node_type, + border_radius: uinode.border_radius, + border: uinode.border, + resolved_gradient: ResolvedGradient::Conic { + start: *start, + center: g_start, + }, + }); + } + } + } + } + } +} + +#[expect( + clippy::too_many_arguments, + reason = "it's a system that needs a lot of them" +)] +pub fn queue_gradient( + extracted_gradients: ResMut, + gradients_pipeline: Res, + mut pipelines: ResMut>, + mut transparent_render_phases: ResMut>, + mut render_views: Query<(&UiCameraView, Option<&UiAntiAlias>), With>, + camera_views: Query<&ExtractedView>, + pipeline_cache: Res, + draw_functions: Res>, +) { + let draw_function = draw_functions.read().id::(); + for (index, gradient) in extracted_gradients.items.iter().enumerate() { + let Ok((default_camera_view, ui_anti_alias)) = + render_views.get_mut(gradient.extracted_camera_entity) + else { + continue; + }; + + let Ok(view) = camera_views.get(default_camera_view.0) else { + continue; + }; + + let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity) + else { + continue; + }; + + let pipeline = pipelines.specialize( + &pipeline_cache, + &gradients_pipeline, + UiGradientPipelineKey { + anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), + hdr: view.hdr, + }, + ); + + transparent_phase.add(TransparentUi { + draw_function, + pipeline, + entity: (gradient.render_entity, gradient.main_entity), + sort_key: FloatOrd(gradient.stack_index as f32 + stack_z_offsets::GRADIENT), + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::None, + index, + indexed: true, + }); + } +} + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct UiGradientVertex { + position: [f32; 3], + uv: [f32; 2], + flags: u32, + radius: [f32; 4], + border: [f32; 4], + size: [f32; 2], + point: [f32; 2], + g_start: [f32; 2], + g_dir: [f32; 2], + start_color: [f32; 4], + start_len: f32, + end_len: f32, + end_color: [f32; 4], + hint: f32, +} + +pub fn prepare_gradient( + mut commands: Commands, + render_device: Res, + render_queue: Res, + mut ui_meta: ResMut, + mut extracted_gradients: ResMut, + mut extracted_color_stops: ResMut, + view_uniforms: Res, + gradients_pipeline: Res, + mut phases: ResMut>, + mut previous_len: Local, +) { + if let Some(view_binding) = view_uniforms.uniforms.binding() { + let mut batches: Vec<(Entity, GradientBatch)> = Vec::with_capacity(*previous_len); + + ui_meta.vertices.clear(); + ui_meta.indices.clear(); + ui_meta.view_bind_group = Some(render_device.create_bind_group( + "gradient_view_bind_group", + &gradients_pipeline.view_layout, + &BindGroupEntries::single(view_binding), + )); + + // Buffer indexes + let mut vertices_index = 0; + let mut indices_index = 0; + + for ui_phase in phases.values_mut() { + for item_index in 0..ui_phase.items.len() { + let item = &mut ui_phase.items[item_index]; + if let Some(gradient) = extracted_gradients + .items + .get(item.index) + .filter(|n| item.entity() == n.render_entity) + { + *item.batch_range_mut() = item_index as u32..item_index as u32 + 1; + let uinode_rect = gradient.rect; + + let rect_size = uinode_rect.size().extend(1.0); + + // Specify the corners of the node + let positions = QUAD_VERTEX_POSITIONS + .map(|pos| (gradient.transform * (pos * rect_size).extend(1.)).xyz()); + let corner_points = QUAD_VERTEX_POSITIONS.map(|pos| pos.xy() * rect_size.xy()); + + // Calculate the effect of clipping + // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) + let positions_diff = if let Some(clip) = gradient.clip { + [ + Vec2::new( + f32::max(clip.min.x - positions[0].x, 0.), + f32::max(clip.min.y - positions[0].y, 0.), + ), + Vec2::new( + f32::min(clip.max.x - positions[1].x, 0.), + f32::max(clip.min.y - positions[1].y, 0.), + ), + Vec2::new( + f32::min(clip.max.x - positions[2].x, 0.), + f32::min(clip.max.y - positions[2].y, 0.), + ), + Vec2::new( + f32::max(clip.min.x - positions[3].x, 0.), + f32::min(clip.max.y - positions[3].y, 0.), + ), + ] + } else { + [Vec2::ZERO; 4] + }; + + let positions_clipped = [ + positions[0] + positions_diff[0].extend(0.), + positions[1] + positions_diff[1].extend(0.), + positions[2] + positions_diff[2].extend(0.), + positions[3] + positions_diff[3].extend(0.), + ]; + + let points = [ + corner_points[0] + positions_diff[0], + corner_points[1] + positions_diff[1], + corner_points[2] + positions_diff[2], + corner_points[3] + positions_diff[3], + ]; + + let transformed_rect_size = gradient.transform.transform_vector3(rect_size); + + // Don't try to cull nodes that have a rotation + // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π + // In those two cases, the culling check can proceed normally as corners will be on + // horizontal / vertical lines + // For all other angles, bypass the culling check + // This does not properly handles all rotations on all axis + if gradient.transform.x_axis[1] == 0.0 { + // Cull nodes that are completely clipped + if positions_diff[0].x - positions_diff[1].x >= transformed_rect_size.x + || positions_diff[1].y - positions_diff[2].y >= transformed_rect_size.y + { + continue; + } + } + + let uvs = { [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y] }; + + let mut flags = if gradient.node_type == NodeType::Border { + shader_flags::BORDER + } else { + 0 + }; + + let (g_start, g_dir, g_flags) = match gradient.resolved_gradient { + ResolvedGradient::Linear { angle } => { + let corner_index = (angle - FRAC_PI_2).rem_euclid(TAU) / FRAC_PI_2; + ( + corner_points[corner_index as usize].into(), + // CSS angles increase in a clockwise direction + [sin(angle), -cos(angle)], + 0, + ) + } + ResolvedGradient::Conic { center, start } => { + (center.into(), [start, 0.], shader_flags::CONIC) + } + ResolvedGradient::Radial { center, size } => ( + center.into(), + Vec2::splat(if size.y != 0. { size.x / size.y } else { 1. }).into(), + shader_flags::RADIAL, + ), + }; + + flags |= g_flags; + + let range = gradient.stops_range.start..gradient.stops_range.end - 1; + let mut segment_count = 0; + + for stop_index in range { + let mut start_stop = extracted_color_stops.0[stop_index]; + let end_stop = extracted_color_stops.0[stop_index + 1]; + if start_stop.1 == end_stop.1 { + if stop_index == gradient.stops_range.end - 2 { + if 0 < segment_count { + start_stop.0 = LinearRgba::NONE; + } + } else { + continue; + } + } + let start_color = start_stop.0.to_f32_array(); + let end_color = end_stop.0.to_f32_array(); + let mut stop_flags = flags; + if 0. < start_stop.1 + && (stop_index == gradient.stops_range.start || segment_count == 0) + { + stop_flags |= shader_flags::FILL_START; + } + if stop_index == gradient.stops_range.end - 2 { + stop_flags |= shader_flags::FILL_END; + } + + for i in 0..4 { + ui_meta.vertices.push(UiGradientVertex { + position: positions_clipped[i].into(), + uv: uvs[i].into(), + flags: stop_flags | shader_flags::CORNERS[i], + radius: [ + gradient.border_radius.top_left, + gradient.border_radius.top_right, + gradient.border_radius.bottom_right, + gradient.border_radius.bottom_left, + ], + border: [ + gradient.border.left, + gradient.border.top, + gradient.border.right, + gradient.border.bottom, + ], + size: rect_size.xy().into(), + g_start, + g_dir, + point: points[i].into(), + start_color, + start_len: start_stop.1, + end_len: end_stop.1, + end_color, + hint: start_stop.2, + }); + } + + for &i in &QUAD_INDICES { + ui_meta.indices.push(indices_index + i as u32); + } + indices_index += 4; + segment_count += 1; + } + + if 0 < segment_count { + let vertices_count = 6 * segment_count; + + batches.push(( + item.entity(), + GradientBatch { + range: vertices_index..(vertices_index + vertices_count), + }, + )); + + vertices_index += vertices_count; + } + } + } + } + ui_meta.vertices.write_buffer(&render_device, &render_queue); + ui_meta.indices.write_buffer(&render_device, &render_queue); + *previous_len = batches.len(); + commands.try_insert_batch(batches); + } + extracted_gradients.items.clear(); + extracted_color_stops.0.clear(); +} + +pub type DrawGradientFns = (SetItemPipeline, SetGradientViewBindGroup<0>, DrawGradient); + +pub struct SetGradientViewBindGroup; +impl RenderCommand

for SetGradientViewBindGroup { + type Param = SRes; + type ViewQuery = Read; + type ItemQuery = (); + + fn render<'w>( + _item: &P, + view_uniform: &'w ViewUniformOffset, + _entity: Option<()>, + ui_meta: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(view_bind_group) = ui_meta.into_inner().view_bind_group.as_ref() else { + return RenderCommandResult::Failure("view_bind_group not available"); + }; + pass.set_bind_group(I, view_bind_group, &[view_uniform.offset]); + RenderCommandResult::Success + } +} + +pub struct DrawGradient; +impl RenderCommand

for DrawGradient { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = Read; + + #[inline] + fn render<'w>( + _item: &P, + _view: (), + batch: Option<&'w GradientBatch>, + ui_meta: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(batch) = batch else { + return RenderCommandResult::Skip; + }; + let ui_meta = ui_meta.into_inner(); + let Some(vertices) = ui_meta.vertices.buffer() else { + return RenderCommandResult::Failure("missing vertices to draw ui"); + }; + let Some(indices) = ui_meta.indices.buffer() else { + return RenderCommandResult::Failure("missing indices to draw ui"); + }; + + // Store the vertices + pass.set_vertex_buffer(0, vertices.slice(..)); + // Define how to "connect" the vertices + pass.set_index_buffer(indices.slice(..), 0, IndexFormat::Uint32); + // Draw the vertices + pass.draw_indexed(batch.range.clone(), 0, 0..1); + RenderCommandResult::Success + } +} diff --git a/crates/bevy_ui/src/render/gradient.wgsl b/crates/bevy_ui/src/render/gradient.wgsl new file mode 100644 index 0000000000..7dd4212510 --- /dev/null +++ b/crates/bevy_ui/src/render/gradient.wgsl @@ -0,0 +1,193 @@ +#import bevy_render::view::View +#import bevy_ui::ui_node::{ + draw_uinode_background, + draw_uinode_border, +} + +const PI: f32 = 3.14159265358979323846; +const TAU: f32 = 2. * PI; + +const TEXTURED = 1u; +const RIGHT_VERTEX = 2u; +const BOTTOM_VERTEX = 4u; +const BORDER: u32 = 8u; +const RADIAL: u32 = 16u; +const FILL_START: u32 = 32u; +const FILL_END: u32 = 64u; +const CONIC: u32 = 128u; + +fn enabled(flags: u32, mask: u32) -> bool { + return (flags & mask) != 0u; +} + +@group(0) @binding(0) var view: View; + +struct GradientVertexOutput { + @location(0) uv: vec2, + @location(1) @interpolate(flat) size: vec2, + @location(2) @interpolate(flat) flags: u32, + @location(3) @interpolate(flat) radius: vec4, + @location(4) @interpolate(flat) border: vec4, + + // Position relative to the center of the rectangle. + @location(5) point: vec2, + @location(6) @interpolate(flat) g_start: vec2, + @location(7) @interpolate(flat) dir: vec2, + @location(8) @interpolate(flat) start_color: vec4, + @location(9) @interpolate(flat) start_len: f32, + @location(10) @interpolate(flat) end_len: f32, + @location(11) @interpolate(flat) end_color: vec4, + @location(12) @interpolate(flat) hint: f32, + @builtin(position) position: vec4, +}; + +@vertex +fn vertex( + @location(0) vertex_position: vec3, + @location(1) vertex_uv: vec2, + @location(2) flags: u32, + + // x: top left, y: top right, z: bottom right, w: bottom left. + @location(3) radius: vec4, + + // x: left, y: top, z: right, w: bottom. + @location(4) border: vec4, + @location(5) size: vec2, + @location(6) point: vec2, + @location(7) @interpolate(flat) g_start: vec2, + @location(8) @interpolate(flat) dir: vec2, + @location(9) @interpolate(flat) start_color: vec4, + @location(10) @interpolate(flat) start_len: f32, + @location(11) @interpolate(flat) end_len: f32, + @location(12) @interpolate(flat) end_color: vec4, + @location(13) @interpolate(flat) hint: f32 +) -> GradientVertexOutput { + var out: GradientVertexOutput; + out.position = view.clip_from_world * vec4(vertex_position, 1.0); + out.uv = vertex_uv; + out.size = size; + out.flags = flags; + out.radius = radius; + out.border = border; + out.point = point; + out.dir = dir; + out.start_color = start_color; + out.start_len = start_len; + out.end_len = end_len; + out.end_color = end_color; + out.g_start = g_start; + out.hint = hint; + + return out; +} + +@fragment +fn fragment(in: GradientVertexOutput) -> @location(0) vec4 { + var g_distance: f32; + if enabled(in.flags, RADIAL) { + g_distance = radial_distance(in.point, in.g_start, in.dir.x); + } else if enabled(in.flags, CONIC) { + g_distance = conic_distance(in.dir.x, in.point, in.g_start); + } else { + g_distance = linear_distance(in.point, in.g_start, in.dir); + } + + let gradient_color = interpolate_gradient( + g_distance, + in.start_color, + in.start_len, + in.end_color, + in.end_len, + in.hint, + in.flags + ); + + if enabled(in.flags, BORDER) { + return draw_uinode_border(gradient_color, in.point, in.size, in.radius, in.border); + } else { + return draw_uinode_background(gradient_color, in.point, in.size, in.radius, in.border); + } +} + +// This function converts two linear rgb colors to srgb space, mixes them, and then converts the result back to linear rgb space. +fn mix_linear_rgb_in_srgb_space(a: vec4, b: vec4, t: f32) -> vec4 { + let a_srgb = pow(a.rgb, vec3(1. / 2.2)); + let b_srgb = pow(b.rgb, vec3(1. / 2.2)); + let mixed_srgb = mix(a_srgb, b_srgb, t); + return vec4(pow(mixed_srgb, vec3(2.2)), mix(a.a, b.a, t)); +} + +// These functions are used to calculate the distance in gradient space from the start of the gradient to the point. +// The distance in gradient space is then used to interpolate between the start and end colors. + +fn linear_distance( + point: vec2, + g_start: vec2, + g_dir: vec2, +) -> f32 { + return dot(point - g_start, g_dir); +} + +fn radial_distance( + point: vec2, + center: vec2, + ratio: f32, +) -> f32 { + let d = point - center; + return length(vec2(d.x, d.y * ratio)); +} + +fn conic_distance( + start: f32, + point: vec2, + center: vec2, +) -> f32 { + let d = point - center; + let angle = atan2(-d.x, d.y) + PI; + return (((angle - start) % TAU) + TAU) % TAU; +} + +fn interpolate_gradient( + distance: f32, + start_color: vec4, + start_distance: f32, + end_color: vec4, + end_distance: f32, + hint: f32, + flags: u32, +) -> vec4 { + if start_distance == end_distance { + if distance <= start_distance && enabled(flags, FILL_START) { + return start_color; + } + if start_distance <= distance && enabled(flags, FILL_END) { + return end_color; + } + return vec4(0.); + } + + var t = (distance - start_distance) / (end_distance - start_distance); + + if t < 0.0 { + if enabled(flags, FILL_START) { + return start_color; + } + return vec4(0.0); + } + + if 1. < t { + if enabled(flags, FILL_END) { + return end_color; + } + return vec4(0.0); + } + + if t < hint { + t = 0.5 * t / hint; + } else { + t = 0.5 * (1 + (t - hint) / (1.0 - hint)); + } + + // Only color interpolation in SRGB space is supported atm. + return mix_linear_rgb_in_srgb_space(start_color, end_color, t); +} diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index e811cfe362..8b0d6bad87 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -6,6 +6,7 @@ pub mod ui_texture_slice_pipeline; #[cfg(feature = "bevy_ui_debug")] mod debug_overlay; +mod gradient; use crate::widget::{ImageNode, ViewportNode}; use crate::{ @@ -48,6 +49,7 @@ use bevy_render::{ use bevy_sprite::{BorderRect, SpriteAssetEvents}; #[cfg(feature = "bevy_ui_debug")] pub use debug_overlay::UiDebugOptions; +use gradient::GradientPlugin; use crate::{Display, Node}; use bevy_platform::collections::{HashMap, HashSet}; @@ -94,6 +96,7 @@ pub mod stack_z_offsets { pub const BOX_SHADOW: f32 = -0.1; pub const TEXTURE_SLICE: f32 = 0.0; pub const NODE: f32 = 0.0; + pub const GRADIENT: f32 = 0.1; pub const MATERIAL: f32 = 0.18267; } @@ -112,6 +115,7 @@ pub enum RenderUiSystems { ExtractTextShadows, ExtractText, ExtractDebug, + ExtractGradient, } /// Deprecated alias for [`RenderUiSystems`]. @@ -196,6 +200,7 @@ pub fn build_ui_render(app: &mut App) { } app.add_plugins(UiTextureSlicerPlugin); + app.add_plugins(GradientPlugin); app.add_plugins(BoxShadowPlugin); } @@ -1077,6 +1082,10 @@ pub mod shader_flags { /// Ordering: top left, top right, bottom right, bottom left. pub const CORNERS: [u32; 4] = [0, 2, 2 | 4, 4]; pub const BORDER: u32 = 8; + pub const RADIAL: u32 = 16; + pub const FILL_START: u32 = 32; + pub const FILL_END: u32 = 64; + pub const CONIC: u32 = 128; } pub fn queue_uinodes( diff --git a/crates/bevy_ui/src/render/ui.wgsl b/crates/bevy_ui/src/render/ui.wgsl index 3fd339405d..67e57d8312 100644 --- a/crates/bevy_ui/src/render/ui.wgsl +++ b/crates/bevy_ui/src/render/ui.wgsl @@ -1,3 +1,5 @@ +#define_import_path bevy_ui::ui_node + #import bevy_render::view::View const TEXTURED = 1u; @@ -120,23 +122,25 @@ fn antialias(distance: f32) -> f32 { return saturate(0.5 - distance); } -fn draw(in: VertexOutput, texture_color: vec4) -> vec4 { - // 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)); - +fn draw_uinode_border( + color: vec4, + point: vec2, + size: vec2, + radius: vec4, + border: vec4, +) -> vec4 { // 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 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); + let external_distance = sd_rounded_box(point, size, 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 the external distance. - let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border); + let internal_distance = sd_inset_rounded_box(point, size, radius, 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 @@ -157,11 +161,15 @@ fn draw(in: VertexOutput, texture_color: vec4) -> vec4 { return vec4(color.rgb, saturate(color.a * t)); } -fn draw_background(in: VertexOutput, texture_color: vec4) -> vec4 { - let color = select(in.color, in.color * texture_color, enabled(in.flags, TEXTURED)); - +fn draw_uinode_background( + color: vec4, + point: vec2, + size: vec2, + radius: vec4, + border: vec4, +) -> vec4 { // When drawing the background only draw the internal area and not the border. - let internal_distance = sd_inset_rounded_box(in.point, in.size, in.radius, in.border); + let internal_distance = sd_inset_rounded_box(point, size, radius, border); #ifdef ANTI_ALIAS let t = antialias(internal_distance); @@ -176,9 +184,13 @@ fn draw_background(in: VertexOutput, texture_color: vec4) -> vec4 { fn fragment(in: VertexOutput) -> @location(0) 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)); + if enabled(in.flags, BORDER) { - return draw(in, texture_color); + return draw_uinode_border(color, in.point, in.size, in.radius, in.border); } else { - return draw_background(in, texture_color); + return draw_uinode_background(color, in.point, in.size, in.radius, in.border); } } diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index c95859624e..fc0cf0d127 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -13,7 +13,7 @@ use bevy_sprite::BorderRect; use bevy_transform::components::Transform; use bevy_utils::once; use bevy_window::{PrimaryWindow, WindowRef}; -use core::num::NonZero; +use core::{f32, num::NonZero}; use derive_more::derive::From; use smallvec::SmallVec; use thiserror::Error; @@ -2432,54 +2432,49 @@ impl BorderRadius { /// Returns the radius of the corner in physical pixels. pub fn resolve_single_corner( radius: Val, - node_size: Vec2, - viewport_size: Vec2, scale_factor: f32, + min_length: f32, + viewport_size: Vec2, ) -> f32 { - match radius { - Val::Auto => 0., - Val::Px(px) => px * scale_factor, - 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., 0.5 * node_size.min_element()) + radius + .resolve(scale_factor, min_length, viewport_size) + .unwrap_or(0.) + .clamp(0., 0.5 * min_length) } /// Resolve the border radii for the corners from the given context values. /// Returns the radii of the each corner in physical pixels. pub fn resolve( &self, + scale_factor: f32, node_size: Vec2, viewport_size: Vec2, - scale_factor: f32, ) -> ResolvedBorderRadius { + let length = node_size.min_element(); ResolvedBorderRadius { top_left: Self::resolve_single_corner( self.top_left, - node_size, - viewport_size, scale_factor, + length, + viewport_size, ), top_right: Self::resolve_single_corner( self.top_right, - node_size, - viewport_size, scale_factor, + length, + viewport_size, ), bottom_left: Self::resolve_single_corner( self.bottom_left, - node_size, - viewport_size, scale_factor, + length, + viewport_size, ), bottom_right: Self::resolve_single_corner( self.bottom_right, - node_size, - viewport_size, scale_factor, + length, + viewport_size, ), } } @@ -2600,37 +2595,6 @@ impl Default for LayoutConfig { } } -#[cfg(test)] -mod tests { - use crate::GridPlacement; - - #[test] - fn invalid_grid_placement_values() { - assert!(std::panic::catch_unwind(|| GridPlacement::span(0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::start(0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::end(0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::start_end(0, 1)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::start_end(-1, 0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::start_span(1, 0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::start_span(0, 1)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::end_span(0, 1)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::end_span(1, 0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::default().set_start(0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::default().set_end(0)).is_err()); - assert!(std::panic::catch_unwind(|| GridPlacement::default().set_span(0)).is_err()); - } - - #[test] - fn grid_placement_accessors() { - assert_eq!(GridPlacement::start(5).get_start(), Some(5)); - assert_eq!(GridPlacement::end(-4).get_end(), Some(-4)); - assert_eq!(GridPlacement::span(2).get_span(), Some(2)); - assert_eq!(GridPlacement::start_end(11, 21).get_span(), None); - assert_eq!(GridPlacement::start_span(3, 5).get_end(), None); - assert_eq!(GridPlacement::end_span(-4, 12).get_start(), None); - } -} - /// Indicates that this root [`Node`] entity should be rendered to a specific camera. /// /// UI then will be laid out respecting the camera's viewport and scale factor, and @@ -2828,3 +2792,34 @@ impl Default for TextShadow { } } } + +#[cfg(test)] +mod tests { + use crate::GridPlacement; + + #[test] + fn invalid_grid_placement_values() { + assert!(std::panic::catch_unwind(|| GridPlacement::span(0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::start(0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::end(0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::start_end(0, 1)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::start_end(-1, 0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::start_span(1, 0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::start_span(0, 1)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::end_span(0, 1)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::end_span(1, 0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::default().set_start(0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::default().set_end(0)).is_err()); + assert!(std::panic::catch_unwind(|| GridPlacement::default().set_span(0)).is_err()); + } + + #[test] + fn grid_placement_accessors() { + assert_eq!(GridPlacement::start(5).get_start(), Some(5)); + assert_eq!(GridPlacement::end(-4).get_end(), Some(-4)); + assert_eq!(GridPlacement::span(2).get_span(), Some(2)); + assert_eq!(GridPlacement::start_end(11, 21).get_span(), None); + assert_eq!(GridPlacement::start_span(3, 5).get_end(), None); + assert_eq!(GridPlacement::end_span(-4, 12).get_start(), None); + } +} diff --git a/examples/README.md b/examples/README.md index 060683f96d..a4ff3474dd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -547,13 +547,16 @@ Example | Description [Flex Layout](../examples/ui/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text [Font Atlas Debug](../examples/ui/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally) [Ghost Nodes](../examples/ui/ghost_nodes.rs) | Demonstrates the use of Ghost Nodes to skip entities in the UI layout hierarchy +[Gradients](../examples/ui/gradients.rs) | An example demonstrating gradients [Overflow](../examples/ui/overflow.rs) | Simple example demonstrating overflow behavior [Overflow Clip Margin](../examples/ui/overflow_clip_margin.rs) | Simple example demonstrating the OverflowClipMargin style property [Overflow and Clipping Debug](../examples/ui/overflow_debug.rs) | An example to debug overflow and clipping behavior +[Radial Gradients](../examples/ui/radial_gradients.rs) | An example demonstrating radial gradients [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 [Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers [Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node. +[Stacked Gradients](../examples/ui/stacked_gradients.rs) | An example demonstrating stacked gradients [Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation between UI elements [Text](../examples/ui/text.rs) | Illustrates creating and updating text [Text Background Colors](../examples/ui/text_background_colors.rs) | Demonstrates text background colors diff --git a/examples/testbed/full_ui.rs b/examples/testbed/full_ui.rs index 4c28cf04cd..551785ca0f 100644 --- a/examples/testbed/full_ui.rs +++ b/examples/testbed/full_ui.rs @@ -5,7 +5,10 @@ use std::f32::consts::PI; use accesskit::{Node as Accessible, Role}; use bevy::{ a11y::AccessibilityNode, - color::palettes::{basic::LIME, css::DARK_GRAY}, + color::palettes::{ + basic::LIME, + css::{DARK_GRAY, NAVY}, + }, input::mouse::{MouseScrollUnit, MouseWheel}, picking::hover::HoverMap, prelude::*, @@ -162,23 +165,41 @@ fn setup(mut commands: Commands, asset_server: Res) { BackgroundColor(Color::srgb(0.10, 0.10, 0.10)), )) .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 { + 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::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() + }); + } + }); }); }); diff --git a/examples/ui/gradients.rs b/examples/ui/gradients.rs new file mode 100644 index 0000000000..e3ee565fda --- /dev/null +++ b/examples/ui/gradients.rs @@ -0,0 +1,186 @@ +//! Simple example demonstrating linear gradients. + +use bevy::color::palettes::css::BLUE; +use bevy::color::palettes::css::GREEN; +use bevy::color::palettes::css::INDIGO; +use bevy::color::palettes::css::LIME; +use bevy::color::palettes::css::ORANGE; +use bevy::color::palettes::css::RED; +use bevy::color::palettes::css::VIOLET; +use bevy::color::palettes::css::YELLOW; +use bevy::prelude::*; +use bevy::ui::ColorStop; +use std::f32::consts::TAU; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, update) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); + + commands + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(30.), + margin: UiRect::all(Val::Px(30.)), + ..Default::default() + }) + .with_children(|commands| { + for (b, stops) in [ + ( + 5., + vec![ + ColorStop::new(Color::WHITE, Val::Percent(15.)), + ColorStop::new(Color::BLACK, Val::Percent(85.)), + ], + ), + (5., vec![RED.into(), BLUE.into(), LIME.into()]), + ( + 0., + vec![ + RED.into(), + ColorStop::new(RED, Val::Percent(100. / 7.)), + ColorStop::new(ORANGE, Val::Percent(100. / 7.)), + ColorStop::new(ORANGE, Val::Percent(200. / 7.)), + ColorStop::new(YELLOW, Val::Percent(200. / 7.)), + ColorStop::new(YELLOW, Val::Percent(300. / 7.)), + ColorStop::new(GREEN, Val::Percent(300. / 7.)), + ColorStop::new(GREEN, Val::Percent(400. / 7.)), + ColorStop::new(BLUE, Val::Percent(400. / 7.)), + ColorStop::new(BLUE, Val::Percent(500. / 7.)), + ColorStop::new(INDIGO, Val::Percent(500. / 7.)), + ColorStop::new(INDIGO, Val::Percent(600. / 7.)), + ColorStop::new(VIOLET, Val::Percent(600. / 7.)), + VIOLET.into(), + ], + ), + ] { + commands.spawn(Node::default()).with_children(|commands| { + commands + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: Val::Px(10.), + ..Default::default() + }) + .with_children(|commands| { + for (w, h) in [(100., 100.), (50., 100.), (100., 50.)] { + commands + .spawn(Node { + column_gap: Val::Px(10.), + ..Default::default() + }) + .with_children(|commands| { + for angle in (0..8).map(|i| i as f32 * TAU / 8.) { + commands.spawn(( + Node { + width: Val::Px(w), + height: Val::Px(h), + border: UiRect::all(Val::Px(b)), + ..default() + }, + BorderRadius::all(Val::Px(20.)), + BackgroundGradient::from(LinearGradient { + angle, + stops: stops.clone(), + }), + BorderGradient::from(LinearGradient { + angle: 3. * TAU / 8., + stops: vec![ + YELLOW.into(), + Color::WHITE.into(), + ORANGE.into(), + ], + }), + )); + } + }); + } + }); + + commands.spawn(Node::default()).with_children(|commands| { + commands.spawn(( + Node { + aspect_ratio: Some(1.), + height: Val::Percent(100.), + border: UiRect::all(Val::Px(b)), + margin: UiRect::left(Val::Px(30.)), + ..default() + }, + BorderRadius::all(Val::Px(20.)), + BackgroundGradient::from(LinearGradient { + angle: 0., + stops: stops.clone(), + }), + BorderGradient::from(LinearGradient { + angle: 3. * TAU / 8., + stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + }), + AnimateMarker, + )); + + commands.spawn(( + Node { + aspect_ratio: Some(1.), + height: Val::Percent(100.), + border: UiRect::all(Val::Px(b)), + margin: UiRect::left(Val::Px(30.)), + ..default() + }, + BorderRadius::all(Val::Px(20.)), + BackgroundGradient::from(RadialGradient { + stops: stops.clone(), + shape: RadialGradientShape::ClosestSide, + position: Position::CENTER, + }), + BorderGradient::from(LinearGradient { + angle: 3. * TAU / 8., + stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + }), + AnimateMarker, + )); + commands.spawn(( + Node { + aspect_ratio: Some(1.), + height: Val::Percent(100.), + border: UiRect::all(Val::Px(b)), + margin: UiRect::left(Val::Px(30.)), + ..default() + }, + BorderRadius::all(Val::Px(20.)), + BackgroundGradient::from(ConicGradient { + start: 0., + stops: stops + .iter() + .map(|stop| AngularColorStop::auto(stop.color)) + .collect(), + position: Position::CENTER, + }), + BorderGradient::from(LinearGradient { + angle: 3. * TAU / 8., + stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + }), + AnimateMarker, + )); + }); + }); + } + }); +} + +#[derive(Component)] +struct AnimateMarker; + +fn update(time: Res