Skip to content

Commit eb9db21

Browse files
bardtDavieralice-i-cecileAlice Cecile
authored
Camera-driven UI (#10559)
# Objective Add support for presenting each UI tree on a specific window and viewport, while making as few breaking changes as possible. This PR is meant to resolve the following issues at once, since they're all related. - Fixes #5622 - Fixes #5570 - Fixes #5621 Adopted #5892 , but started over since the current codebase diverged significantly from the original PR branch. Also, I made a decision to propagate component to children instead of recursively iterating over nodes in search for the root. ## Solution Add a new optional component that can be inserted to UI root nodes and propagate to children to specify which camera it should render onto. This is then used to get the render target and the viewport for that UI tree. Since this component is optional, the default behavior should be to render onto the single camera (if only one exist) and warn of ambiguity if multiple cameras exist. This reduces the complexity for users with just one camera, while giving control in contexts where it matters. ## Changelog - Adds `TargetCamera(Entity)` component to specify which camera should a node tree be rendered into. If only one camera exists, this component is optional. - Adds an example of rendering UI to a texture and using it as a material in a 3D world. - Fixes recalculation of physical viewport size when target scale factor changes. This can happen when the window is moved between displays with different DPI. - Changes examples to demonstrate assigning UI to different viewports and windows and make interactions in an offset viewport testable. - Removes `UiCameraConfig`. UI visibility now can be controlled via combination of explicit `TargetCamera` and `Visibility` on the root nodes. --------- Co-authored-by: davier <[email protected]> Co-authored-by: Alice Cecile <[email protected]> Co-authored-by: Alice Cecile <[email protected]>
1 parent ee9a150 commit eb9db21

16 files changed

+872
-296
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2249,6 +2249,17 @@ description = "Showcases the RelativeCursorPosition component"
22492249
category = "UI (User Interface)"
22502250
wasm = true
22512251

2252+
[[example]]
2253+
name = "render_ui_to_texture"
2254+
path = "examples/ui/render_ui_to_texture.rs"
2255+
doc-scrape-examples = true
2256+
2257+
[package.metadata.example.render_ui_to_texture]
2258+
name = "Render UI to Texture"
2259+
description = "An example of rendering UI as a part of a 3D world"
2260+
category = "UI (User Interface)"
2261+
wasm = true
2262+
22522263
[[example]]
22532264
name = "size_constraints"
22542265
path = "examples/ui/size_constraints.rs"

crates/bevy_render/src/camera/camera.rs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use bevy_transform::components::GlobalTransform;
2828
use bevy_utils::{HashMap, HashSet};
2929
use bevy_window::{
3030
NormalizedWindowRef, PrimaryWindow, Window, WindowCreated, WindowRef, WindowResized,
31+
WindowScaleFactorChanged,
3132
};
3233
use std::{borrow::Cow, ops::Range};
3334
use wgpu::{BlendState, LoadOp, TextureFormat};
@@ -79,7 +80,7 @@ pub struct RenderTargetInfo {
7980
pub struct ComputedCameraValues {
8081
projection_matrix: Mat4,
8182
target_info: Option<RenderTargetInfo>,
82-
// position and size of the `Viewport`
83+
// size of the `Viewport`
8384
old_viewport_size: Option<UVec2>,
8485
}
8586

@@ -229,6 +230,11 @@ impl Camera {
229230
self.computed.target_info.as_ref().map(|t| t.physical_size)
230231
}
231232

233+
#[inline]
234+
pub fn target_scaling_factor(&self) -> Option<f32> {
235+
self.computed.target_info.as_ref().map(|t| t.scale_factor)
236+
}
237+
232238
/// The projection matrix computed using this camera's [`CameraProjection`].
233239
#[inline]
234240
pub fn projection_matrix(&self) -> Mat4 {
@@ -575,9 +581,9 @@ impl NormalizedRenderTarget {
575581

576582
/// System in charge of updating a [`Camera`] when its window or projection changes.
577583
///
578-
/// The system detects window creation and resize events to update the camera projection if
579-
/// needed. It also queries any [`CameraProjection`] component associated with the same entity
580-
/// as the [`Camera`] one, to automatically update the camera projection matrix.
584+
/// The system detects window creation, resize, and scale factor change events to update the camera
585+
/// projection if needed. It also queries any [`CameraProjection`] component associated with the same
586+
/// entity as the [`Camera`] one, to automatically update the camera projection matrix.
581587
///
582588
/// The system function is generic over the camera projection type, and only instances of
583589
/// [`OrthographicProjection`] and [`PerspectiveProjection`] are automatically added to
@@ -595,6 +601,7 @@ impl NormalizedRenderTarget {
595601
pub fn camera_system<T: CameraProjection + Component>(
596602
mut window_resized_events: EventReader<WindowResized>,
597603
mut window_created_events: EventReader<WindowCreated>,
604+
mut window_scale_factor_changed_events: EventReader<WindowScaleFactorChanged>,
598605
mut image_asset_events: EventReader<AssetEvent<Image>>,
599606
primary_window: Query<Entity, With<PrimaryWindow>>,
600607
windows: Query<(Entity, &Window)>,
@@ -607,6 +614,11 @@ pub fn camera_system<T: CameraProjection + Component>(
607614
let mut changed_window_ids = HashSet::new();
608615
changed_window_ids.extend(window_created_events.read().map(|event| event.window));
609616
changed_window_ids.extend(window_resized_events.read().map(|event| event.window));
617+
let scale_factor_changed_window_ids: HashSet<_> = window_scale_factor_changed_events
618+
.read()
619+
.map(|event| event.window)
620+
.collect();
621+
changed_window_ids.extend(scale_factor_changed_window_ids.clone());
610622

611623
let changed_image_handles: HashSet<&AssetId<Image>> = image_asset_events
612624
.read()
@@ -617,7 +629,7 @@ pub fn camera_system<T: CameraProjection + Component>(
617629
.collect();
618630

619631
for (mut camera, mut camera_projection) in &mut cameras {
620-
let viewport_size = camera
632+
let mut viewport_size = camera
621633
.viewport
622634
.as_ref()
623635
.map(|viewport| viewport.physical_size);
@@ -628,11 +640,36 @@ pub fn camera_system<T: CameraProjection + Component>(
628640
|| camera_projection.is_changed()
629641
|| camera.computed.old_viewport_size != viewport_size
630642
{
631-
camera.computed.target_info = normalized_target.get_render_target_info(
643+
let new_computed_target_info = normalized_target.get_render_target_info(
632644
&windows,
633645
&images,
634646
&manual_texture_views,
635647
);
648+
// Check for the scale factor changing, and resize the viewport if needed.
649+
// This can happen when the window is moved between monitors with different DPIs.
650+
// Without this, the viewport will take a smaller portion of the window moved to
651+
// a higher DPI monitor.
652+
if normalized_target.is_changed(&scale_factor_changed_window_ids, &HashSet::new()) {
653+
if let (Some(new_scale_factor), Some(old_scale_factor)) = (
654+
new_computed_target_info
655+
.as_ref()
656+
.map(|info| info.scale_factor),
657+
camera
658+
.computed
659+
.target_info
660+
.as_ref()
661+
.map(|info| info.scale_factor),
662+
) {
663+
let resize_factor = new_scale_factor / old_scale_factor;
664+
if let Some(ref mut viewport) = camera.viewport {
665+
let resize = |vec: UVec2| (vec.as_vec2() * resize_factor).as_uvec2();
666+
viewport.physical_position = resize(viewport.physical_position);
667+
viewport.physical_size = resize(viewport.physical_size);
668+
viewport_size = Some(viewport.physical_size);
669+
}
670+
}
671+
}
672+
camera.computed.target_info = new_computed_target_info;
636673
if let Some(size) = camera.logical_viewport_size() {
637674
camera_projection.update(size.x, size.y);
638675
camera.computed.projection_matrix = camera_projection.get_projection_matrix();

crates/bevy_ui/src/camera_config.rs

Lines changed: 0 additions & 30 deletions
This file was deleted.

crates/bevy_ui/src/focus.rs

Lines changed: 74 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{camera_config::UiCameraConfig, CalculatedClip, Node, UiScale, UiStack};
1+
use crate::{CalculatedClip, DefaultUiCamera, Node, TargetCamera, UiScale, UiStack};
22
use bevy_ecs::{
33
change_detection::DetectChangesMut,
44
entity::Entity,
@@ -13,7 +13,7 @@ use bevy_reflect::{std_traits::ReflectDefault, Reflect};
1313
use bevy_render::{camera::NormalizedRenderTarget, prelude::Camera, view::ViewVisibility};
1414
use bevy_transform::components::GlobalTransform;
1515

16-
use bevy_utils::smallvec::SmallVec;
16+
use bevy_utils::{smallvec::SmallVec, HashMap};
1717
use bevy_window::{PrimaryWindow, Window};
1818

1919
#[cfg(feature = "serialize")]
@@ -133,6 +133,7 @@ pub struct NodeQuery {
133133
focus_policy: Option<&'static FocusPolicy>,
134134
calculated_clip: Option<&'static CalculatedClip>,
135135
view_visibility: Option<&'static ViewVisibility>,
136+
target_camera: Option<&'static TargetCamera>,
136137
}
137138

138139
/// The system that sets Interaction for all UI elements based on the mouse cursor activity
@@ -141,14 +142,15 @@ pub struct NodeQuery {
141142
#[allow(clippy::too_many_arguments)]
142143
pub fn ui_focus_system(
143144
mut state: Local<State>,
144-
camera: Query<(&Camera, Option<&UiCameraConfig>)>,
145+
camera_query: Query<(Entity, &Camera)>,
146+
default_ui_camera: DefaultUiCamera,
147+
primary_window: Query<Entity, With<PrimaryWindow>>,
145148
windows: Query<&Window>,
146149
mouse_button_input: Res<ButtonInput<MouseButton>>,
147150
touches_input: Res<Touches>,
148151
ui_scale: Res<UiScale>,
149152
ui_stack: Res<UiStack>,
150153
mut node_query: Query<NodeQuery>,
151-
primary_window: Query<Entity, With<PrimaryWindow>>,
152154
) {
153155
let primary_window = primary_window.iter().next();
154156

@@ -174,31 +176,31 @@ pub fn ui_focus_system(
174176
let mouse_clicked =
175177
mouse_button_input.just_pressed(MouseButton::Left) || touches_input.any_just_pressed();
176178

177-
let is_ui_disabled =
178-
|camera_ui| matches!(camera_ui, Some(&UiCameraConfig { show_ui: false, .. }));
179-
180-
let cursor_position = camera
179+
let camera_cursor_positions: HashMap<Entity, Vec2> = camera_query
181180
.iter()
182-
.filter(|(_, camera_ui)| !is_ui_disabled(*camera_ui))
183-
.filter_map(|(camera, _)| {
184-
if let Some(NormalizedRenderTarget::Window(window_ref)) =
181+
.filter_map(|(entity, camera)| {
182+
// Interactions are only supported for cameras rendering to a window.
183+
let Some(NormalizedRenderTarget::Window(window_ref)) =
185184
camera.target.normalize(primary_window)
186-
{
187-
Some(window_ref)
188-
} else {
189-
None
190-
}
191-
})
192-
.find_map(|window_ref| {
185+
else {
186+
return None;
187+
};
188+
189+
let viewport_position = camera
190+
.logical_viewport_rect()
191+
.map(|rect| rect.min)
192+
.unwrap_or_default();
193193
windows
194194
.get(window_ref.entity())
195195
.ok()
196196
.and_then(|window| window.cursor_position())
197+
.or_else(|| touches_input.first_pressed_position())
198+
.map(|cursor_position| (entity, cursor_position - viewport_position))
197199
})
198-
.or_else(|| touches_input.first_pressed_position())
199200
// The cursor position returned by `Window` only takes into account the window scale factor and not `UiScale`.
200201
// To convert the cursor position to logical UI viewport coordinates we have to divide it by `UiScale`.
201-
.map(|cursor_position| cursor_position / ui_scale.0);
202+
.map(|(entity, cursor_position)| (entity, cursor_position / ui_scale.0))
203+
.collect();
202204

203205
// prepare an iterator that contains all the nodes that have the cursor in their rect,
204206
// from the top node to the bottom one. this will also reset the interaction to `None`
@@ -209,61 +211,69 @@ pub fn ui_focus_system(
209211
// reverse the iterator to traverse the tree from closest nodes to furthest
210212
.rev()
211213
.filter_map(|entity| {
212-
if let Ok(node) = node_query.get_mut(*entity) {
213-
// Nodes that are not rendered should not be interactable
214-
if let Some(view_visibility) = node.view_visibility {
215-
if !view_visibility.get() {
216-
// Reset their interaction to None to avoid strange stuck state
217-
if let Some(mut interaction) = node.interaction {
218-
// We cannot simply set the interaction to None, as that will trigger change detection repeatedly
219-
interaction.set_if_neq(Interaction::None);
220-
}
214+
let Ok(node) = node_query.get_mut(*entity) else {
215+
return None;
216+
};
221217

222-
return None;
223-
}
218+
let Some(view_visibility) = node.view_visibility else {
219+
return None;
220+
};
221+
// Nodes that are not rendered should not be interactable
222+
if !view_visibility.get() {
223+
// Reset their interaction to None to avoid strange stuck state
224+
if let Some(mut interaction) = node.interaction {
225+
// We cannot simply set the interaction to None, as that will trigger change detection repeatedly
226+
interaction.set_if_neq(Interaction::None);
224227
}
228+
return None;
229+
}
230+
let Some(camera_entity) = node
231+
.target_camera
232+
.map(TargetCamera::entity)
233+
.or(default_ui_camera.get())
234+
else {
235+
return None;
236+
};
225237

226-
let node_rect = node.node.logical_rect(node.global_transform);
238+
let node_rect = node.node.logical_rect(node.global_transform);
227239

228-
// Intersect with the calculated clip rect to find the bounds of the visible region of the node
229-
let visible_rect = node
230-
.calculated_clip
231-
.map(|clip| node_rect.intersect(clip.clip))
232-
.unwrap_or(node_rect);
240+
// Intersect with the calculated clip rect to find the bounds of the visible region of the node
241+
let visible_rect = node
242+
.calculated_clip
243+
.map(|clip| node_rect.intersect(clip.clip))
244+
.unwrap_or(node_rect);
233245

234-
// The mouse position relative to the node
235-
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner
236-
// Coordinates are relative to the entire node, not just the visible region.
237-
let relative_cursor_position = cursor_position
238-
.map(|cursor_position| (cursor_position - node_rect.min) / node_rect.size());
246+
// The mouse position relative to the node
247+
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner
248+
// Coordinates are relative to the entire node, not just the visible region.
249+
let relative_cursor_position = camera_cursor_positions
250+
.get(&camera_entity)
251+
.map(|cursor_position| (*cursor_position - node_rect.min) / node_rect.size());
239252

240-
// If the current cursor position is within the bounds of the node's visible area, consider it for
241-
// clicking
242-
let relative_cursor_position_component = RelativeCursorPosition {
243-
normalized_visible_node_rect: visible_rect.normalize(node_rect),
244-
normalized: relative_cursor_position,
245-
};
253+
// If the current cursor position is within the bounds of the node's visible area, consider it for
254+
// clicking
255+
let relative_cursor_position_component = RelativeCursorPosition {
256+
normalized_visible_node_rect: visible_rect.normalize(node_rect),
257+
normalized: relative_cursor_position,
258+
};
246259

247-
let contains_cursor = relative_cursor_position_component.mouse_over();
260+
let contains_cursor = relative_cursor_position_component.mouse_over();
248261

249-
// Save the relative cursor position to the correct component
250-
if let Some(mut node_relative_cursor_position_component) =
251-
node.relative_cursor_position
252-
{
253-
*node_relative_cursor_position_component = relative_cursor_position_component;
254-
}
262+
// Save the relative cursor position to the correct component
263+
if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position
264+
{
265+
*node_relative_cursor_position_component = relative_cursor_position_component;
266+
}
255267

256-
if contains_cursor {
257-
Some(*entity)
258-
} else {
259-
if let Some(mut interaction) = node.interaction {
260-
if *interaction == Interaction::Hovered || (cursor_position.is_none()) {
261-
interaction.set_if_neq(Interaction::None);
262-
}
268+
if contains_cursor {
269+
Some(*entity)
270+
} else {
271+
if let Some(mut interaction) = node.interaction {
272+
if *interaction == Interaction::Hovered || (relative_cursor_position.is_none())
273+
{
274+
interaction.set_if_neq(Interaction::None);
263275
}
264-
None
265276
}
266-
} else {
267277
None
268278
}
269279
})

crates/bevy_ui/src/layout/debug.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub fn print_ui_layout_tree(ui_surface: &UiSurface) {
1212
.iter()
1313
.map(|(entity, node)| (*node, *entity))
1414
.collect();
15-
for (&entity, roots) in &ui_surface.window_roots {
15+
for (&entity, roots) in &ui_surface.camera_roots {
1616
let mut out = String::new();
1717
for root in roots {
1818
print_node(
@@ -25,7 +25,7 @@ pub fn print_ui_layout_tree(ui_surface: &UiSurface) {
2525
&mut out,
2626
);
2727
}
28-
bevy_log::info!("Layout tree for window entity: {entity:?}\n{out}");
28+
bevy_log::info!("Layout tree for camera entity: {entity:?}\n{out}");
2929
}
3030
}
3131

0 commit comments

Comments
 (0)