diff --git a/Cargo.toml b/Cargo.toml index 70e9a4ca7cb15..ec361396b44c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2166,6 +2166,17 @@ description = "Showcases the RelativeCursorPosition component" category = "UI (User Interface)" wasm = true +[[example]] +name = "render_ui_to_texture" +path = "examples/ui/render_ui_to_texture.rs" +doc-scrape-examples = true + +[package.metadata.example.render_ui_to_texture] +name = "Render UI to Texture" +description = "An example of rendering UI as a part of a 3D world" +category = "UI (User Interface)" +wasm = true + [[example]] name = "size_constraints" path = "examples/ui/size_constraints.rs" diff --git a/crates/bevy_render/src/camera/camera.rs b/crates/bevy_render/src/camera/camera.rs index be6349689b7cb..8527ed5bfd9c5 100644 --- a/crates/bevy_render/src/camera/camera.rs +++ b/crates/bevy_render/src/camera/camera.rs @@ -26,6 +26,7 @@ use bevy_transform::components::GlobalTransform; use bevy_utils::{HashMap, HashSet}; use bevy_window::{ NormalizedWindowRef, PrimaryWindow, Window, WindowCreated, WindowRef, WindowResized, + WindowScaleFactorChanged, }; use std::{borrow::Cow, ops::Range}; use wgpu::{BlendState, LoadOp, TextureFormat}; @@ -74,7 +75,7 @@ pub struct RenderTargetInfo { pub struct ComputedCameraValues { projection_matrix: Mat4, target_info: Option, - // position and size of the `Viewport` + // size of the `Viewport` old_viewport_size: Option, } @@ -219,6 +220,11 @@ impl Camera { self.computed.target_info.as_ref().map(|t| t.physical_size) } + #[inline] + pub fn target_scaling_factor(&self) -> Option { + self.computed.target_info.as_ref().map(|t| t.scale_factor) + } + /// The projection matrix computed using this camera's [`CameraProjection`]. #[inline] pub fn projection_matrix(&self) -> Mat4 { @@ -546,9 +552,9 @@ impl NormalizedRenderTarget { /// System in charge of updating a [`Camera`] when its window or projection changes. /// -/// The system detects window creation and resize events to update the camera projection if -/// needed. It also queries any [`CameraProjection`] component associated with the same entity -/// as the [`Camera`] one, to automatically update the camera projection matrix. +/// The system detects window creation, resize, and scale factor change events to update the camera +/// projection if needed. It also queries any [`CameraProjection`] component associated with the same +/// entity as the [`Camera`] one, to automatically update the camera projection matrix. /// /// The system function is generic over the camera projection type, and only instances of /// [`OrthographicProjection`] and [`PerspectiveProjection`] are automatically added to @@ -567,6 +573,7 @@ impl NormalizedRenderTarget { pub fn camera_system( mut window_resized_events: EventReader, mut window_created_events: EventReader, + mut window_scale_factor_changed_events: EventReader, mut image_asset_events: EventReader>, primary_window: Query>, windows: Query<(Entity, &Window)>, @@ -579,6 +586,11 @@ pub fn camera_system( let mut changed_window_ids = HashSet::new(); changed_window_ids.extend(window_created_events.read().map(|event| event.window)); changed_window_ids.extend(window_resized_events.read().map(|event| event.window)); + let scale_factor_changed_window_ids: HashSet<_> = window_scale_factor_changed_events + .read() + .map(|event| event.window) + .collect(); + changed_window_ids.extend(scale_factor_changed_window_ids.clone()); let changed_image_handles: HashSet<&AssetId> = image_asset_events .read() @@ -592,7 +604,7 @@ pub fn camera_system( .collect(); for (mut camera, mut camera_projection) in &mut cameras { - let viewport_size = camera + let mut viewport_size = camera .viewport .as_ref() .map(|viewport| viewport.physical_size); @@ -603,11 +615,36 @@ pub fn camera_system( || camera_projection.is_changed() || camera.computed.old_viewport_size != viewport_size { - camera.computed.target_info = normalized_target.get_render_target_info( + let new_computed_target_info = normalized_target.get_render_target_info( &windows, &images, &manual_texture_views, ); + // Check for the scale factor changing, and resize the viewport if needed. + // This can happen when the window is moved between monitors with different DPIs. + // Without this, the viewport will take a smaller portion of the window moved to + // a higher DPI monitor. + if normalized_target.is_changed(&scale_factor_changed_window_ids, &HashSet::new()) { + if let (Some(new_scale_factor), Some(old_scale_factor)) = ( + new_computed_target_info + .as_ref() + .map(|info| info.scale_factor), + camera + .computed + .target_info + .as_ref() + .map(|info| info.scale_factor), + ) { + let resize_factor = new_scale_factor / old_scale_factor; + if let Some(ref mut viewport) = camera.viewport { + let resize = |vec: UVec2| (vec.as_dvec2() * resize_factor).as_uvec2(); + viewport.physical_position = resize(viewport.physical_position); + viewport.physical_size = resize(viewport.physical_size); + viewport_size = Some(viewport.physical_size); + } + } + } + camera.computed.target_info = new_computed_target_info; if let Some(size) = camera.logical_viewport_size() { camera_projection.update(size.x, size.y); camera.computed.projection_matrix = camera_projection.get_projection_matrix(); diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index 91454adeeaada..11f93b87322ab 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -1,4 +1,4 @@ -use crate::{camera_config::UiCameraConfig, CalculatedClip, Node, UiScale, UiStack}; +use crate::{camera_config::UiCameraConfig, CalculatedClip, Node, TargetCamera, UiScale, UiStack}; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ change_detection::DetectChangesMut, @@ -126,6 +126,7 @@ pub struct NodeQuery { focus_policy: Option<&'static FocusPolicy>, calculated_clip: Option<&'static CalculatedClip>, view_visibility: Option<&'static ViewVisibility>, + target_camera: Option<&'static TargetCamera>, } /// The system that sets Interaction for all UI elements based on the mouse cursor activity @@ -134,14 +135,14 @@ pub struct NodeQuery { #[allow(clippy::too_many_arguments)] pub fn ui_focus_system( mut state: Local, - camera: Query<(&Camera, Option<&UiCameraConfig>)>, + camera_query: Query<(Entity, &Camera, Option<&UiCameraConfig>)>, + primary_window: Query>, windows: Query<&Window>, mouse_button_input: Res>, touches_input: Res, ui_scale: Res, ui_stack: Res, mut node_query: Query, - primary_window: Query>, ) { let primary_window = primary_window.iter().next(); @@ -170,28 +171,34 @@ pub fn ui_focus_system( let is_ui_disabled = |camera_ui| matches!(camera_ui, Some(&UiCameraConfig { show_ui: false, .. })); - let cursor_position = camera + // If there is only one camera, we use it as default + let default_single_camera = camera_query.get_single().ok().map(|(entity, _, _)| entity); + let camera_cursor_positions: Vec<(Entity, Vec2)> = camera_query .iter() - .filter(|(_, camera_ui)| !is_ui_disabled(*camera_ui)) - .filter_map(|(camera, _)| { - if let Some(NormalizedRenderTarget::Window(window_ref)) = + .filter(|(_, _, camera_ui)| !is_ui_disabled(*camera_ui)) + .filter_map(|(entity, camera, _)| { + // Interactions are only supported for cameras rendering to a window. + let Some(NormalizedRenderTarget::Window(window_ref)) = camera.target.normalize(primary_window) - { - Some(window_ref) - } else { - None - } - }) - .find_map(|window_ref| { + else { + return None; + }; + + let viewport_position = camera + .logical_viewport_rect() + .map(|rect| rect.min) + .unwrap_or_default(); windows .get(window_ref.entity()) .ok() .and_then(|window| window.cursor_position()) + .or_else(|| touches_input.first_pressed_position()) + .map(|cursor_position| (entity, cursor_position - viewport_position)) }) - .or_else(|| touches_input.first_pressed_position()) // The cursor position returned by `Window` only takes into account the window scale factor and not `UiScale`. // To convert the cursor position to logical UI viewport coordinates we have to divide it by `UiScale`. - .map(|cursor_position| cursor_position / ui_scale.0 as f32); + .map(|(entity, cursor_position)| (entity, cursor_position / ui_scale.0 as f32)) + .collect(); // prepare an iterator that contains all the nodes that have the cursor in their rect, // from the top node to the bottom one. this will also reset the interaction to `None` @@ -202,59 +209,70 @@ pub fn ui_focus_system( // reverse the iterator to traverse the tree from closest nodes to furthest .rev() .filter_map(|entity| { - if let Ok(node) = node_query.get_mut(*entity) { - // Nodes that are not rendered should not be interactable - if let Some(view_visibility) = node.view_visibility { - if !view_visibility.get() { - // Reset their interaction to None to avoid strange stuck state - if let Some(mut interaction) = node.interaction { - // We cannot simply set the interaction to None, as that will trigger change detection repeatedly - interaction.set_if_neq(Interaction::None); - } + let Ok(node) = node_query.get_mut(*entity) else { + return None; + }; - return None; - } + let Some(view_visibility) = node.view_visibility else { + return None; + }; + // Nodes that are not rendered should not be interactable + if !view_visibility.get() { + // Reset their interaction to None to avoid strange stuck state + if let Some(mut interaction) = node.interaction { + // We cannot simply set the interaction to None, as that will trigger change detection repeatedly + interaction.set_if_neq(Interaction::None); } + return None; + } + let Some(camera_entity) = node + .target_camera + .map(TargetCamera::entity) + .or(default_single_camera) + else { + return None; + }; - let position = node.global_transform.translation(); - let ui_position = position.truncate(); - let extents = node.node.size() / 2.0; - let mut min = ui_position - extents; - if let Some(clip) = node.calculated_clip { - min = Vec2::max(min, clip.clip.min); - } + let cursor_position = camera_cursor_positions + .iter() + .find_map(|(entity, position)| { + if *entity == camera_entity { + Some(*position) + } else { + None + } + }); - // The mouse position relative to the node - // (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner - let relative_cursor_position = cursor_position - .map(|cursor_position| (cursor_position - min) / node.node.size()); + let node_rect = node.node.logical_rect(node.global_transform); - // If the current cursor position is within the bounds of the node, consider it for - // clicking - let relative_cursor_position_component = RelativeCursorPosition { - normalized: relative_cursor_position, - }; + // The mouse position relative to the node + // (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner + // Coordinates are relative to the entire node, not just the visible region. + let relative_cursor_position = cursor_position + .map(|cursor_position| (cursor_position - node_rect.min) / node_rect.size()); - let contains_cursor = relative_cursor_position_component.mouse_over(); + // If the current cursor position is within the bounds of the node's visible area, consider it for + // clicking + let relative_cursor_position_component = RelativeCursorPosition { + normalized: relative_cursor_position, + }; - // Save the relative cursor position to the correct component - if let Some(mut node_relative_cursor_position_component) = - node.relative_cursor_position - { - *node_relative_cursor_position_component = relative_cursor_position_component; - } + let contains_cursor = relative_cursor_position_component.mouse_over(); - if contains_cursor { - Some(*entity) - } else { - if let Some(mut interaction) = node.interaction { - if *interaction == Interaction::Hovered || (cursor_position.is_none()) { - interaction.set_if_neq(Interaction::None); - } + // Save the relative cursor position to the correct component + if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position + { + *node_relative_cursor_position_component = relative_cursor_position_component; + } + + if contains_cursor { + Some(*entity) + } else { + if let Some(mut interaction) = node.interaction { + if *interaction == Interaction::Hovered || (cursor_position.is_none()) { + interaction.set_if_neq(Interaction::None); } - None } - } else { None } }) diff --git a/crates/bevy_ui/src/layout/debug.rs b/crates/bevy_ui/src/layout/debug.rs index c47b2ca8e802b..37fb5c2b88845 100644 --- a/crates/bevy_ui/src/layout/debug.rs +++ b/crates/bevy_ui/src/layout/debug.rs @@ -12,7 +12,7 @@ pub fn print_ui_layout_tree(ui_surface: &UiSurface) { .iter() .map(|(entity, node)| (*node, *entity)) .collect(); - for (&entity, roots) in &ui_surface.window_roots { + for (&entity, roots) in &ui_surface.camera_roots { let mut out = String::new(); for root in roots { print_node( @@ -25,7 +25,7 @@ pub fn print_ui_layout_tree(ui_surface: &UiSurface) { &mut out, ); } - bevy_log::info!("Layout tree for window entity: {entity:?}\n{out}"); + bevy_log::info!("Layout tree for camera entity: {entity:?}\n{out}"); } } diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index d404431b1bf26..6e7788ed7991d 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,7 +1,7 @@ mod convert; pub mod debug; -use crate::{ContentSize, Node, Outline, Style, UiScale}; +use crate::{ContentSize, Node, Outline, Style, TargetCamera, UiScale}; use bevy_ecs::{ change_detection::{DetectChanges, DetectChangesMut}, entity::Entity, @@ -13,10 +13,11 @@ use bevy_ecs::{ }; use bevy_hierarchy::{Children, Parent}; use bevy_log::warn; -use bevy_math::Vec2; +use bevy_math::{UVec2, Vec2}; +use bevy_render::camera::{Camera, NormalizedRenderTarget}; use bevy_transform::components::Transform; -use bevy_utils::{default, HashMap}; -use bevy_window::{PrimaryWindow, Window, WindowResolution, WindowScaleFactorChanged}; +use bevy_utils::{default, HashMap, HashSet}; +use bevy_window::{PrimaryWindow, Window, WindowScaleFactorChanged}; use std::fmt; use taffy::Taffy; use thiserror::Error; @@ -51,7 +52,7 @@ struct RootNodePair { #[derive(Resource)] pub struct UiSurface { entity_to_taffy: HashMap, - window_roots: HashMap>, + camera_roots: HashMap>, taffy: Taffy, } @@ -66,7 +67,7 @@ impl fmt::Debug for UiSurface { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_struct("UiSurface") .field("entity_to_taffy", &self.entity_to_taffy) - .field("window_nodes", &self.window_roots) + .field("camera_roots", &self.camera_roots) .finish() } } @@ -77,7 +78,7 @@ impl Default for UiSurface { taffy.disable_rounding(); Self { entity_to_taffy: Default::default(), - window_roots: Default::default(), + camera_roots: Default::default(), taffy, } } @@ -142,9 +143,9 @@ without UI components as a child of an entity with UI components, results may be } /// Set the ui node entities without a [`Parent`] as children to the root node in the taffy layout. - pub fn set_window_children( + pub fn set_camera_children( &mut self, - window_id: Entity, + camera_id: Entity, children: impl Iterator, ) { let viewport_style = taffy::style::Style { @@ -160,7 +161,7 @@ without UI components as a child of an entity with UI components, results may be ..default() }; - let existing_roots = self.window_roots.entry(window_id).or_default(); + let existing_roots = self.camera_roots.entry(camera_id).or_default(); let mut new_roots = Vec::new(); for entity in children { let node = *self.entity_to_taffy.get(&entity).unwrap(); @@ -185,18 +186,20 @@ without UI components as a child of an entity with UI components, results may be } } - self.window_roots.insert(window_id, new_roots); + self.camera_roots.insert(camera_id, new_roots); } /// Compute the layout for each window entity's corresponding root node in the layout. - pub fn compute_window_layout(&mut self, window: Entity, window_resolution: &WindowResolution) { + pub fn compute_camera_layout(&mut self, camera: Entity, render_target_resolution: UVec2) { + let Some(camera_root_nodes) = self.camera_roots.get(&camera) else { + return; + }; + let available_space = taffy::geometry::Size { - width: taffy::style::AvailableSpace::Definite(window_resolution.physical_width() as f32), - height: taffy::style::AvailableSpace::Definite( - window_resolution.physical_height() as f32 - ), + width: taffy::style::AvailableSpace::Definite(render_target_resolution.x as f32), + height: taffy::style::AvailableSpace::Definite(render_target_resolution.y as f32), }; - for root_nodes in self.window_roots.entry(window).or_default() { + for root_nodes in camera_root_nodes { self.taffy .compute_layout(root_nodes.implicit_viewport_node, available_space) .unwrap(); @@ -241,64 +244,110 @@ pub enum LayoutError { #[allow(clippy::too_many_arguments)] pub fn ui_layout_system( primary_window: Query<(Entity, &Window), With>, - windows: Query<(Entity, &Window)>, + cameras: Query<(Entity, &Camera)>, ui_scale: Res, mut scale_factor_events: EventReader, mut resize_events: EventReader, mut ui_surface: ResMut, - root_node_query: Query, Without)>, - style_query: Query<(Entity, Ref