From e7e9973c80c259ccf27a02090097887d9060d8fc Mon Sep 17 00:00:00 2001 From: SpecificProtagonist Date: Mon, 19 May 2025 03:35:07 +0200 Subject: [PATCH 001/421] Per world error handler (#18810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective [see original comment](https://github.com/bevyengine/bevy/pull/18801#issuecomment-2796981745) > Alternately, could we store it on the World instead of a global? I think we have a World nearby whenever we call default_error_handler(). That would avoid the need for atomics or locks, since we could do ordinary reads and writes to the World. Global error handlers don't actually need to be global – per world is enough. This allows using different handlers for different worlds and also removes the restrictions on changing the handler only once. ## Solution Each `World` can now store its own error handler in a resource. For convenience, you can also set the default error handler for an `App`, which applies it to the worlds of all `SubApp`s. The old behavior of only being able to set the error handler once is kept for apps. We also don't need the `configurable_error_handler` feature anymore now. ## Testing New/adjusted tests for failing schedule systems & observers. --- ## Showcase ```rust App::new() .set_error_handler(info) … ``` --- Cargo.toml | 6 +- crates/bevy_app/src/app.rs | 53 +++++++++++++- crates/bevy_ecs/Cargo.toml | 7 -- crates/bevy_ecs/src/error/command_handling.rs | 41 +++++++---- crates/bevy_ecs/src/error/handler.rs | 71 ++++++------------- crates/bevy_ecs/src/error/mod.rs | 16 ++--- crates/bevy_ecs/src/observer/runner.rs | 43 +++++++---- .../src/schedule/executor/multi_threaded.rs | 24 ++++--- .../bevy_ecs/src/schedule/executor/simple.rs | 26 ++++--- .../src/schedule/executor/single_threaded.rs | 26 ++++--- crates/bevy_ecs/src/schedule/schedule.rs | 32 ++++++++- crates/bevy_ecs/src/system/commands/mod.rs | 16 ++--- crates/bevy_ecs/src/world/mod.rs | 11 +++ .../bevy_ecs/src/world/unsafe_world_cell.rs | 13 ++++ crates/bevy_internal/Cargo.toml | 3 - docs/cargo_features.md | 1 - examples/ecs/error_handling.rs | 22 ++---- examples/ecs/fallible_params.rs | 16 ++--- .../per-world-error-handler.md | 10 +++ 19 files changed, 272 insertions(+), 165 deletions(-) create mode 100644 release-content/migration-guides/per-world-error-handler.md diff --git a/Cargo.toml b/Cargo.toml index a3d3a2ab63..7cf1334e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -291,9 +291,6 @@ bevy_log = ["bevy_internal/bevy_log"] # Enable input focus subsystem bevy_input_focus = ["bevy_internal/bevy_input_focus"] -# Use the configurable global error handler as the default error handler. -configurable_error_handler = ["bevy_internal/configurable_error_handler"] - # Enable passthrough loading for SPIR-V shaders (Only supported on Vulkan, shader capabilities and extensions must agree with the platform implementation) spirv_shader_passthrough = ["bevy_internal/spirv_shader_passthrough"] @@ -2215,7 +2212,6 @@ wasm = false name = "fallible_params" path = "examples/ecs/fallible_params.rs" doc-scrape-examples = true -required-features = ["configurable_error_handler"] [package.metadata.example.fallible_params] name = "Fallible System Parameters" @@ -2227,7 +2223,7 @@ wasm = false name = "error_handling" path = "examples/ecs/error_handling.rs" doc-scrape-examples = true -required-features = ["bevy_mesh_picking_backend", "configurable_error_handler"] +required-features = ["bevy_mesh_picking_backend"] [package.metadata.example.error_handling] name = "Error handling" diff --git a/crates/bevy_app/src/app.rs b/crates/bevy_app/src/app.rs index 60431ca479..81d7baeb4b 100644 --- a/crates/bevy_app/src/app.rs +++ b/crates/bevy_app/src/app.rs @@ -10,6 +10,7 @@ use alloc::{ pub use bevy_derive::AppLabel; use bevy_ecs::{ component::RequiredComponentsError, + error::{DefaultErrorHandler, ErrorHandler}, event::{event_update_system, EventCursor}, intern::Interned, prelude::*, @@ -85,6 +86,7 @@ pub struct App { /// [`WinitPlugin`]: https://docs.rs/bevy/latest/bevy/winit/struct.WinitPlugin.html /// [`ScheduleRunnerPlugin`]: https://docs.rs/bevy/latest/bevy/app/struct.ScheduleRunnerPlugin.html pub(crate) runner: RunnerFn, + default_error_handler: Option, } impl Debug for App { @@ -143,6 +145,7 @@ impl App { sub_apps: HashMap::default(), }, runner: Box::new(run_once), + default_error_handler: None, } } @@ -1115,7 +1118,12 @@ impl App { } /// Inserts a [`SubApp`] with the given label. - pub fn insert_sub_app(&mut self, label: impl AppLabel, sub_app: SubApp) { + pub fn insert_sub_app(&mut self, label: impl AppLabel, mut sub_app: SubApp) { + if let Some(handler) = self.default_error_handler { + sub_app + .world_mut() + .get_resource_or_insert_with(|| DefaultErrorHandler(handler)); + } self.sub_apps.sub_apps.insert(label.intern(), sub_app); } @@ -1334,6 +1342,49 @@ impl App { self.world_mut().add_observer(observer); self } + + /// Gets the error handler to set for new supapps. + /// + /// Note that the error handler of existing subapps may differ. + pub fn get_error_handler(&self) -> Option { + self.default_error_handler + } + + /// Set the [default error handler] for the all subapps (including the main one and future ones) + /// that do not have one. + /// + /// May only be called once and should be set by the application, not by libraries. + /// + /// The handler will be called when an error is produced and not otherwise handled. + /// + /// # Panics + /// Panics if called multiple times. + /// + /// # Example + /// ``` + /// # use bevy_app::*; + /// # use bevy_ecs::error::warn; + /// # fn MyPlugins(_: &mut App) {} + /// App::new() + /// .set_error_handler(warn) + /// .add_plugins(MyPlugins) + /// .run(); + /// ``` + /// + /// [default error handler]: bevy_ecs::error::DefaultErrorHandler + pub fn set_error_handler(&mut self, handler: ErrorHandler) -> &mut Self { + assert!( + self.default_error_handler.is_none(), + "`set_error_handler` called multiple times on same `App`" + ); + self.default_error_handler = Some(handler); + for sub_app in self.sub_apps.iter_mut() { + sub_app + .world_mut() + .get_resource_or_insert_with(|| DefaultErrorHandler(handler)); + } + self + } } type RunnerFn = Box AppExit>; diff --git a/crates/bevy_ecs/Cargo.toml b/crates/bevy_ecs/Cargo.toml index 97cdcee082..28987f1413 100644 --- a/crates/bevy_ecs/Cargo.toml +++ b/crates/bevy_ecs/Cargo.toml @@ -33,13 +33,6 @@ bevy_reflect = ["dep:bevy_reflect"] ## Extends reflection support to functions. reflect_functions = ["bevy_reflect", "bevy_reflect/functions"] -## Use the configurable global error handler as the default error handler. -## -## This is typically used to turn panics from the ECS into loggable errors. -## This may be useful for production builds, -## but can result in a measurable performance impact, especially for commands. -configurable_error_handler = [] - ## Enables automatic backtrace capturing in BevyError backtrace = ["std"] diff --git a/crates/bevy_ecs/src/error/command_handling.rs b/crates/bevy_ecs/src/error/command_handling.rs index d85ad4a87e..bf2741d376 100644 --- a/crates/bevy_ecs/src/error/command_handling.rs +++ b/crates/bevy_ecs/src/error/command_handling.rs @@ -7,22 +7,17 @@ use crate::{ world::{error::EntityMutableFetchError, World}, }; -use super::{default_error_handler, BevyError, ErrorContext}; +use super::{BevyError, ErrorContext, ErrorHandler}; -/// Takes a [`Command`] that returns a Result and uses a given error handler function to convert it into +/// Takes a [`Command`] that potentially returns a Result and uses a given error handler function to convert it into /// a [`Command`] that internally handles an error if it occurs and returns `()`. -pub trait HandleError { +pub trait HandleError: Send + 'static { /// Takes a [`Command`] that returns a Result and uses a given error handler function to convert it into /// a [`Command`] that internally handles an error if it occurs and returns `()`. - fn handle_error_with(self, error_handler: fn(BevyError, ErrorContext)) -> impl Command; + fn handle_error_with(self, error_handler: ErrorHandler) -> impl Command; /// Takes a [`Command`] that returns a Result and uses the default error handler function to convert it into /// a [`Command`] that internally handles an error if it occurs and returns `()`. - fn handle_error(self) -> impl Command - where - Self: Sized, - { - self.handle_error_with(default_error_handler()) - } + fn handle_error(self) -> impl Command; } impl HandleError> for C @@ -30,7 +25,7 @@ where C: Command>, E: Into, { - fn handle_error_with(self, error_handler: fn(BevyError, ErrorContext)) -> impl Command { + fn handle_error_with(self, error_handler: ErrorHandler) -> impl Command { move |world: &mut World| match self.apply(world) { Ok(_) => {} Err(err) => (error_handler)( @@ -41,6 +36,18 @@ where ), } } + + fn handle_error(self) -> impl Command { + move |world: &mut World| match self.apply(world) { + Ok(_) => {} + Err(err) => world.default_error_handler()( + err.into(), + ErrorContext::Command { + name: type_name::().into(), + }, + ), + } + } } impl HandleError for C @@ -52,6 +59,13 @@ where self.apply(world); } } + + #[inline] + fn handle_error(self) -> impl Command { + move |world: &mut World| { + self.apply(world); + } + } } impl HandleError for C @@ -63,10 +77,7 @@ where self } #[inline] - fn handle_error(self) -> impl Command - where - Self: Sized, - { + fn handle_error(self) -> impl Command { self } } diff --git a/crates/bevy_ecs/src/error/handler.rs b/crates/bevy_ecs/src/error/handler.rs index 688b599473..d53e1b3e05 100644 --- a/crates/bevy_ecs/src/error/handler.rs +++ b/crates/bevy_ecs/src/error/handler.rs @@ -1,9 +1,8 @@ -#[cfg(feature = "configurable_error_handler")] -use bevy_platform::sync::OnceLock; use core::fmt::Display; -use crate::{component::Tick, error::BevyError}; +use crate::{component::Tick, error::BevyError, prelude::Resource}; use alloc::borrow::Cow; +use derive_more::derive::{Deref, DerefMut}; /// Context for a [`BevyError`] to aid in debugging. #[derive(Debug, PartialEq, Eq, Clone)] @@ -77,53 +76,6 @@ impl ErrorContext { } } -/// A global error handler. This can be set at startup, as long as it is set before -/// any uses. This should generally be configured _before_ initializing the app. -/// -/// This should be set inside of your `main` function, before initializing the Bevy app. -/// The value of this error handler can be accessed using the [`default_error_handler`] function, -/// which calls [`OnceLock::get_or_init`] to get the value. -/// -/// **Note:** this is only available when the `configurable_error_handler` feature of `bevy_ecs` (or `bevy`) is enabled! -/// -/// # Example -/// -/// ``` -/// # use bevy_ecs::error::{GLOBAL_ERROR_HANDLER, warn}; -/// GLOBAL_ERROR_HANDLER.set(warn).expect("The error handler can only be set once, globally."); -/// // initialize Bevy App here -/// ``` -/// -/// To use this error handler in your app for custom error handling logic: -/// -/// ```rust -/// use bevy_ecs::error::{default_error_handler, GLOBAL_ERROR_HANDLER, BevyError, ErrorContext, panic}; -/// -/// fn handle_errors(error: BevyError, ctx: ErrorContext) { -/// let error_handler = default_error_handler(); -/// error_handler(error, ctx); -/// } -/// ``` -/// -/// # Warning -/// -/// As this can *never* be overwritten, library code should never set this value. -#[cfg(feature = "configurable_error_handler")] -pub static GLOBAL_ERROR_HANDLER: OnceLock = OnceLock::new(); - -/// The default error handler. This defaults to [`panic()`], -/// but if set, the [`GLOBAL_ERROR_HANDLER`] will be used instead, enabling error handler customization. -/// The `configurable_error_handler` feature must be enabled to change this from the panicking default behavior, -/// as there may be runtime overhead. -#[inline] -pub fn default_error_handler() -> fn(BevyError, ErrorContext) { - #[cfg(not(feature = "configurable_error_handler"))] - return panic; - - #[cfg(feature = "configurable_error_handler")] - return *GLOBAL_ERROR_HANDLER.get_or_init(|| panic); -} - macro_rules! inner { ($call:path, $e:ident, $c:ident) => { $call!( @@ -135,6 +87,25 @@ macro_rules! inner { }; } +/// Defines how Bevy reacts to errors. +pub type ErrorHandler = fn(BevyError, ErrorContext); + +/// Error handler to call when an error is not handled otherwise. +/// Defaults to [`panic()`]. +/// +/// When updated while a [`Schedule`] is running, it doesn't take effect for +/// that schedule until it's completed. +/// +/// [`Schedule`]: crate::schedule::Schedule +#[derive(Resource, Deref, DerefMut, Copy, Clone)] +pub struct DefaultErrorHandler(pub ErrorHandler); + +impl Default for DefaultErrorHandler { + fn default() -> Self { + Self(panic) + } +} + /// Error handler that panics with the system error. #[track_caller] #[inline] diff --git a/crates/bevy_ecs/src/error/mod.rs b/crates/bevy_ecs/src/error/mod.rs index 950deee3ec..231bdda940 100644 --- a/crates/bevy_ecs/src/error/mod.rs +++ b/crates/bevy_ecs/src/error/mod.rs @@ -7,8 +7,9 @@ //! All [`BevyError`]s returned by a system, observer or command are handled by an "error handler". By default, the //! [`panic`] error handler function is used, resulting in a panic with the error message attached. //! -//! You can change the default behavior by registering a custom error handler. -//! Modify the [`GLOBAL_ERROR_HANDLER`] value to set a custom error handler function for your entire app. +//! You can change the default behavior by registering a custom error handler: +//! Use [`DefaultErrorHandler`] to set a custom error handler function for a world, +//! or `App::set_error_handler` for a whole app. //! In practice, this is generally feature-flagged: panicking or loudly logging errors in development, //! and quietly logging or ignoring them in production to avoid crashing the app. //! @@ -33,10 +34,8 @@ //! The [`ErrorContext`] allows you to access additional details relevant to providing //! context surrounding the error – such as the system's [`name`] – in your error messages. //! -//! Remember to turn on the `configurable_error_handler` feature to set a global error handler! -//! //! ```rust, ignore -//! use bevy_ecs::error::{GLOBAL_ERROR_HANDLER, BevyError, ErrorContext}; +//! use bevy_ecs::error::{BevyError, ErrorContext, DefaultErrorHandler}; //! use log::trace; //! //! fn my_error_handler(error: BevyError, ctx: ErrorContext) { @@ -48,10 +47,9 @@ //! } //! //! fn main() { -//! // This requires the "configurable_error_handler" feature to be enabled to be in scope. -//! GLOBAL_ERROR_HANDLER.set(my_error_handler).expect("The error handler can only be set once."); -//! -//! // Initialize your Bevy App here +//! let mut world = World::new(); +//! world.insert_resource(DefaultErrorHandler(my_error_handler)); +//! // Use your world here //! } //! ``` //! diff --git a/crates/bevy_ecs/src/observer/runner.rs b/crates/bevy_ecs/src/observer/runner.rs index ac1caa5ad0..be7bc4ede2 100644 --- a/crates/bevy_ecs/src/observer/runner.rs +++ b/crates/bevy_ecs/src/observer/runner.rs @@ -3,7 +3,7 @@ use core::any::Any; use crate::{ component::{ComponentHook, ComponentId, HookContext, Mutable, StorageType}, - error::{default_error_handler, ErrorContext}, + error::{ErrorContext, ErrorHandler}, observer::{ObserverDescriptor, ObserverTrigger}, prelude::*, query::DebugCheckedUnwrap, @@ -190,7 +190,7 @@ pub type ObserverRunner = fn(DeferredWorld, ObserverTrigger, PtrMut, propagate: /// [`SystemParam`]: crate::system::SystemParam pub struct Observer { hook_on_add: ComponentHook, - error_handler: Option, + error_handler: Option, system: Box, pub(crate) descriptor: ObserverDescriptor, pub(crate) last_trigger_id: u32, @@ -232,6 +232,7 @@ impl Observer { system: Box::new(|| {}), descriptor: Default::default(), hook_on_add: |mut world, hook_context| { + let default_error_handler = world.default_error_handler(); world.commands().queue(move |world: &mut World| { let entity = hook_context.entity; if let Some(mut observe) = world.get_mut::(entity) { @@ -239,7 +240,7 @@ impl Observer { return; } if observe.error_handler.is_none() { - observe.error_handler = Some(default_error_handler()); + observe.error_handler = Some(default_error_handler); } world.register_observer(entity); } @@ -348,8 +349,6 @@ fn observer_system_runner>( return; } state.last_trigger_id = last_trigger; - // SAFETY: Observer was triggered so must have an `Observer` component. - let error_handler = unsafe { state.error_handler.debug_checked_unwrap() }; let trigger: Trigger = Trigger::new( // SAFETY: Caller ensures `ptr` is castable to `&mut T` @@ -377,7 +376,10 @@ fn observer_system_runner>( match (*system).validate_param_unsafe(world) { Ok(()) => { if let Err(err) = (*system).run_unsafe(trigger, world) { - error_handler( + let handler = state + .error_handler + .unwrap_or_else(|| world.default_error_handler()); + handler( err, ErrorContext::Observer { name: (*system).name(), @@ -389,7 +391,10 @@ fn observer_system_runner>( } Err(e) => { if !e.skipped { - error_handler( + let handler = state + .error_handler + .unwrap_or_else(|| world.default_error_handler()); + handler( e.into(), ErrorContext::Observer { name: (*system).name(), @@ -424,9 +429,6 @@ fn hook_on_add>( observe.descriptor.events.push(event_id); observe.descriptor.components.extend(components); - if observe.error_handler.is_none() { - observe.error_handler = Some(default_error_handler()); - } let system: *mut dyn ObserverSystem = observe.system.downcast_mut::().unwrap(); // SAFETY: World reference is exclusive and initialize does not touch system, so references do not alias unsafe { @@ -439,7 +441,11 @@ fn hook_on_add>( #[cfg(test)] mod tests { use super::*; - use crate::{event::Event, observer::Trigger}; + use crate::{ + error::{ignore, DefaultErrorHandler}, + event::Event, + observer::Trigger, + }; #[derive(Event)] struct TriggerEvent; @@ -467,11 +473,20 @@ mod tests { Err("I failed!".into()) } + // Using observer error handler let mut world = World::default(); world.init_resource::(); - let observer = Observer::new(system).with_error_handler(crate::error::ignore); - world.spawn(observer); - Schedule::default().run(&mut world); + world.spawn(Observer::new(system).with_error_handler(ignore)); + world.trigger(TriggerEvent); + assert!(world.resource::().0); + + // Using world error handler + let mut world = World::default(); + world.init_resource::(); + world.spawn(Observer::new(system)); + // Test that the correct handler is used when the observer was added + // before the default handler + world.insert_resource(DefaultErrorHandler(ignore)); world.trigger(TriggerEvent); assert!(world.resource::().0); } diff --git a/crates/bevy_ecs/src/schedule/executor/multi_threaded.rs b/crates/bevy_ecs/src/schedule/executor/multi_threaded.rs index b0757cc031..763504eaec 100644 --- a/crates/bevy_ecs/src/schedule/executor/multi_threaded.rs +++ b/crates/bevy_ecs/src/schedule/executor/multi_threaded.rs @@ -13,7 +13,7 @@ use std::sync::{Mutex, MutexGuard}; use tracing::{info_span, Span}; use crate::{ - error::{default_error_handler, BevyError, ErrorContext, Result}, + error::{ErrorContext, ErrorHandler, Result}, prelude::Resource, schedule::{is_apply_deferred, BoxedCondition, ExecutorKind, SystemExecutor, SystemSchedule}, system::ScheduleSystem, @@ -134,7 +134,7 @@ pub struct ExecutorState { struct Context<'scope, 'env, 'sys> { environment: &'env Environment<'env, 'sys>, scope: &'scope Scope<'scope, 'env, ()>, - error_handler: fn(BevyError, ErrorContext), + error_handler: ErrorHandler, } impl Default for MultiThreadedExecutor { @@ -240,7 +240,7 @@ impl SystemExecutor for MultiThreadedExecutor { schedule: &mut SystemSchedule, world: &mut World, _skip_systems: Option<&FixedBitSet>, - error_handler: fn(BevyError, ErrorContext), + error_handler: ErrorHandler, ) { let state = self.state.get_mut().unwrap(); // reset counts @@ -484,6 +484,7 @@ impl ExecutorState { system, conditions, context.environment.world_cell, + context.error_handler, ) } { self.skip_system_and_signal_dependents(system_index); @@ -584,9 +585,9 @@ impl ExecutorState { system: &mut ScheduleSystem, conditions: &mut Conditions, world: UnsafeWorldCell, + error_handler: ErrorHandler, ) -> bool { let mut should_run = !self.skipped_systems.contains(system_index); - let error_handler = default_error_handler(); for set_idx in conditions.sets_with_conditions_of_systems[system_index].ones() { if self.evaluated_sets.contains(set_idx) { @@ -599,7 +600,11 @@ impl ExecutorState { // required by the conditions. // - `update_archetype_component_access` has been called for each run condition. let set_conditions_met = unsafe { - evaluate_and_fold_conditions(&mut conditions.set_conditions[set_idx], world) + evaluate_and_fold_conditions( + &mut conditions.set_conditions[set_idx], + world, + error_handler, + ) }; if !set_conditions_met { @@ -617,7 +622,11 @@ impl ExecutorState { // required by the conditions. // - `update_archetype_component_access` has been called for each run condition. let system_conditions_met = unsafe { - evaluate_and_fold_conditions(&mut conditions.system_conditions[system_index], world) + evaluate_and_fold_conditions( + &mut conditions.system_conditions[system_index], + world, + error_handler, + ) }; if !system_conditions_met { @@ -822,9 +831,8 @@ fn apply_deferred( unsafe fn evaluate_and_fold_conditions( conditions: &mut [BoxedCondition], world: UnsafeWorldCell, + error_handler: ErrorHandler, ) -> bool { - let error_handler = default_error_handler(); - #[expect( clippy::unnecessary_fold, reason = "Short-circuiting here would prevent conditions from mutating their own state as needed." diff --git a/crates/bevy_ecs/src/schedule/executor/simple.rs b/crates/bevy_ecs/src/schedule/executor/simple.rs index 701a8d8f06..584c5a1073 100644 --- a/crates/bevy_ecs/src/schedule/executor/simple.rs +++ b/crates/bevy_ecs/src/schedule/executor/simple.rs @@ -10,7 +10,7 @@ use tracing::info_span; use std::eprintln; use crate::{ - error::{default_error_handler, BevyError, ErrorContext}, + error::{ErrorContext, ErrorHandler}, schedule::{ executor::is_apply_deferred, BoxedCondition, ExecutorKind, SystemExecutor, SystemSchedule, }, @@ -50,7 +50,7 @@ impl SystemExecutor for SimpleExecutor { schedule: &mut SystemSchedule, world: &mut World, _skip_systems: Option<&FixedBitSet>, - error_handler: fn(BevyError, ErrorContext), + error_handler: ErrorHandler, ) { // If stepping is enabled, make sure we skip those systems that should // not be run. @@ -73,8 +73,11 @@ impl SystemExecutor for SimpleExecutor { } // evaluate system set's conditions - let set_conditions_met = - evaluate_and_fold_conditions(&mut schedule.set_conditions[set_idx], world); + let set_conditions_met = evaluate_and_fold_conditions( + &mut schedule.set_conditions[set_idx], + world, + error_handler, + ); if !set_conditions_met { self.completed_systems @@ -86,8 +89,11 @@ impl SystemExecutor for SimpleExecutor { } // evaluate system's conditions - let system_conditions_met = - evaluate_and_fold_conditions(&mut schedule.system_conditions[system_index], world); + let system_conditions_met = evaluate_and_fold_conditions( + &mut schedule.system_conditions[system_index], + world, + error_handler, + ); should_run &= system_conditions_met; @@ -175,9 +181,11 @@ impl SimpleExecutor { since = "0.17.0", note = "Use SingleThreadedExecutor instead. See https://github.com/bevyengine/bevy/issues/18453 for motivation." )] -fn evaluate_and_fold_conditions(conditions: &mut [BoxedCondition], world: &mut World) -> bool { - let error_handler = default_error_handler(); - +fn evaluate_and_fold_conditions( + conditions: &mut [BoxedCondition], + world: &mut World, + error_handler: ErrorHandler, +) -> bool { #[expect( clippy::unnecessary_fold, reason = "Short-circuiting here would prevent conditions from mutating their own state as needed." diff --git a/crates/bevy_ecs/src/schedule/executor/single_threaded.rs b/crates/bevy_ecs/src/schedule/executor/single_threaded.rs index 82e9e354a8..0076103637 100644 --- a/crates/bevy_ecs/src/schedule/executor/single_threaded.rs +++ b/crates/bevy_ecs/src/schedule/executor/single_threaded.rs @@ -8,7 +8,7 @@ use tracing::info_span; use std::eprintln; use crate::{ - error::{default_error_handler, BevyError, ErrorContext}, + error::{ErrorContext, ErrorHandler}, schedule::{is_apply_deferred, BoxedCondition, ExecutorKind, SystemExecutor, SystemSchedule}, world::World, }; @@ -50,7 +50,7 @@ impl SystemExecutor for SingleThreadedExecutor { schedule: &mut SystemSchedule, world: &mut World, _skip_systems: Option<&FixedBitSet>, - error_handler: fn(BevyError, ErrorContext), + error_handler: ErrorHandler, ) { // If stepping is enabled, make sure we skip those systems that should // not be run. @@ -73,8 +73,11 @@ impl SystemExecutor for SingleThreadedExecutor { } // evaluate system set's conditions - let set_conditions_met = - evaluate_and_fold_conditions(&mut schedule.set_conditions[set_idx], world); + let set_conditions_met = evaluate_and_fold_conditions( + &mut schedule.set_conditions[set_idx], + world, + error_handler, + ); if !set_conditions_met { self.completed_systems @@ -86,8 +89,11 @@ impl SystemExecutor for SingleThreadedExecutor { } // evaluate system's conditions - let system_conditions_met = - evaluate_and_fold_conditions(&mut schedule.system_conditions[system_index], world); + let system_conditions_met = evaluate_and_fold_conditions( + &mut schedule.system_conditions[system_index], + world, + error_handler, + ); should_run &= system_conditions_met; @@ -193,9 +199,11 @@ impl SingleThreadedExecutor { } } -fn evaluate_and_fold_conditions(conditions: &mut [BoxedCondition], world: &mut World) -> bool { - let error_handler: fn(BevyError, ErrorContext) = default_error_handler(); - +fn evaluate_and_fold_conditions( + conditions: &mut [BoxedCondition], + world: &mut World, + error_handler: ErrorHandler, +) -> bool { #[expect( clippy::unnecessary_fold, reason = "Short-circuiting here would prevent conditions from mutating their own state as needed." diff --git a/crates/bevy_ecs/src/schedule/schedule.rs b/crates/bevy_ecs/src/schedule/schedule.rs index a754f1e1d4..b15860406b 100644 --- a/crates/bevy_ecs/src/schedule/schedule.rs +++ b/crates/bevy_ecs/src/schedule/schedule.rs @@ -27,7 +27,6 @@ use tracing::info_span; use crate::{ component::{ComponentId, Components, Tick}, - error::default_error_handler, prelude::Component, resource::Resource, schedule::*, @@ -442,7 +441,7 @@ impl Schedule { self.initialize(world) .unwrap_or_else(|e| panic!("Error when initializing schedule {:?}: {e}", self.label)); - let error_handler = default_error_handler(); + let error_handler = world.default_error_handler(); #[cfg(not(feature = "bevy_debug_stepping"))] self.executor @@ -2061,6 +2060,7 @@ mod tests { use bevy_ecs_macros::ScheduleLabel; use crate::{ + error::{ignore, panic, DefaultErrorHandler, Result}, prelude::{ApplyDeferred, Res, Resource}, schedule::{ tests::ResMut, IntoScheduleConfigs, Schedule, ScheduleBuildSettings, SystemSet, @@ -2810,4 +2810,32 @@ mod tests { .expect("CheckSystemRan Resource Should Exist"); assert_eq!(value.0, 2); } + + #[test] + fn test_default_error_handler() { + #[derive(Resource, Default)] + struct Ran(bool); + + fn system(mut ran: ResMut) -> Result { + ran.0 = true; + Err("I failed!".into()) + } + + // Test that the default error handler is used + let mut world = World::default(); + world.init_resource::(); + world.insert_resource(DefaultErrorHandler(ignore)); + let mut schedule = Schedule::default(); + schedule.add_systems(system).run(&mut world); + assert!(world.resource::().0); + + // Test that the handler doesn't change within the schedule + schedule.add_systems( + (|world: &mut World| { + world.insert_resource(DefaultErrorHandler(panic)); + }) + .before(system), + ); + schedule.run(&mut world); + } } diff --git a/crates/bevy_ecs/src/system/commands/mod.rs b/crates/bevy_ecs/src/system/commands/mod.rs index 621a9de77e..8b10b64b28 100644 --- a/crates/bevy_ecs/src/system/commands/mod.rs +++ b/crates/bevy_ecs/src/system/commands/mod.rs @@ -88,8 +88,8 @@ use crate::{ /// A [`Command`] can return a [`Result`](crate::error::Result), /// which will be passed to an [error handler](crate::error) if the `Result` is an error. /// -/// The [default error handler](crate::error::default_error_handler) panics. -/// It can be configured by setting the `GLOBAL_ERROR_HANDLER`. +/// The default error handler panics. It can be configured via +/// the [`DefaultErrorHandler`](crate::error::DefaultErrorHandler) resource. /// /// Alternatively, you can customize the error handler for a specific command /// by calling [`Commands::queue_handled`]. @@ -508,7 +508,7 @@ impl<'w, 's> Commands<'w, 's> { /// Pushes a generic [`Command`] to the command queue. /// /// If the [`Command`] returns a [`Result`], - /// it will be handled using the [default error handler](crate::error::default_error_handler). + /// it will be handled using the [default error handler](crate::error::DefaultErrorHandler). /// /// To use a custom error handler, see [`Commands::queue_handled`]. /// @@ -643,7 +643,7 @@ impl<'w, 's> Commands<'w, 's> { /// This command will fail if any of the given entities do not exist. /// /// It will internally return a [`TryInsertBatchError`](crate::world::error::TryInsertBatchError), - /// which will be handled by the [default error handler](crate::error::default_error_handler). + /// which will be handled by the [default error handler](crate::error::DefaultErrorHandler). #[track_caller] pub fn insert_batch(&mut self, batch: I) where @@ -674,7 +674,7 @@ impl<'w, 's> Commands<'w, 's> { /// This command will fail if any of the given entities do not exist. /// /// It will internally return a [`TryInsertBatchError`](crate::world::error::TryInsertBatchError), - /// which will be handled by the [default error handler](crate::error::default_error_handler). + /// which will be handled by the [default error handler](crate::error::DefaultErrorHandler). #[track_caller] pub fn insert_batch_if_new(&mut self, batch: I) where @@ -1175,8 +1175,8 @@ impl<'w, 's> Commands<'w, 's> { /// An [`EntityCommand`] can return a [`Result`](crate::error::Result), /// which will be passed to an [error handler](crate::error) if the `Result` is an error. /// -/// The [default error handler](crate::error::default_error_handler) panics. -/// It can be configured by setting the `GLOBAL_ERROR_HANDLER`. +/// The default error handler panics. It can be configured via +/// the [`DefaultErrorHandler`](crate::error::DefaultErrorHandler) resource. /// /// Alternatively, you can customize the error handler for a specific command /// by calling [`EntityCommands::queue_handled`]. @@ -1768,7 +1768,7 @@ impl<'a> EntityCommands<'a> { /// Pushes an [`EntityCommand`] to the queue, /// which will get executed for the current [`Entity`]. /// - /// The [default error handler](crate::error::default_error_handler) + /// The [default error handler](crate::error::DefaultErrorHandler) /// will be used to handle error cases. /// Every [`EntityCommand`] checks whether the entity exists at the time of execution /// and returns an error if it does not. diff --git a/crates/bevy_ecs/src/world/mod.rs b/crates/bevy_ecs/src/world/mod.rs index 87340551af..3a1195aea3 100644 --- a/crates/bevy_ecs/src/world/mod.rs +++ b/crates/bevy_ecs/src/world/mod.rs @@ -14,6 +14,7 @@ pub mod unsafe_world_cell; #[cfg(feature = "bevy_reflect")] pub mod reflect; +use crate::error::{DefaultErrorHandler, ErrorHandler}; pub use crate::{ change_detection::{Mut, Ref, CHECK_TICK_THRESHOLD}, world::command_queue::CommandQueue, @@ -3046,6 +3047,16 @@ impl World { // SAFETY: We just initialized the bundle so its id should definitely be valid. unsafe { self.bundles.get(id).debug_checked_unwrap() } } + + /// Convenience method for accessing the world's default error handler, + /// which can be overwritten with [`DefaultErrorHandler`]. + #[inline] + pub fn default_error_handler(&self) -> ErrorHandler { + self.get_resource::() + .copied() + .unwrap_or_default() + .0 + } } impl World { diff --git a/crates/bevy_ecs/src/world/unsafe_world_cell.rs b/crates/bevy_ecs/src/world/unsafe_world_cell.rs index ea5f21c22e..3f74f855a6 100644 --- a/crates/bevy_ecs/src/world/unsafe_world_cell.rs +++ b/crates/bevy_ecs/src/world/unsafe_world_cell.rs @@ -7,6 +7,7 @@ use crate::{ change_detection::{MaybeLocation, MutUntyped, Ticks, TicksMut}, component::{ComponentId, ComponentTicks, Components, Mutable, StorageType, Tick, TickCells}, entity::{ContainsEntity, Entities, Entity, EntityDoesNotExistError, EntityLocation}, + error::{DefaultErrorHandler, ErrorHandler}, observer::Observers, prelude::Component, query::{DebugCheckedUnwrap, ReadOnlyQueryData}, @@ -705,6 +706,18 @@ impl<'w> UnsafeWorldCell<'w> { (*self.ptr).last_trigger_id = (*self.ptr).last_trigger_id.wrapping_add(1); } } + + /// Convenience method for accessing the world's default error handler, + /// + /// # Safety + /// Must have read access to [`DefaultErrorHandler`]. + #[inline] + pub unsafe fn default_error_handler(&self) -> ErrorHandler { + self.get_resource::() + .copied() + .unwrap_or_default() + .0 + } } impl Debug for UnsafeWorldCell<'_> { diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 28d234f2b4..38f89d4ef8 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -279,9 +279,6 @@ custom_cursor = ["bevy_winit/custom_cursor"] # Experimental support for nodes that are ignored for UI layouting ghost_nodes = ["bevy_ui/ghost_nodes"] -# Use the configurable global error handler as the default error handler. -configurable_error_handler = ["bevy_ecs/configurable_error_handler"] - # Allows access to the `std` crate. Enabling this feature will prevent compilation # on `no_std` targets, but provides access to certain additional features on # supported platforms. diff --git a/docs/cargo_features.md b/docs/cargo_features.md index f15fa1c4c6..1a1cb68fda 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -70,7 +70,6 @@ The default feature set enables most of the expected features of a game engine, |bevy_remote|Enable the Bevy Remote Protocol| |bevy_ui_debug|Provides a debug overlay for bevy UI| |bmp|BMP image format support| -|configurable_error_handler|Use the configurable global error handler as the default error handler.| |critical-section|`critical-section` provides the building blocks for synchronization primitives on all platforms, including `no_std`.| |dds|DDS compressed texture support| |debug_glam_assert|Enable assertions in debug builds to check the validity of parameters passed to glam| diff --git a/examples/ecs/error_handling.rs b/examples/ecs/error_handling.rs index d326d7d4aa..31f9838aaa 100644 --- a/examples/ecs/error_handling.rs +++ b/examples/ecs/error_handling.rs @@ -1,13 +1,7 @@ //! Showcases how fallible systems and observers can make use of Rust's powerful result handling //! syntax. -//! -//! Important note: to set the global error handler, the `configurable_error_handler` feature must be -//! enabled. This feature is disabled by default, as it may introduce runtime overhead, especially for commands. -use bevy::ecs::{ - error::{warn, GLOBAL_ERROR_HANDLER}, - world::DeferredWorld, -}; +use bevy::ecs::{error::warn, world::DeferredWorld}; use bevy::math::sampling::UniformMeshSampler; use bevy::prelude::*; @@ -16,17 +10,15 @@ use rand::SeedableRng; use rand_chacha::ChaCha8Rng; fn main() { + let mut app = App::new(); // By default, fallible systems that return an error will panic. // - // We can change this by setting a custom error handler, which applies globally. - // Here we set the global error handler using one of the built-in - // error handlers. Bevy provides built-in handlers for `panic`, `error`, `warn`, `info`, + // We can change this by setting a custom error handler, which applies to the entire app + // (you can also set it for specific `World`s). + // Here we it using one of the built-in error handlers. + // Bevy provides built-in handlers for `panic`, `error`, `warn`, `info`, // `debug`, `trace` and `ignore`. - GLOBAL_ERROR_HANDLER - .set(warn) - .expect("The error handler can only be set once, globally."); - - let mut app = App::new(); + app.set_error_handler(warn); app.add_plugins(DefaultPlugins); diff --git a/examples/ecs/fallible_params.rs b/examples/ecs/fallible_params.rs index 1ba510cda9..94a8007aec 100644 --- a/examples/ecs/fallible_params.rs +++ b/examples/ecs/fallible_params.rs @@ -2,7 +2,7 @@ //! from running if their acquiry conditions aren't met. //! //! Fallible system parameters include: -//! - [`Res`], [`ResMut`] - Resource has to exist, and the [`GLOBAL_ERROR_HANDLER`] will be called if it doesn't. +//! - [`Res`], [`ResMut`] - Resource has to exist, and the [`World::get_default_error_handler`] will be called if it doesn't. //! - [`Single`] - There must be exactly one matching entity, but the system will be silently skipped otherwise. //! - [`Option>`] - There must be zero or one matching entity. The system will be silently skipped if there are more. //! - [`Populated`] - There must be at least one matching entity, but the system will be silently skipped otherwise. @@ -18,19 +18,13 @@ //! //! [`SystemParamValidationError`]: bevy::ecs::system::SystemParamValidationError //! [`SystemParam::validate_param`]: bevy::ecs::system::SystemParam::validate_param +//! [`default_error_handler`]: bevy::ecs::error::default_error_handler -use bevy::ecs::error::{warn, GLOBAL_ERROR_HANDLER}; +use bevy::ecs::error::warn; use bevy::prelude::*; use rand::Rng; fn main() { - // By default, if a parameter fail to be fetched, - // the `GLOBAL_ERROR_HANDLER` will be used to handle the error, - // which by default is set to panic. - GLOBAL_ERROR_HANDLER - .set(warn) - .expect("The error handler can only be set once, globally."); - println!(); println!("Press 'A' to add enemy ships and 'R' to remove them."); println!("Player ship will wait for enemy ships and track one if it exists,"); @@ -38,6 +32,10 @@ fn main() { println!(); App::new() + // By default, if a parameter fail to be fetched, + // `World::get_default_error_handler` will be used to handle the error, + // which by default is set to panic. + .set_error_handler(warn) .add_plugins(DefaultPlugins) .add_systems(Startup, setup) .add_systems(Update, (user_input, move_targets, track_targets).chain()) diff --git a/release-content/migration-guides/per-world-error-handler.md b/release-content/migration-guides/per-world-error-handler.md new file mode 100644 index 0000000000..1a963d13c6 --- /dev/null +++ b/release-content/migration-guides/per-world-error-handler.md @@ -0,0 +1,10 @@ +--- +title: Global default error handler +pull_requests: [18810] +--- + +Worlds can now have different default error handlers, so there no longer is a global handler. + +Replace uses of `GLOBAL_ERROR_HANDLER` with `App`'s `.set_error_handler(handler)`. +For worlds that do not directly belong to an `App`/`SubApp`, +insert the `DefaultErrorHandler(handler)` resource. From 37b16d869d797c2428ae9afcc189976ac5acdd4d Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 19 May 2025 20:17:20 +0100 Subject: [PATCH 002/421] Remove `YAxisOrientation` from `bevy_text` (#19077) # Objective Been looking for simplifications in the text systems as part of the text input changes. This enum isn't very helpful I think. We can remove it and the associated parameters and instead just negate the glyph's y-offsets in `extract_text2d_sprite`. ## Solution Remove the `YAxisOrientation` enum and parameters. Queue text sprites relative to the top-left in `extract_text2d_sprite` and negate the glyph's y-offset. ## Testing The `text2d` example can be used for testing: ``` cargo run --example text2d ``` --- crates/bevy_text/src/lib.rs | 12 ------------ crates/bevy_text/src/pipeline.rs | 7 +------ crates/bevy_text/src/text2d.rs | 11 +++++------ crates/bevy_ui/src/widget/text.rs | 3 +-- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index 70ac992924..2bc74a1aa7 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -87,18 +87,6 @@ pub const DEFAULT_FONT_DATA: &[u8] = include_bytes!("FiraMono-subset.ttf"); #[derive(Default)] pub struct TextPlugin; -/// Text is rendered for two different view projections; -/// 2-dimensional text ([`Text2d`]) is rendered in "world space" with a `BottomToTop` Y-axis, -/// while UI is rendered with a `TopToBottom` Y-axis. -/// This matters for text because the glyph positioning is different in either layout. -/// For `TopToBottom`, 0 is the top of the text, while for `BottomToTop` 0 is the bottom. -pub enum YAxisOrientation { - /// Top to bottom Y-axis orientation, for UI - TopToBottom, - /// Bottom to top Y-axis orientation, for 2d world space - BottomToTop, -} - /// System set in [`PostUpdate`] where all 2d text update systems are executed. #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub struct Text2dUpdateSystems; diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index 2a47866f76..93ee4907bd 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -17,7 +17,7 @@ use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, Wrap}; use crate::{ error::TextError, ComputedTextBlock, Font, FontAtlasSets, FontSmoothing, JustifyText, - LineBreak, PositionedGlyph, TextBounds, TextEntity, TextFont, TextLayout, YAxisOrientation, + LineBreak, PositionedGlyph, TextBounds, TextEntity, TextFont, TextLayout, }; /// A wrapper resource around a [`cosmic_text::FontSystem`] @@ -228,7 +228,6 @@ impl TextPipeline { font_atlas_sets: &mut FontAtlasSets, texture_atlases: &mut Assets, textures: &mut Assets, - y_axis_orientation: YAxisOrientation, computed: &mut ComputedTextBlock, font_system: &mut CosmicFontSystem, swash_cache: &mut SwashCache, @@ -348,10 +347,6 @@ impl TextPipeline { let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; let y = line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; - let y = match y_axis_orientation { - YAxisOrientation::TopToBottom => y, - YAxisOrientation::BottomToTop => box_size.y - y, - }; let position = Vec2::new(x, y); diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index a9419e89c0..5069804df8 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -2,7 +2,7 @@ use crate::pipeline::CosmicFontSystem; use crate::{ ComputedTextBlock, Font, FontAtlasSets, LineBreak, PositionedGlyph, SwashCache, TextBounds, TextColor, TextError, TextFont, TextLayout, TextLayoutInfo, TextPipeline, TextReader, TextRoot, - TextSpanAccess, TextWriter, YAxisOrientation, + TextSpanAccess, TextWriter, }; use bevy_asset::Assets; use bevy_color::LinearRgba; @@ -182,10 +182,10 @@ pub fn extract_text2d_sprite( text_bounds.width.unwrap_or(text_layout_info.size.x), text_bounds.height.unwrap_or(text_layout_info.size.y), ); - let bottom_left = - -(anchor.as_vec() + 0.5) * size + (size.y - text_layout_info.size.y) * Vec2::Y; + + let top_left = (Anchor::TOP_LEFT.0 - anchor.as_vec()) * size; let transform = - *global_transform * GlobalTransform::from_translation(bottom_left.extend(0.)) * scaling; + *global_transform * GlobalTransform::from_translation(top_left.extend(0.)) * scaling; let mut color = LinearRgba::WHITE; let mut current_span = usize::MAX; @@ -218,7 +218,7 @@ pub fn extract_text2d_sprite( .textures[atlas_info.location.glyph_index] .as_rect(); extracted_slices.slices.push(ExtractedSlice { - offset: *position, + offset: Vec2::new(position.x, -position.y), rect, size: rect.size(), }); @@ -316,7 +316,6 @@ pub fn update_text2d_layout( &mut font_atlas_sets, &mut texture_atlases, &mut textures, - YAxisOrientation::BottomToTop, computed.as_mut(), &mut font_system, &mut swash_cache, diff --git a/crates/bevy_ui/src/widget/text.rs b/crates/bevy_ui/src/widget/text.rs index 0153fa954c..785040c1e9 100644 --- a/crates/bevy_ui/src/widget/text.rs +++ b/crates/bevy_ui/src/widget/text.rs @@ -20,7 +20,7 @@ use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_text::{ scale_value, ComputedTextBlock, CosmicFontSystem, Font, FontAtlasSets, LineBreak, SwashCache, TextBounds, TextColor, TextError, TextFont, TextLayout, TextLayoutInfo, TextMeasureInfo, - TextPipeline, TextReader, TextRoot, TextSpanAccess, TextWriter, YAxisOrientation, + TextPipeline, TextReader, TextRoot, TextSpanAccess, TextWriter, }; use taffy::style::AvailableSpace; use tracing::error; @@ -328,7 +328,6 @@ fn queue_text( font_atlas_sets, texture_atlases, textures, - YAxisOrientation::TopToBottom, computed, font_system, swash_cache, From fe16624d3c4af10c48b195a5d2196a70ae651445 Mon Sep 17 00:00:00 2001 From: theotherphil Date: Mon, 19 May 2025 20:22:07 +0100 Subject: [PATCH 003/421] Add boilerplate docs for PointerHits::new and HitData::new (#19259) # Objective Add documentation for the last two functions in bevy_picking that are missing them. ## Solution Add boilerplate "Constructs an X" to `PointerHits::new()` and `HitData::new()`. This form of no-information documentation of `new()` functions is used in several places in the repo, and @alice-i-cecile agreed that this is a reasonable approach - the params are already documented on the fields within the struct definition. --------- Co-authored-by: Jan Hohenheim --- crates/bevy_picking/src/backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bevy_picking/src/backend.rs b/crates/bevy_picking/src/backend.rs index 3758816ac9..9e28cc6d7c 100644 --- a/crates/bevy_picking/src/backend.rs +++ b/crates/bevy_picking/src/backend.rs @@ -84,7 +84,7 @@ pub struct PointerHits { } impl PointerHits { - #[expect(missing_docs, reason = "Not all docs are written yet, see #3492.")] + /// Construct [`PointerHits`]. pub fn new(pointer: prelude::PointerId, picks: Vec<(Entity, HitData)>, order: f32) -> Self { Self { pointer, @@ -114,7 +114,7 @@ pub struct HitData { } impl HitData { - #[expect(missing_docs, reason = "Not all docs are written yet, see #3492.")] + /// Construct a [`HitData`]. pub fn new(camera: Entity, depth: f32, position: Option, normal: Option) -> Self { Self { camera, From 70e6a9010d256cf982d4659e405b169cc5dcbba0 Mon Sep 17 00:00:00 2001 From: theotherphil Date: Mon, 19 May 2025 20:34:59 +0100 Subject: [PATCH 004/421] Add missing words in Traversal doc comment (#19298) # Objective Minor docs fix - add missing "is responsible". --- crates/bevy_ecs/src/traversal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ecs/src/traversal.rs b/crates/bevy_ecs/src/traversal.rs index 342ad47849..306ae7c92d 100644 --- a/crates/bevy_ecs/src/traversal.rs +++ b/crates/bevy_ecs/src/traversal.rs @@ -10,7 +10,7 @@ use crate::{entity::Entity, query::ReadOnlyQueryData, relationship::Relationship /// Infinite loops are possible, and are not checked for. While looping can be desirable in some contexts /// (for example, an observer that triggers itself multiple times before stopping), following an infinite /// traversal loop without an eventual exit will cause your application to hang. Each implementer of `Traversal` -/// for documenting possible looping behavior, and consumers of those implementations are responsible for +/// is responsible for documenting possible looping behavior, and consumers of those implementations are responsible for /// avoiding infinite loops in their code. /// /// Traversals may be parameterized with additional data. For example, in observer event propagation, the From 17914943a3c30f9cb006481b8241d3d909f5486e Mon Sep 17 00:00:00 2001 From: Eero Lehtinen Date: Mon, 19 May 2025 22:42:09 +0300 Subject: [PATCH 005/421] Fix spot light shadow glitches (#19273) # Objective Spot light shadows are still broken after fixing point lights in #19265 ## Solution Fix spot lights in the same way, just using the spot light specific visible entities component. I also changed the query to be directly in the render world instead of being extracted to be more accurate. ## Testing Tested with the same code but changing `PointLight` to `SpotLight`. --- crates/bevy_pbr/src/render/light.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/bevy_pbr/src/render/light.rs b/crates/bevy_pbr/src/render/light.rs index dfc7f679f3..f57ba9adf3 100644 --- a/crates/bevy_pbr/src/render/light.rs +++ b/crates/bevy_pbr/src/render/light.rs @@ -221,7 +221,17 @@ pub fn extract_lights( point_light_shadow_map: Extract>, directional_light_shadow_map: Extract>, global_visible_clusterable: Extract>, - cubemap_visible_entities: Extract>>, + previous_point_lights: Query< + Entity, + ( + With, + With, + ), + >, + previous_spot_lights: Query< + Entity, + (With, With), + >, point_lights: Extract< Query<( Entity, @@ -278,14 +288,20 @@ pub fn extract_lights( commands.insert_resource(directional_light_shadow_map.clone()); } - // Clear previous visible entities for all cubemapped lights as they might not be in the + // Clear previous visible entities for all point/spot lights as they might not be in the // `global_visible_clusterable` list anymore. commands.try_insert_batch( - cubemap_visible_entities + previous_point_lights .iter() .map(|render_entity| (render_entity, RenderCubemapVisibleEntities::default())) .collect::>(), ); + commands.try_insert_batch( + previous_spot_lights + .iter() + .map(|render_entity| (render_entity, RenderVisibleMeshEntities::default())) + .collect::>(), + ); // This is the point light shadow map texel size for one face of the cube as a distance of 1.0 // world unit from the light. From 55edb0b476fcd411000b65461366e8a886097858 Mon Sep 17 00:00:00 2001 From: Lucas Franca Date: Mon, 19 May 2025 20:56:48 -0300 Subject: [PATCH 006/421] Fix warnings and errors reported on Rust beta (#19294) # Objective Fixes errors and warnings on this week's Rust beta pipeline * https://github.com/bevyengine/bevy/issues/18748#issuecomment-2890820218 --- clippy.toml | 1 - crates/bevy_macro_utils/src/bevy_manifest.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/clippy.toml b/clippy.toml index 2c98e8ed02..372ffbaf0b 100644 --- a/clippy.toml +++ b/clippy.toml @@ -41,7 +41,6 @@ disallowed-methods = [ { path = "f32::asinh", reason = "use bevy_math::ops::asinh instead for libm determinism" }, { path = "f32::acosh", reason = "use bevy_math::ops::acosh instead for libm determinism" }, { path = "f32::atanh", reason = "use bevy_math::ops::atanh instead for libm determinism" }, - { path = "criterion::black_box", reason = "use core::hint::black_box instead" }, ] # Require `bevy_ecs::children!` to use `[]` braces, instead of `()` or `{}`. diff --git a/crates/bevy_macro_utils/src/bevy_manifest.rs b/crates/bevy_macro_utils/src/bevy_manifest.rs index 8d32781069..b6df0e0e0f 100644 --- a/crates/bevy_macro_utils/src/bevy_manifest.rs +++ b/crates/bevy_macro_utils/src/bevy_manifest.rs @@ -95,7 +95,7 @@ impl BevyManifest { return None; }; - let mut path = Self::parse_str::(&format!("::{}", package)); + let mut path = Self::parse_str::(&format!("::{package}")); if let Some(module) = name.strip_prefix("bevy_") { path.segments.push(Self::parse_str(module)); } From c62ca1a0d36c064e82f25c10b820b9dcdc319a87 Mon Sep 17 00:00:00 2001 From: DaoLendaye Date: Tue, 20 May 2025 22:45:04 +0800 Subject: [PATCH 007/421] 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 008/421] 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