From 5d5a95fa6e919763536706824673dca8f849ebe0 Mon Sep 17 00:00:00 2001 From: Lailatova <132931168+Lailatova@users.noreply.github.com> Date: Sat, 21 Jun 2025 17:05:04 +0200 Subject: [PATCH 01/12] Fix issue 19734: add dependency on bevy_utils for the bevy_ecs test. (#19738) Without this dependency, the bevy_ecs tests fail with missing as_string methods. # Objective - Fixes #19734 ## Solution - add bevy_utils with feature = "Debug" to dev-dependencies ## Testing - Ran `cargo test -p bevy_ecs` - Ran `taplo fmt --check` --- --- crates/bevy_ecs/src/lib.rs | 8 ++-- crates/bevy_ecs/src/observer/runner.rs | 4 +- crates/bevy_ecs/src/query/mod.rs | 2 +- crates/bevy_ecs/src/query/state.rs | 39 +++++-------------- crates/bevy_ecs/src/schedule/mod.rs | 7 +++- crates/bevy_ecs/src/system/mod.rs | 32 +++++---------- crates/bevy_ecs/src/system/system.rs | 3 -- crates/bevy_ecs/src/system/system_name.rs | 1 + crates/bevy_ecs/src/system/system_param.rs | 5 ++- crates/bevy_ecs/src/system/system_registry.rs | 3 -- 10 files changed, 34 insertions(+), 70 deletions(-) diff --git a/crates/bevy_ecs/src/lib.rs b/crates/bevy_ecs/src/lib.rs index e5f0e908e5..ecd77b028f 100644 --- a/crates/bevy_ecs/src/lib.rs +++ b/crates/bevy_ecs/src/lib.rs @@ -1572,9 +1572,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Attempted to access or drop non-send resource bevy_ecs::tests::NonSendA from thread" - )] + #[should_panic] fn non_send_resource_drop_from_different_thread() { let mut world = World::default(); world.insert_non_send_resource(NonSendA::default()); @@ -2589,7 +2587,7 @@ mod tests { } #[test] - #[should_panic = "Recursive required components detected: A → B → C → B\nhelp: If this is intentional, consider merging the components."] + #[should_panic] fn required_components_recursion_errors() { #[derive(Component, Default)] #[require(B)] @@ -2607,7 +2605,7 @@ mod tests { } #[test] - #[should_panic = "Recursive required components detected: A → A\nhelp: Remove require(A)."] + #[should_panic] fn required_components_self_errors() { #[derive(Component, Default)] #[require(A)] diff --git a/crates/bevy_ecs/src/observer/runner.rs b/crates/bevy_ecs/src/observer/runner.rs index d6bffd8f22..d055164cc2 100644 --- a/crates/bevy_ecs/src/observer/runner.rs +++ b/crates/bevy_ecs/src/observer/runner.rs @@ -516,9 +516,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Exclusive system `bevy_ecs::observer::runner::tests::exclusive_system_cannot_be_observer::system` may not be used as observer.\nInstead of `&mut World`, use either `DeferredWorld` if you do not need structural changes, or `Commands` if you do." - )] + #[should_panic] fn exclusive_system_cannot_be_observer() { fn system(_: On, _world: &mut World) {} let mut world = World::default(); diff --git a/crates/bevy_ecs/src/query/mod.rs b/crates/bevy_ecs/src/query/mod.rs index 7c1487fde4..0bd3bbed23 100644 --- a/crates/bevy_ecs/src/query/mod.rs +++ b/crates/bevy_ecs/src/query/mod.rs @@ -507,7 +507,7 @@ mod tests { } #[test] - #[should_panic = "&mut bevy_ecs::query::tests::A conflicts with a previous access in this query."] + #[should_panic] fn self_conflicting_worldquery() { #[derive(QueryData)] #[query_data(mutable)] diff --git a/crates/bevy_ecs/src/query/state.rs b/crates/bevy_ecs/src/query/state.rs index 63ae1134a5..00d8b6f970 100644 --- a/crates/bevy_ecs/src/query/state.rs +++ b/crates/bevy_ecs/src/query/state.rs @@ -1901,9 +1901,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for ((&bevy_ecs::query::state::tests::A, &bevy_ecs::query::state::tests::B), ()) attempts to access terms that are not allowed by original state (&bevy_ecs::query::state::tests::A, ())." - )] + #[should_panic] fn cannot_transmute_to_include_data_not_in_original_query() { let mut world = World::new(); world.register_component::(); @@ -1915,9 +1913,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for (&mut bevy_ecs::query::state::tests::A, ()) attempts to access terms that are not allowed by original state (&bevy_ecs::query::state::tests::A, ())." - )] + #[should_panic] fn cannot_transmute_immut_to_mut() { let mut world = World::new(); world.spawn(A(0)); @@ -1927,9 +1923,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for (&bevy_ecs::query::state::tests::A, ()) attempts to access terms that are not allowed by original state (core::option::Option<&bevy_ecs::query::state::tests::A>, ())." - )] + #[should_panic] fn cannot_transmute_option_to_immut() { let mut world = World::new(); world.spawn(C(0)); @@ -1941,9 +1935,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for (&bevy_ecs::query::state::tests::A, ()) attempts to access terms that are not allowed by original state (bevy_ecs::world::entity_ref::EntityRef, ())." - )] + #[should_panic] fn cannot_transmute_entity_ref() { let mut world = World::new(); world.register_component::(); @@ -2009,9 +2001,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for (bevy_ecs::entity::Entity, bevy_ecs::query::filter::Changed) attempts to access terms that are not allowed by original state (&bevy_ecs::query::state::tests::A, ())." - )] + #[should_panic] fn cannot_transmute_changed_without_access() { let mut world = World::new(); world.register_component::(); @@ -2021,9 +2011,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Transmuted state for (&mut bevy_ecs::query::state::tests::A, ()) attempts to access terms that are not allowed by original state (&bevy_ecs::query::state::tests::A, ())." - )] + #[should_panic] fn cannot_transmute_mutable_after_readonly() { let mut world = World::new(); // Calling this method would mean we had aliasing queries. @@ -2130,9 +2118,7 @@ mod tests { } #[test] - #[should_panic(expected = "Joined state for (&bevy_ecs::query::state::tests::C, ()) \ - attempts to access terms that are not allowed by state \ - (&bevy_ecs::query::state::tests::A, ()) joined with (&bevy_ecs::query::state::tests::B, ()).")] + #[should_panic] fn cannot_join_wrong_fetch() { let mut world = World::new(); world.register_component::(); @@ -2142,12 +2128,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Joined state for (bevy_ecs::entity::Entity, bevy_ecs::query::filter::Changed) \ - attempts to access terms that are not allowed by state \ - (&bevy_ecs::query::state::tests::A, bevy_ecs::query::filter::Without) \ - joined with (&bevy_ecs::query::state::tests::B, bevy_ecs::query::filter::Without)." - )] + #[should_panic] fn cannot_join_wrong_filter() { let mut world = World::new(); let query_1 = QueryState::<&A, Without>::new(&mut world); @@ -2156,9 +2137,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Joined state for ((&mut bevy_ecs::query::state::tests::A, &mut bevy_ecs::query::state::tests::B), ()) attempts to access terms that are not allowed by state (&bevy_ecs::query::state::tests::A, ()) joined with (&mut bevy_ecs::query::state::tests::B, ())." - )] + #[should_panic] fn cannot_join_mutable_after_readonly() { let mut world = World::new(); // Calling this method would mean we had aliasing queries. diff --git a/crates/bevy_ecs/src/schedule/mod.rs b/crates/bevy_ecs/src/schedule/mod.rs index 91f1b41312..12f58a7cd3 100644 --- a/crates/bevy_ecs/src/schedule/mod.rs +++ b/crates/bevy_ecs/src/schedule/mod.rs @@ -26,7 +26,9 @@ pub mod passes { #[cfg(test)] mod tests { use super::*; - use alloc::{string::ToString, vec, vec::Vec}; + #[cfg(feature = "trace")] + use alloc::string::ToString; + use alloc::{vec, vec::Vec}; use core::sync::atomic::{AtomicU32, Ordering}; use crate::error::BevyError; @@ -770,6 +772,7 @@ mod tests { } mod system_ambiguity { + #[cfg(feature = "trace")] use alloc::collections::BTreeSet; use super::*; @@ -1110,6 +1113,7 @@ mod tests { // Tests that the correct ambiguities were reported in the correct order. #[test] + #[cfg(feature = "trace")] fn correct_ambiguities() { fn system_a(_res: ResMut) {} fn system_b(_res: ResMut) {} @@ -1183,6 +1187,7 @@ mod tests { // Test that anonymous set names work properly // Related issue https://github.com/bevyengine/bevy/issues/9641 #[test] + #[cfg(feature = "trace")] fn anonymous_set_name() { let mut schedule = Schedule::new(TestSchedule); schedule.add_systems((resmut_system, resmut_system).run_if(|| true)); diff --git a/crates/bevy_ecs/src/system/mod.rs b/crates/bevy_ecs/src/system/mod.rs index db4cd452c0..3e63dcf3d6 100644 --- a/crates/bevy_ecs/src/system/mod.rs +++ b/crates/bevy_ecs/src/system/mod.rs @@ -634,7 +634,7 @@ mod tests { } #[test] - #[should_panic = "&bevy_ecs::system::tests::A conflicts with a previous access in this query."] + #[should_panic] fn any_of_with_mut_and_ref() { fn sys(_: Query>) {} let mut world = World::default(); @@ -642,7 +642,7 @@ mod tests { } #[test] - #[should_panic = "&mut bevy_ecs::system::tests::A conflicts with a previous access in this query."] + #[should_panic] fn any_of_with_ref_and_mut() { fn sys(_: Query>) {} let mut world = World::default(); @@ -650,7 +650,7 @@ mod tests { } #[test] - #[should_panic = "&bevy_ecs::system::tests::A conflicts with a previous access in this query."] + #[should_panic] fn any_of_with_mut_and_option() { fn sys(_: Query)>>) {} let mut world = World::default(); @@ -680,7 +680,7 @@ mod tests { } #[test] - #[should_panic = "&mut bevy_ecs::system::tests::A conflicts with a previous access in this query."] + #[should_panic] fn any_of_with_conflicting() { fn sys(_: Query>) {} let mut world = World::default(); @@ -1629,54 +1629,42 @@ mod tests { } #[test] - #[should_panic( - expected = "error[B0001]: Query in system bevy_ecs::system::tests::assert_world_and_entity_mut_system_does_conflict_first::system accesses component(s) in a way that conflicts with a previous system parameter. Consider using `Without` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001" - )] + #[should_panic] fn assert_world_and_entity_mut_system_does_conflict_first() { fn system(_query: &World, _q2: Query) {} super::assert_system_does_not_conflict(system); } #[test] - #[should_panic( - expected = "&World conflicts with a previous mutable system parameter. Allowing this would break Rust's mutability rules" - )] + #[should_panic] fn assert_world_and_entity_mut_system_does_conflict_second() { fn system(_: Query, _: &World) {} super::assert_system_does_not_conflict(system); } #[test] - #[should_panic( - expected = "error[B0001]: Query in system bevy_ecs::system::tests::assert_entity_ref_and_entity_mut_system_does_conflict::system accesses component(s) in a way that conflicts with a previous system parameter. Consider using `Without` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001" - )] + #[should_panic] fn assert_entity_ref_and_entity_mut_system_does_conflict() { fn system(_query: Query, _q2: Query) {} super::assert_system_does_not_conflict(system); } #[test] - #[should_panic( - expected = "error[B0001]: Query in system bevy_ecs::system::tests::assert_entity_mut_system_does_conflict::system accesses component(s) in a way that conflicts with a previous system parameter. Consider using `Without` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001" - )] + #[should_panic] fn assert_entity_mut_system_does_conflict() { fn system(_query: Query, _q2: Query) {} super::assert_system_does_not_conflict(system); } #[test] - #[should_panic( - expected = "error[B0001]: Query in system bevy_ecs::system::tests::assert_deferred_world_and_entity_ref_system_does_conflict_first::system accesses component(s) in a way that conflicts with a previous system parameter. Consider using `Without` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001" - )] + #[should_panic] fn assert_deferred_world_and_entity_ref_system_does_conflict_first() { fn system(_world: DeferredWorld, _query: Query) {} super::assert_system_does_not_conflict(system); } #[test] - #[should_panic( - expected = "DeferredWorld in system bevy_ecs::system::tests::assert_deferred_world_and_entity_ref_system_does_conflict_second::system conflicts with a previous access." - )] + #[should_panic] fn assert_deferred_world_and_entity_ref_system_does_conflict_second() { fn system(_query: Query, _world: DeferredWorld) {} super::assert_system_does_not_conflict(system); diff --git a/crates/bevy_ecs/src/system/system.rs b/crates/bevy_ecs/src/system/system.rs index 8527f3d3b8..d4521e76f5 100644 --- a/crates/bevy_ecs/src/system/system.rs +++ b/crates/bevy_ecs/src/system/system.rs @@ -410,7 +410,6 @@ pub enum RunSystemError { mod tests { use super::*; use crate::prelude::*; - use alloc::string::ToString; #[test] fn run_system_once() { @@ -483,7 +482,5 @@ mod tests { let result = world.run_system_once(system); assert!(matches!(result, Err(RunSystemError::InvalidParams { .. }))); - let expected = "System bevy_ecs::system::system::tests::run_system_once_invalid_params::system did not run due to failed parameter validation: Parameter `Res` failed validation: Resource does not exist\nIf this is an expected state, wrap the parameter in `Option` and handle `None` when it happens, or wrap the parameter in `When` to skip the system when it happens."; - assert_eq!(expected, result.unwrap_err().to_string()); } } diff --git a/crates/bevy_ecs/src/system/system_name.rs b/crates/bevy_ecs/src/system/system_name.rs index f38a5eb1aa..e0c3c952cf 100644 --- a/crates/bevy_ecs/src/system/system_name.rs +++ b/crates/bevy_ecs/src/system/system_name.rs @@ -85,6 +85,7 @@ impl ExclusiveSystemParam for SystemName { } #[cfg(test)] +#[cfg(feature = "trace")] mod tests { use crate::{ system::{IntoSystem, RunSystemOnce, SystemName}, diff --git a/crates/bevy_ecs/src/system/system_param.rs b/crates/bevy_ecs/src/system/system_param.rs index 65fecaf564..8faa6f1b0c 100644 --- a/crates/bevy_ecs/src/system/system_param.rs +++ b/crates/bevy_ecs/src/system/system_param.rs @@ -151,6 +151,7 @@ use variadics_please::{all_tuples, all_tuples_enumerated}; /// let mut world = World::new(); /// let err = world.run_system_cached(|param: MyParam| {}).unwrap_err(); /// let expected = "Parameter `MyParam::foo` failed validation: Custom Message"; +/// # #[cfg(feature="Trace")] // Without debug_utils/debug enabled MyParam::foo is stripped and breaks the assert /// assert!(err.to_string().contains(expected)); /// ``` /// @@ -3076,7 +3077,7 @@ mod tests { } #[test] - #[should_panic = "Encountered an error in system `bevy_ecs::system::system_param::tests::missing_resource_error::res_system`: Parameter `Res` failed validation: Resource does not exist"] + #[should_panic] fn missing_resource_error() { #[derive(Resource)] pub struct MissingResource; @@ -3090,7 +3091,7 @@ mod tests { } #[test] - #[should_panic = "Encountered an error in system `bevy_ecs::system::system_param::tests::missing_event_error::event_system`: Parameter `EventReader::events` failed validation: BufferedEvent not initialized"] + #[should_panic] fn missing_event_error() { use crate::prelude::{BufferedEvent, EventReader}; diff --git a/crates/bevy_ecs/src/system/system_registry.rs b/crates/bevy_ecs/src/system/system_registry.rs index 6889a8f4cd..e9c9cdba13 100644 --- a/crates/bevy_ecs/src/system/system_registry.rs +++ b/crates/bevy_ecs/src/system/system_registry.rs @@ -913,7 +913,6 @@ mod tests { #[test] fn run_system_invalid_params() { use crate::system::RegisteredSystemError; - use alloc::{format, string::ToString}; struct T; impl Resource for T {} @@ -928,8 +927,6 @@ mod tests { result, Err(RegisteredSystemError::InvalidParams { .. }) )); - let expected = format!("System {id:?} did not run due to failed parameter validation: Parameter `Res` failed validation: Resource does not exist\nIf this is an expected state, wrap the parameter in `Option` and handle `None` when it happens, or wrap the parameter in `When` to skip the system when it happens."); - assert_eq!(expected, result.unwrap_err().to_string()); } #[test] From 45a3f3d138cdd599bf007490f8f8146920d9c910 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 21 Jun 2025 16:06:35 +0100 Subject: [PATCH 02/12] Color interpolation in OKLab, OKLCH spaces for UI gradients (#19330) # Objective Add support for interpolation in OKLab and OKLCH color spaces for UI gradients. ## Solution * New `InterpolationColorSpace` enum with `OkLab`, `OkLch`, `OkLchLong`, `Srgb` and `LinearRgb` variants. * Added a color space specialization to the gradients pipeline. * Added support for interpolation in OkLCH and OkLAB color spaces to the gradients shader. OKLCH interpolation supports both short and long hue paths. This is mostly based on the conversion functions from `bevy_color` except that interpolation in polar space uses radians. * Added `color_space` fields to each gradient type. ## Testing The `gradients` example has been updated to demonstrate the different color interpolation methods. Press space to cycle through the different options. --- ## Showcase ![color_spaces](https://github.com/user-attachments/assets/e10f8342-c3c8-487e-b386-7acdf38d638f) --- crates/bevy_ui/src/gradients.rs | 120 +++++++++++++++++- crates/bevy_ui/src/render/gradient.rs | 26 +++- crates/bevy_ui/src/render/gradient.wgsl | 96 +++++++++++++- examples/testbed/ui.rs | 1 + examples/ui/button.rs | 4 +- examples/ui/gradients.rs | 100 +++++++++++++++ examples/ui/stacked_gradients.rs | 4 + release-content/release-notes/ui_gradients.md | 6 +- 8 files changed, 344 insertions(+), 13 deletions(-) diff --git a/crates/bevy_ui/src/gradients.rs b/crates/bevy_ui/src/gradients.rs index 969e062cd7..eb1d255cc7 100644 --- a/crates/bevy_ui/src/gradients.rs +++ b/crates/bevy_ui/src/gradients.rs @@ -3,6 +3,7 @@ use bevy_color::{Color, Srgba}; use bevy_ecs::component::Component; use bevy_math::Vec2; use bevy_reflect::prelude::*; +use bevy_utils::default; use core::{f32, f32::consts::TAU}; /// A color stop for a gradient @@ -205,7 +206,7 @@ impl Default for AngularColorStop { /// A linear gradient /// /// -#[derive(Clone, PartialEq, Debug, Reflect)] +#[derive(Default, Clone, PartialEq, Debug, Reflect)] #[reflect(PartialEq)] #[cfg_attr( feature = "serialize", @@ -213,6 +214,8 @@ impl Default for AngularColorStop { reflect(Serialize, Deserialize) )] pub struct LinearGradient { + /// The color space used for interpolation. + pub color_space: InterpolationColorSpace, /// The direction of the gradient in radians. /// An angle of `0.` points upward, with the value increasing in the clockwise direction. pub angle: f32, @@ -240,7 +243,11 @@ impl LinearGradient { /// Create a new linear gradient pub fn new(angle: f32, stops: Vec) -> Self { - Self { angle, stops } + Self { + angle, + stops, + color_space: InterpolationColorSpace::default(), + } } /// A linear gradient transitioning from bottom to top @@ -248,6 +255,7 @@ impl LinearGradient { Self { angle: Self::TO_TOP, stops, + color_space: InterpolationColorSpace::default(), } } @@ -256,6 +264,7 @@ impl LinearGradient { Self { angle: Self::TO_TOP_RIGHT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -264,6 +273,7 @@ impl LinearGradient { Self { angle: Self::TO_RIGHT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -272,6 +282,7 @@ impl LinearGradient { Self { angle: Self::TO_BOTTOM_RIGHT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -280,6 +291,7 @@ impl LinearGradient { Self { angle: Self::TO_BOTTOM, stops, + color_space: InterpolationColorSpace::default(), } } @@ -288,6 +300,7 @@ impl LinearGradient { Self { angle: Self::TO_BOTTOM_LEFT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -296,6 +309,7 @@ impl LinearGradient { Self { angle: Self::TO_LEFT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -304,6 +318,7 @@ impl LinearGradient { Self { angle: Self::TO_TOP_LEFT, stops, + color_space: InterpolationColorSpace::default(), } } @@ -312,8 +327,14 @@ impl LinearGradient { Self { angle: degrees.to_radians(), stops, + color_space: InterpolationColorSpace::default(), } } + + pub fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } } /// A radial gradient @@ -327,6 +348,8 @@ impl LinearGradient { reflect(Serialize, Deserialize) )] pub struct RadialGradient { + /// The color space used for interpolation. + pub color_space: InterpolationColorSpace, /// The center of the radial gradient pub position: UiPosition, /// Defines the end shape of the radial gradient @@ -339,11 +362,17 @@ impl RadialGradient { /// Create a new radial gradient pub fn new(position: UiPosition, shape: RadialGradientShape, stops: Vec) -> Self { Self { + color_space: default(), position, shape, stops, } } + + pub fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } } impl Default for RadialGradient { @@ -352,6 +381,7 @@ impl Default for RadialGradient { position: UiPosition::CENTER, shape: RadialGradientShape::ClosestCorner, stops: Vec::new(), + color_space: default(), } } } @@ -359,7 +389,7 @@ impl Default for RadialGradient { /// A conic gradient /// /// -#[derive(Clone, PartialEq, Debug, Reflect)] +#[derive(Default, Clone, PartialEq, Debug, Reflect)] #[reflect(PartialEq)] #[cfg_attr( feature = "serialize", @@ -367,6 +397,8 @@ impl Default for RadialGradient { reflect(Serialize, Deserialize) )] pub struct ConicGradient { + /// The color space used for interpolation. + pub color_space: InterpolationColorSpace, /// The starting angle of the gradient in radians pub start: f32, /// The center of the conic gradient @@ -379,6 +411,7 @@ impl ConicGradient { /// Create a new conic gradient pub fn new(position: UiPosition, stops: Vec) -> Self { Self { + color_space: default(), start: 0., position, stops, @@ -396,6 +429,11 @@ impl ConicGradient { self.position = position; self } + + pub fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } } #[derive(Clone, PartialEq, Debug, Reflect)] @@ -573,3 +611,79 @@ impl RadialGradientShape { } } } + +/// The color space used for interpolation. +#[derive(Default, Copy, Clone, Hash, Debug, PartialEq, Eq, Reflect)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum InterpolationColorSpace { + /// Interpolates in `OKLab` space. + #[default] + OkLab, + /// Interpolates in OKLCH space, taking the shortest hue path. + OkLch, + /// Interpolates in OKLCH space, taking the longest hue path. + OkLchLong, + /// Interpolates in sRGB space. + Srgb, + /// Interpolates in linear sRGB space. + LinearRgb, +} + +/// Set the color space used for interpolation. +pub trait InColorSpace: Sized { + /// Interpolate in the given `color_space`. + fn in_color_space(self, color_space: InterpolationColorSpace) -> Self; + + /// Interpolate in `OKLab` space. + fn in_oklab(self) -> Self { + self.in_color_space(InterpolationColorSpace::OkLab) + } + + /// Interpolate in OKLCH space (short hue path). + fn in_oklch(self) -> Self { + self.in_color_space(InterpolationColorSpace::OkLch) + } + + /// Interpolate in OKLCH space (long hue path). + fn in_oklch_long(self) -> Self { + self.in_color_space(InterpolationColorSpace::OkLchLong) + } + + /// Interpolate in sRGB space. + fn in_srgb(self) -> Self { + self.in_color_space(InterpolationColorSpace::Srgb) + } + + /// Interpolate in linear sRGB space. + fn in_linear_rgb(self) -> Self { + self.in_color_space(InterpolationColorSpace::LinearRgb) + } +} + +impl InColorSpace for LinearGradient { + /// Interpolate in the given `color_space`. + fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } +} + +impl InColorSpace for RadialGradient { + /// Interpolate in the given `color_space`. + fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } +} + +impl InColorSpace for ConicGradient { + /// Interpolate in the given `color_space`. + fn in_color_space(mut self, color_space: InterpolationColorSpace) -> Self { + self.color_space = color_space; + self + } +} diff --git a/crates/bevy_ui/src/render/gradient.rs b/crates/bevy_ui/src/render/gradient.rs index bd818c7d5b..e1c845d481 100644 --- a/crates/bevy_ui/src/render/gradient.rs +++ b/crates/bevy_ui/src/render/gradient.rs @@ -140,6 +140,7 @@ pub fn compute_gradient_line_length(angle: f32, size: Vec2) -> f32 { #[derive(Clone, Copy, Hash, PartialEq, Eq)] pub struct UiGradientPipelineKey { anti_alias: bool, + color_space: InterpolationColorSpace, pub hdr: bool, } @@ -180,10 +181,18 @@ impl SpecializedRenderPipeline for GradientPipeline { VertexFormat::Float32, ], ); + let color_space = match key.color_space { + InterpolationColorSpace::OkLab => "IN_OKLAB", + InterpolationColorSpace::OkLch => "IN_OKLCH", + InterpolationColorSpace::OkLchLong => "IN_OKLCH_LONG", + InterpolationColorSpace::Srgb => "IN_SRGB", + InterpolationColorSpace::LinearRgb => "IN_LINEAR_RGB", + }; + let shader_defs = if key.anti_alias { - vec!["ANTI_ALIAS".into()] + vec![color_space.into(), "ANTI_ALIAS".into()] } else { - Vec::new() + vec![color_space.into()] }; RenderPipelineDescriptor { @@ -254,6 +263,7 @@ pub struct ExtractedGradient { /// Ordering: left, top, right, bottom. pub border: BorderRect, pub resolved_gradient: ResolvedGradient, + pub color_space: InterpolationColorSpace, } #[derive(Resource, Default)] @@ -422,7 +432,11 @@ pub fn extract_gradients( continue; } match gradient { - Gradient::Linear(LinearGradient { angle, stops }) => { + Gradient::Linear(LinearGradient { + color_space, + angle, + stops, + }) => { let length = compute_gradient_line_length(*angle, uinode.size); let range_start = extracted_color_stops.0.len(); @@ -452,9 +466,11 @@ pub fn extract_gradients( border_radius: uinode.border_radius, border: uinode.border, resolved_gradient: ResolvedGradient::Linear { angle: *angle }, + color_space: *color_space, }); } Gradient::Radial(RadialGradient { + color_space, position: center, shape, stops, @@ -500,9 +516,11 @@ pub fn extract_gradients( border_radius: uinode.border_radius, border: uinode.border, resolved_gradient: ResolvedGradient::Radial { center: c, size }, + color_space: *color_space, }); } Gradient::Conic(ConicGradient { + color_space, start, position: center, stops, @@ -557,6 +575,7 @@ pub fn extract_gradients( start: *start, center: g_start, }, + color_space: *color_space, }); } } @@ -601,6 +620,7 @@ pub fn queue_gradient( &gradients_pipeline, UiGradientPipelineKey { anti_alias: matches!(ui_anti_alias, None | Some(UiAntiAlias::On)), + color_space: gradient.color_space, hdr: view.hdr, }, ); diff --git a/crates/bevy_ui/src/render/gradient.wgsl b/crates/bevy_ui/src/render/gradient.wgsl index 0223836f2d..074cf35a35 100644 --- a/crates/bevy_ui/src/render/gradient.wgsl +++ b/crates/bevy_ui/src/render/gradient.wgsl @@ -122,6 +122,89 @@ fn mix_linear_rgb_in_srgb_space(a: vec4, b: vec4, t: f32) -> vec4 return vec4(pow(mixed_srgb, vec3(2.2)), mix(a.a, b.a, t)); } +fn linear_rgb_to_oklab(c: vec4) -> vec4 { + let l = pow(0.41222146 * c.x + 0.53633255 * c.y + 0.051445995 * c.z, 1. / 3.); + let m = pow(0.2119035 * c.x + 0.6806995 * c.y + 0.10739696 * c.z, 1. / 3.); + let s = pow(0.08830246 * c.x + 0.28171885 * c.y + 0.6299787 * c.z, 1. / 3.); + return vec4( + 0.21045426 * l + 0.7936178 * m - 0.004072047 * s, + 1.9779985 * l - 2.4285922 * m + 0.4505937 * s, + 0.025904037 * l + 0.78277177 * m - 0.80867577 * s, + c.w + ); +} + +fn oklab_to_linear_rgba(c: vec4) -> vec4 { + let l_ = c.x + 0.39633778 * c.y + 0.21580376 * c.z; + let m_ = c.x - 0.105561346 * c.y - 0.06385417 * c.z; + let s_ = c.x - 0.08948418 * c.y - 1.2914855 * c.z; + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + return vec4( + 4.0767417 * l - 3.3077116 * m + 0.23096994 * s, + -1.268438 * l + 2.6097574 * m - 0.34131938 * s, + -0.0041960863 * l - 0.7034186 * m + 1.7076147 * s, + c.w + ); +} + +fn mix_linear_rgb_in_oklab_space(a: vec4, b: vec4, t: f32) -> vec4 { + return oklab_to_linear_rgba(mix(linear_rgb_to_oklab(a), linear_rgb_to_oklab(b), t)); +} + +/// hue is left in radians and not converted to degrees +fn linear_rgb_to_oklch(c: vec4) -> vec4 { + let o = linear_rgb_to_oklab(c); + let chroma = sqrt(o.y * o.y + o.z * o.z); + let hue = atan2(o.z, o.y); + return vec4(o.x, chroma, select(hue + TAU, hue, hue < 0.0), o.w); +} + +fn oklch_to_linear_rgb(c: vec4) -> vec4 { + let a = c.y * cos(c.z); + let b = c.y * sin(c.z); + return oklab_to_linear_rgba(vec4(c.x, a, b, c.w)); +} + +fn rem_euclid(a: f32, b: f32) -> f32 { + return ((a % b) + b) % b; +} + +fn lerp_hue(a: f32, b: f32, t: f32) -> f32 { + let diff = rem_euclid(b - a + PI, TAU) - PI; + return rem_euclid(a + diff * t, TAU); +} + +fn lerp_hue_long(a: f32, b: f32, t: f32) -> f32 { + let diff = rem_euclid(b - a + PI, TAU) - PI; + return rem_euclid(a + select(diff - TAU, diff + TAU, 0. < diff) * t, TAU); +} + +fn mix_oklch(a: vec4, b: vec4, t: f32) -> vec4 { + return vec4( + mix(a.xy, b.xy, t), + lerp_hue(a.z, b.z, t), + mix(a.w, b.w, t) + ); +} + +fn mix_oklch_long(a: vec4, b: vec4, t: f32) -> vec4 { + return vec4( + mix(a.xy, b.xy, t), + lerp_hue_long(a.z, b.z, t), + mix(a.w, b.w, t) + ); +} + +fn mix_linear_rgb_in_oklch_space(a: vec4, b: vec4, t: f32) -> vec4 { + return oklch_to_linear_rgb(mix_oklch(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t)); +} + +fn mix_linear_rgb_in_oklch_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + return oklch_to_linear_rgb(mix_oklch_long(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), 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. @@ -192,7 +275,16 @@ fn interpolate_gradient( } else { t = 0.5 * (1 + (t - hint) / (1.0 - hint)); } - - // Only color interpolation in SRGB space is supported atm. + +#ifdef IN_SRGB return mix_linear_rgb_in_srgb_space(start_color, end_color, t); +#else ifdef IN_OKLAB + return mix_linear_rgb_in_oklab_space(start_color, end_color, t); +#else ifdef IN_OKLCH + return mix_linear_rgb_in_oklch_space(start_color, end_color, t); +#else ifdef IN_OKLCH_LONG + return mix_linear_rgb_in_oklch_space_long(start_color, end_color, t); +#else + return mix(start_color, end_color, t); +#endif } diff --git a/examples/testbed/ui.rs b/examples/testbed/ui.rs index 6538840575..10e4e8dc8f 100644 --- a/examples/testbed/ui.rs +++ b/examples/testbed/ui.rs @@ -618,6 +618,7 @@ mod radial_gradient { stops: color_stops.clone(), position, shape, + ..default() }), )); }); diff --git a/examples/ui/button.rs b/examples/ui/button.rs index e533a84867..a402b5e7da 100644 --- a/examples/ui/button.rs +++ b/examples/ui/button.rs @@ -93,9 +93,9 @@ fn button(asset_server: &AssetServer) -> impl Bundle + use<> { align_items: AlignItems::Center, ..default() }, - BorderColor::all(Color::BLACK), + BorderColor::all(Color::WHITE), BorderRadius::MAX, - BackgroundColor(NORMAL_BUTTON), + BackgroundColor(Color::BLACK), children![( Text::new("Button"), TextFont { diff --git a/examples/ui/gradients.rs b/examples/ui/gradients.rs index a354903708..0adc930a34 100644 --- a/examples/ui/gradients.rs +++ b/examples/ui/gradients.rs @@ -12,6 +12,9 @@ use bevy::prelude::*; use bevy::ui::ColorStop; use std::f32::consts::TAU; +#[derive(Component)] +struct CurrentColorSpaceLabel; + fn main() { App::new() .add_plugins(DefaultPlugins) @@ -87,6 +90,7 @@ fn setup(mut commands: Commands) { BackgroundGradient::from(LinearGradient { angle, stops: stops.clone(), + ..default() }), BorderGradient::from(LinearGradient { angle: 3. * TAU / 8., @@ -95,6 +99,7 @@ fn setup(mut commands: Commands) { Color::WHITE.into(), ORANGE.into(), ], + ..default() }), )); } @@ -115,10 +120,12 @@ fn setup(mut commands: Commands) { BackgroundGradient::from(LinearGradient { angle: 0., stops: stops.clone(), + ..default() }), BorderGradient::from(LinearGradient { angle: 3. * TAU / 8., stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + ..default() }), AnimateMarker, )); @@ -136,10 +143,12 @@ fn setup(mut commands: Commands) { stops: stops.clone(), shape: RadialGradientShape::ClosestSide, position: UiPosition::CENTER, + ..default() }), BorderGradient::from(LinearGradient { angle: 3. * TAU / 8., stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + ..default() }), AnimateMarker, )); @@ -159,16 +168,107 @@ fn setup(mut commands: Commands) { .map(|stop| AngularColorStop::auto(stop.color)) .collect(), position: UiPosition::CENTER, + ..default() }), BorderGradient::from(LinearGradient { angle: 3. * TAU / 8., stops: vec![YELLOW.into(), Color::WHITE.into(), ORANGE.into()], + ..default() }), AnimateMarker, )); }); }); } + + let button = commands.spawn(( + Button, + Node { + border: UiRect::all(Val::Px(2.0)), + padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)), + // horizontally center child text + justify_content: JustifyContent::Center, + // vertically center child text + align_items: AlignItems::Center, + ..default() + }, + BorderColor::all(Color::WHITE), + BorderRadius::MAX, + BackgroundColor(Color::BLACK), + children![( + Text::new("next color space"), + TextColor(Color::srgb(0.9, 0.9, 0.9)), + TextShadow::default(), + )] + )).observe( + |_trigger: On>, mut border_query: Query<&mut BorderColor, With