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<ButtonInput<KeyCode>>, options: ResMut<UiDebugOptions>) {
   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 <nico@nicopap.ch>
Co-authored-by: Pablo Reinhardt <pabloreinhardt@gmail.com>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Pablo Reinhardt 2024-03-18 15:11:06 -03:00 committed by GitHub
parent 289a02cad6
commit 1af9bc853b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 543 additions and 11 deletions

View file

@ -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 }

View file

@ -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<i64> 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<i64, Dir>,
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);
}
}
}

View file

@ -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<Entity>,
}
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<GizmoConfigStore>,
mut options: ResMut<UiDebugOptions>,
mut cmds: Commands,
mut debug_cams: Query<&mut Camera, With<DebugOverlayCamera>>,
) {
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::<UiGizmosDebug>()) {
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::<UiGizmosDebug>()) {
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<DebugOverlayCamera>>;
#[derive(SystemParam)]
struct CameraParam<'w, 's> {
debug_camera: Query<'w, 's, &'static Camera, With<DebugOverlayCamera>>,
cameras: Query<'w, 's, &'static Camera, Without<DebugOverlayCamera>>,
primary_window: Query<'w, 's, &'static Window, With<PrimaryWindow>>,
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<UiGizmosDebug>,
cam: CameraParam,
roots: Query<
(
Entity,
&GlobalTransform,
&Node,
Option<&ViewVisibility>,
Option<&TargetCamera>,
),
Without<Parent>,
>,
window: Query<&Window, With<PrimaryWindow>>,
nonprimary_windows: Query<&Window, Without<PrimaryWindow>>,
options: Res<UiDebugOptions>,
) {
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::<UiDebugOptions>()
.init_gizmo_group::<UiGizmosDebug>()
.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(),
);
}
}

View file

@ -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.
///

View file

@ -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<AssetServer>) {
// 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<AssetServer>) {
.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<AssetServer>) {
// 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<ButtonInput<KeyCode>>,
mut options: ResMut<bevy::dev_tools::debug_overlay::UiDebugOptions>,
) {
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();
}
}