From 1af9bc853bd7d90d86d4162dcb53ed333d143cc4 Mon Sep 17 00:00:00 2001 From: Pablo Reinhardt <126117294+pablo-lua@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:11:06 -0300 Subject: [PATCH] Add a gizmo-based overlay to show UI node outlines (Adopted) (#11237) # Objective - This is an adopted version of #10420 - The objective is to help debugging the Ui layout tree with helpful outlines, that can be easily enabled/disabled ## Solution - Like #10420, the solution is using the bevy_gizmos in outlining the nodes --- ## Changelog ### Added - Added debug_overlay mod to `bevy_dev_tools` - Added bevy_ui_debug feature to `bevy_dev_tools` ## How to use - The user must use `bevy_dev_tools` feature in TOML - The user must use the plugin UiDebugPlugin, that can be found on `bevy::dev_tools::debug_overlay` - Finally, to enable the function, the user must set `UiDebugOptions::enabled` to true Someone can easily toggle the function with something like: ```rust fn toggle_overlay(input: Res>, options: ResMut) { if input.just_pressed(KeyCode::Space) { // The toggle method will enable if disabled and disable if enabled options.toggle(); } } ``` Note that this feature can be disabled from dev_tools, as its in fact behind a default feature there, being the feature bevy_ui_debug. # Limitations Currently, due to limitations with gizmos itself, it's not possible to support this feature to more the one window, so this tool is limited to the primary window only. # Showcase ![image](https://github.com/bevyengine/bevy/assets/126117294/ce9d70e6-0a57-4fa9-9753-ff5a9d82c009) Ui example with debug_overlay enabled ![image](https://github.com/bevyengine/bevy/assets/126117294/e945015c-5bab-4d7f-9273-472aabaf25a9) And disabled --------- Co-authored-by: Nicola Papale Co-authored-by: Pablo Reinhardt Co-authored-by: Alice Cecile --- crates/bevy_dev_tools/Cargo.toml | 21 +- .../bevy_dev_tools/src/debug_overlay/inset.rs | 192 ++++++++++++ .../bevy_dev_tools/src/debug_overlay/mod.rs | 280 ++++++++++++++++++ crates/bevy_dev_tools/src/lib.rs | 3 + examples/ui/ui.rs | 58 +++- 5 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 crates/bevy_dev_tools/src/debug_overlay/inset.rs create mode 100644 crates/bevy_dev_tools/src/debug_overlay/mod.rs diff --git a/crates/bevy_dev_tools/Cargo.toml b/crates/bevy_dev_tools/Cargo.toml index 7be2541583148..62ab2c8fcc8ae 100644 --- a/crates/bevy_dev_tools/Cargo.toml +++ b/crates/bevy_dev_tools/Cargo.toml @@ -9,22 +9,31 @@ license = "MIT OR Apache-2.0" keywords = ["bevy"] [features] +default = ["bevy_ui_debug"] bevy_ci_testing = ["serde", "ron"] +bevy_ui_debug = [] [dependencies] # bevy bevy_app = { path = "../bevy_app", version = "0.14.0-dev" } -bevy_utils = { path = "../bevy_utils", version = "0.14.0-dev" } +bevy_asset = { path = "../bevy_asset", version = "0.14.0-dev" } +bevy_color = { path = "../bevy_color", version = "0.14.0-dev" } +bevy_core = { path = "../bevy_core", version = "0.14.0-dev" } +bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.14.0-dev" } +bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.14.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.14.0-dev" } +bevy_gizmos = { path = "../bevy_gizmos", version = "0.14.0-dev" } +bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.14.0-dev" } +bevy_input = { path = "../bevy_input", version = "0.14.0-dev" } +bevy_math = { path = "../bevy_math", version = "0.14.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.14.0-dev" } bevy_render = { path = "../bevy_render", version = "0.14.0-dev" } bevy_time = { path = "../bevy_time", version = "0.14.0-dev" } -bevy_window = { path = "../bevy_window", version = "0.14.0-dev" } -bevy_asset = { path = "../bevy_asset", version = "0.14.0-dev" } +bevy_transform = { path = "../bevy_transform", version = "0.14.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.14.0-dev" } +bevy_utils = { path = "../bevy_utils", version = "0.14.0-dev" } +bevy_window = { path = "../bevy_window", version = "0.14.0-dev" } bevy_text = { path = "../bevy_text", version = "0.14.0-dev" } -bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.14.0-dev" } -bevy_color = { path = "../bevy_color", version = "0.14.0-dev" } -bevy_input = { path = "../bevy_input", version = "0.14.0-dev" } # other serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/crates/bevy_dev_tools/src/debug_overlay/inset.rs b/crates/bevy_dev_tools/src/debug_overlay/inset.rs new file mode 100644 index 0000000000000..86be2146c73d7 --- /dev/null +++ b/crates/bevy_dev_tools/src/debug_overlay/inset.rs @@ -0,0 +1,192 @@ +use bevy_color::Color; +use bevy_gizmos::{config::GizmoConfigGroup, prelude::Gizmos}; +use bevy_math::{Vec2, Vec2Swizzles}; +use bevy_reflect::Reflect; +use bevy_transform::prelude::GlobalTransform; +use bevy_utils::HashMap; + +use super::{CameraQuery, LayoutRect}; + +// Function used here so we don't need to redraw lines that are fairly close to each other. +fn approx_eq(compared: f32, other: f32) -> bool { + (compared - other).abs() < 0.001 +} + +fn rect_border_axis(rect: LayoutRect) -> (f32, f32, f32, f32) { + let pos = rect.pos; + let size = rect.size; + let offset = pos + size; + (pos.x, offset.x, pos.y, offset.y) +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)] +enum Dir { + Start, + End, +} +impl Dir { + const fn increments(self) -> i64 { + match self { + Dir::Start => 1, + Dir::End => -1, + } + } +} +impl From for Dir { + fn from(value: i64) -> Self { + if value.is_positive() { + Dir::Start + } else { + Dir::End + } + } +} +/// Collection of axis aligned "lines" (actually just their coordinate on +/// a given axis). +#[derive(Debug, Clone)] +struct DrawnLines { + lines: HashMap, + width: f32, +} +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] +impl DrawnLines { + fn new(width: f32) -> Self { + DrawnLines { + lines: HashMap::new(), + width, + } + } + /// Return `value` offset by as many `increment`s as necessary to make it + /// not overlap with already drawn lines. + fn inset(&self, value: f32) -> f32 { + let scaled = value / self.width; + let fract = scaled.fract(); + let mut on_grid = scaled.floor() as i64; + for _ in 0..10 { + let Some(dir) = self.lines.get(&on_grid) else { + break; + }; + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(added) = on_grid.checked_add(dir.increments()) else { + break; + }; + on_grid = added; + } + ((on_grid as f32) + fract) * self.width + } + /// Remove a line from the collection of drawn lines. + /// + /// Typically, we only care for pre-existing lines when drawing the children + /// of a container, nothing more. So we remove it after we are done with + /// the children. + fn remove(&mut self, value: f32, increment: i64) { + let mut on_grid = (value / self.width).floor() as i64; + loop { + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(next_cell) = on_grid.checked_add(increment) else { + return; + }; + if !self.lines.contains_key(&next_cell) { + self.lines.remove(&on_grid); + return; + } + on_grid = next_cell; + } + } + /// Add a line from the collection of drawn lines. + fn add(&mut self, value: f32, increment: i64) { + let mut on_grid = (value / self.width).floor() as i64; + loop { + let old_value = self.lines.insert(on_grid, increment.into()); + if old_value.is_none() { + return; + } + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(added) = on_grid.checked_add(increment) else { + return; + }; + on_grid = added; + } + } +} + +#[derive(GizmoConfigGroup, Reflect, Default)] +pub struct UiGizmosDebug; + +pub(super) struct InsetGizmo<'w, 's> { + draw: Gizmos<'w, 's, UiGizmosDebug>, + cam: CameraQuery<'w, 's>, + known_y: DrawnLines, + known_x: DrawnLines, +} +impl<'w, 's> InsetGizmo<'w, 's> { + pub(super) fn new( + draw: Gizmos<'w, 's, UiGizmosDebug>, + cam: CameraQuery<'w, 's>, + line_width: f32, + ) -> Self { + InsetGizmo { + draw, + cam, + known_y: DrawnLines::new(line_width), + known_x: DrawnLines::new(line_width), + } + } + fn relative(&self, mut position: Vec2) -> Vec2 { + let zero = GlobalTransform::IDENTITY; + let Ok(cam) = self.cam.get_single() else { + return Vec2::ZERO; + }; + if let Some(new_position) = cam.world_to_viewport(&zero, position.extend(0.)) { + position = new_position; + }; + position.xy() + } + fn line_2d(&mut self, mut start: Vec2, mut end: Vec2, color: Color) { + if approx_eq(start.x, end.x) { + start.x = self.known_x.inset(start.x); + end.x = start.x; + } else if approx_eq(start.y, end.y) { + start.y = self.known_y.inset(start.y); + end.y = start.y; + } + let (start, end) = (self.relative(start), self.relative(end)); + self.draw.line_2d(start, end, color); + } + pub(super) fn set_scope(&mut self, rect: LayoutRect) { + let (left, right, top, bottom) = rect_border_axis(rect); + self.known_x.add(left, 1); + self.known_x.add(right, -1); + self.known_y.add(top, 1); + self.known_y.add(bottom, -1); + } + pub(super) fn clear_scope(&mut self, rect: LayoutRect) { + let (left, right, top, bottom) = rect_border_axis(rect); + self.known_x.remove(left, 1); + self.known_x.remove(right, -1); + self.known_y.remove(top, 1); + self.known_y.remove(bottom, -1); + } + pub(super) fn rect_2d(&mut self, rect: LayoutRect, color: Color) { + let (left, right, top, bottom) = rect_border_axis(rect); + if approx_eq(left, right) { + self.line_2d(Vec2::new(left, top), Vec2::new(left, bottom), color); + } else if approx_eq(top, bottom) { + self.line_2d(Vec2::new(left, top), Vec2::new(right, top), color); + } else { + let inset_x = |v| self.known_x.inset(v); + let inset_y = |v| self.known_y.inset(v); + let (left, right) = (inset_x(left), inset_x(right)); + let (top, bottom) = (inset_y(top), inset_y(bottom)); + let strip = [ + Vec2::new(left, top), + Vec2::new(left, bottom), + Vec2::new(right, bottom), + Vec2::new(right, top), + Vec2::new(left, top), + ]; + self.draw + .linestrip_2d(strip.map(|v| self.relative(v)), color); + } + } +} diff --git a/crates/bevy_dev_tools/src/debug_overlay/mod.rs b/crates/bevy_dev_tools/src/debug_overlay/mod.rs new file mode 100644 index 0000000000000..952539850638d --- /dev/null +++ b/crates/bevy_dev_tools/src/debug_overlay/mod.rs @@ -0,0 +1,280 @@ +//! A visual representation of UI node sizes. +use std::any::{Any, TypeId}; + +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_color::Hsla; +use bevy_core::Name; +use bevy_core_pipeline::core_2d::Camera2dBundle; +use bevy_ecs::{prelude::*, system::SystemParam}; +use bevy_gizmos::{config::GizmoConfigStore, prelude::Gizmos, AppGizmoBuilder}; +use bevy_hierarchy::{Children, Parent}; +use bevy_math::{Vec2, Vec3Swizzles}; +use bevy_render::{ + camera::RenderTarget, + prelude::*, + view::{RenderLayers, VisibilitySystems}, +}; +use bevy_transform::{prelude::GlobalTransform, TransformSystem}; +use bevy_ui::{DefaultUiCamera, Display, Node, Style, TargetCamera, UiScale}; +use bevy_utils::{default, warn_once}; +use bevy_window::{PrimaryWindow, Window, WindowRef}; + +use inset::InsetGizmo; + +use self::inset::UiGizmosDebug; + +mod inset; + +/// The [`Camera::order`] index used by the layout debug camera. +pub const LAYOUT_DEBUG_CAMERA_ORDER: isize = 255; +/// The [`RenderLayers`] used by the debug gizmos and the debug camera. +pub const LAYOUT_DEBUG_LAYERS: RenderLayers = RenderLayers::none().with(16); + +#[derive(Clone, Copy)] +struct LayoutRect { + pos: Vec2, + size: Vec2, +} + +impl LayoutRect { + fn new(trans: &GlobalTransform, node: &Node, scale: f32) -> Self { + let mut this = Self { + pos: trans.translation().xy() * scale, + size: node.size() * scale, + }; + this.pos -= this.size / 2.; + this + } +} + +#[derive(Component, Debug, Clone, Default)] +struct DebugOverlayCamera; + +/// The debug overlay options. +#[derive(Resource, Clone, Default)] +pub struct UiDebugOptions { + /// Whether the overlay is enabled. + pub enabled: bool, + layout_gizmos_camera: Option, +} +impl UiDebugOptions { + /// This will toggle the enabled field, setting it to false if true and true if false. + pub fn toggle(&mut self) { + self.enabled = !self.enabled; + } +} + +/// The system responsible to change the [`Camera`] config based on changes in [`UiDebugOptions`] and [`GizmoConfig`](bevy_gizmos::prelude::GizmoConfig). +fn update_debug_camera( + mut gizmo_config: ResMut, + mut options: ResMut, + mut cmds: Commands, + mut debug_cams: Query<&mut Camera, With>, +) { + if !options.is_changed() && !gizmo_config.is_changed() { + return; + } + if !options.enabled { + let Some(cam) = options.layout_gizmos_camera else { + return; + }; + let Ok(mut cam) = debug_cams.get_mut(cam) else { + return; + }; + cam.is_active = false; + if let Some((config, _)) = gizmo_config.get_config_mut_dyn(&TypeId::of::()) { + config.enabled = false; + } + } else { + let spawn_cam = || { + cmds.spawn(( + Camera2dBundle { + projection: OrthographicProjection { + far: 1000.0, + viewport_origin: Vec2::new(0.0, 0.0), + ..default() + }, + camera: Camera { + order: LAYOUT_DEBUG_CAMERA_ORDER, + clear_color: ClearColorConfig::None, + ..default() + }, + ..default() + }, + LAYOUT_DEBUG_LAYERS, + DebugOverlayCamera, + Name::new("Layout Debug Camera"), + )) + .id() + }; + if let Some((config, _)) = gizmo_config.get_config_mut_dyn(&TypeId::of::()) { + config.enabled = true; + config.render_layers = LAYOUT_DEBUG_LAYERS; + } + let cam = *options.layout_gizmos_camera.get_or_insert_with(spawn_cam); + let Ok(mut cam) = debug_cams.get_mut(cam) else { + return; + }; + cam.is_active = true; + } +} + +/// The function that goes over every children of given [`Entity`], skipping the not visible ones and drawing the gizmos outlines. +fn outline_nodes(outline: &OutlineParam, draw: &mut InsetGizmo, this_entity: Entity, scale: f32) { + let Ok(to_iter) = outline.children.get(this_entity) else { + return; + }; + + for (entity, trans, node, style, children) in outline.nodes.iter_many(to_iter) { + if style.is_none() || style.is_some_and(|s| matches!(s.display, Display::None)) { + continue; + } + + if let Ok(view_visibility) = outline.view_visibility.get(entity) { + if !view_visibility.get() { + continue; + } + } + let rect = LayoutRect::new(trans, node, scale); + outline_node(entity, rect, draw); + if children.is_some() { + outline_nodes(outline, draw, entity, scale); + } + draw.clear_scope(rect); + } +} + +type NodesQuery = ( + Entity, + &'static GlobalTransform, + &'static Node, + Option<&'static Style>, + Option<&'static Children>, +); + +#[derive(SystemParam)] +struct OutlineParam<'w, 's> { + gizmo_config: Res<'w, GizmoConfigStore>, + children: Query<'w, 's, &'static Children>, + nodes: Query<'w, 's, NodesQuery>, + view_visibility: Query<'w, 's, &'static ViewVisibility>, + ui_scale: Res<'w, UiScale>, +} + +type CameraQuery<'w, 's> = Query<'w, 's, &'static Camera, With>; + +#[derive(SystemParam)] +struct CameraParam<'w, 's> { + debug_camera: Query<'w, 's, &'static Camera, With>, + cameras: Query<'w, 's, &'static Camera, Without>, + primary_window: Query<'w, 's, &'static Window, With>, + default_ui_camera: DefaultUiCamera<'w, 's>, +} + +/// system responsible for drawing the gizmos lines around all the node roots, iterating recursively through all visible children. +fn outline_roots( + outline: OutlineParam, + draw: Gizmos, + cam: CameraParam, + roots: Query< + ( + Entity, + &GlobalTransform, + &Node, + Option<&ViewVisibility>, + Option<&TargetCamera>, + ), + Without, + >, + window: Query<&Window, With>, + nonprimary_windows: Query<&Window, Without>, + options: Res, +) { + if !options.enabled { + return; + } + if !nonprimary_windows.is_empty() { + warn_once!( + "The layout debug view only uses the primary window scale, \ + you might notice gaps between container lines" + ); + } + let window_scale = window.get_single().map_or(1., Window::scale_factor); + let scale_factor = window_scale * outline.ui_scale.0; + + // We let the line be defined by the window scale alone + let line_width = outline + .gizmo_config + .get_config_dyn(&UiGizmosDebug.type_id()) + .map_or(2., |(config, _)| config.line_width) + / window_scale; + let mut draw = InsetGizmo::new(draw, cam.debug_camera, line_width); + for (entity, trans, node, view_visibility, maybe_target_camera) in &roots { + if let Some(view_visibility) = view_visibility { + // If the entity isn't visible, we will not draw any lines. + if !view_visibility.get() { + continue; + } + } + // We skip ui in other windows that are not the primary one + if let Some(camera_entity) = maybe_target_camera + .map(|target| target.0) + .or(cam.default_ui_camera.get()) + { + let Ok(camera) = cam.cameras.get(camera_entity) else { + // The camera wasn't found. Either the Camera don't exist or the Camera is the debug Camera, that we want to skip and warn + warn_once!("Camera {:?} wasn't found for debug overlay", camera_entity); + continue; + }; + match camera.target { + RenderTarget::Window(window_ref) => { + if let WindowRef::Entity(window_entity) = window_ref { + if cam.primary_window.get(window_entity).is_err() { + // This window isn't the primary, so we skip this root. + continue; + } + } + } + // Hard to know the results of this, better skip this target. + _ => continue, + } + } + + let rect = LayoutRect::new(trans, node, scale_factor); + outline_node(entity, rect, &mut draw); + outline_nodes(&outline, &mut draw, entity, scale_factor); + } +} + +/// Function responsible for drawing the gizmos lines around the given Entity +fn outline_node(entity: Entity, rect: LayoutRect, draw: &mut InsetGizmo) { + let color = Hsla::sequential_dispersed(entity.index()); + + draw.rect_2d(rect, color.into()); + draw.set_scope(rect); +} + +/// The debug overlay plugin. +/// +/// This spawns a new camera with a low order, and draws gizmo. +/// +/// Note that due to limitation with [`bevy_gizmos`], multiple windows with this feature +/// enabled isn't supported and the lines are only drawn in the [`PrimaryWindow`] +pub struct DebugUiPlugin; +impl Plugin for DebugUiPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_gizmo_group::() + .add_systems( + PostUpdate, + ( + update_debug_camera, + outline_roots + .after(TransformSystem::TransformPropagate) + // This needs to run before VisibilityPropagate so it can relies on ViewVisibility + .before(VisibilitySystems::VisibilityPropagate), + ) + .chain(), + ); + } +} diff --git a/crates/bevy_dev_tools/src/lib.rs b/crates/bevy_dev_tools/src/lib.rs index adad8cec9030b..8bb9b0b7d2b98 100644 --- a/crates/bevy_dev_tools/src/lib.rs +++ b/crates/bevy_dev_tools/src/lib.rs @@ -8,6 +8,9 @@ use bevy_app::prelude::*; pub mod ci_testing; pub mod fps_overlay; +#[cfg(feature = "bevy_ui_debug")] +pub mod debug_overlay; + /// Enables developer tools in an [`App`]. This plugin is added automatically with `bevy_dev_tools` /// feature. /// diff --git a/examples/ui/ui.rs b/examples/ui/ui.rs index 3a314237af2a7..585d69ee389d0 100644 --- a/examples/ui/ui.rs +++ b/examples/ui/ui.rs @@ -12,18 +12,25 @@ use bevy::{ }; fn main() { - App::new() - .add_plugins(DefaultPlugins) + let mut app = App::new(); + app.add_plugins(DefaultPlugins) // Only run the app when there is user input. This will significantly reduce CPU/GPU use. .insert_resource(WinitSettings::desktop_app()) .add_systems(Startup, setup) - .add_systems(Update, mouse_scroll) - .run(); + .add_systems(Update, mouse_scroll); + + #[cfg(feature = "bevy_dev_tools")] + { + app.add_plugins(bevy::dev_tools::debug_overlay::DebugUiPlugin) + .add_systems(Update, toggle_overlay); + } + + app.run(); } fn setup(mut commands: Commands, asset_server: Res) { // Camera - commands.spawn(Camera2dBundle::default()); + commands.spawn((Camera2dBundle::default(), IsDefaultUiCamera)); // root node commands @@ -54,6 +61,7 @@ fn setup(mut commands: Commands, asset_server: Res) { .spawn(NodeBundle { style: Style { width: Val::Percent(100.), + flex_direction: FlexDirection::Column, ..default() }, background_color: Color::srgb(0.15, 0.15, 0.15).into(), @@ -79,6 +87,33 @@ fn setup(mut commands: Commands, asset_server: Res) { // for accessibility to treat the text accordingly. Label, )); + + #[cfg(feature = "bevy_dev_tools")] + // Debug overlay text + parent.spawn(( + TextBundle::from_section( + "Press Space to enable debug outlines.", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 20., + ..Default::default() + }, + ), + Label, + )); + + #[cfg(not(feature = "bevy_dev_tools"))] + parent.spawn(( + TextBundle::from_section( + "Try enabling feature \"bevy_dev_tools\".", + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 20., + ..Default::default() + }, + ), + Label, + )); }); }); // right vertical fill @@ -334,3 +369,16 @@ fn mouse_scroll( } } } + +#[cfg(feature = "bevy_dev_tools")] +// The system that will enable/disable the debug outlines around the nodes +fn toggle_overlay( + input: Res>, + mut options: ResMut, +) { + info_once!("The debug outlines are enabled, press Space to turn them on/off"); + if input.just_pressed(KeyCode::Space) { + // The toggle method will enable the debug_overlay if disabled and disable if enabled + options.toggle(); + } +}