diff --git a/Cargo.toml b/Cargo.toml index 43d49df..6e696d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ bevy = { version = "0.13" } name = "print_render_graph" required-features = ["render_graph"] +[[example]] +name = "print_events_graph" + # [patch.crates-io] # bevy_ecs = { path = "/home/jakob/dev/rust/bevy/crates/bevy_ecs" } # bevy_app = { path = "/home/jakob/dev/rust/bevy/crates/bevy_app" } diff --git a/examples/print_events_graph.rs b/examples/print_events_graph.rs new file mode 100644 index 0000000..d182827 --- /dev/null +++ b/examples/print_events_graph.rs @@ -0,0 +1,8 @@ +use bevy::log::LogPlugin; +use bevy::prelude::*; + +fn main() { + let mut app = App::new(); + app.add_plugins(DefaultPlugins.build().disable::()); + bevy_mod_debugdump::print_events_graph(&mut app); +} diff --git a/src/event_graph/mod.rs b/src/event_graph/mod.rs new file mode 100644 index 0000000..61c08f1 --- /dev/null +++ b/src/event_graph/mod.rs @@ -0,0 +1,277 @@ +pub mod settings; +mod system_style; + +use std::sync::atomic::AtomicUsize; + +use bevy_ecs::{ + component::ComponentId, + schedule::{NodeId, Schedule, ScheduleLabel, Schedules}, + world::World, +}; +use bevy_utils::hashbrown::{HashMap, HashSet}; + +use crate::dot::DotGraph; +pub use settings::Settings; + +pub struct EventGraphContext<'a> { + settings: &'a Settings, + + events_tracked: HashSet, + event_readers: HashMap>, + event_writers: HashMap>, + schedule: Box, + + color_edge_idx: AtomicUsize, +} + +impl<'a> EventGraphContext<'a> { + fn next_edge_color(&self) -> &str { + use std::sync::atomic::Ordering; + let (Ok(idx) | Err(idx)) = + self.color_edge_idx + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |a| { + Some((a + 1) % self.settings.style.color_edge.len()) + }); + + &self.settings.style.color_edge[idx] + } + + fn add_system( + &self, + dot: &mut DotGraph, + system: &NodeId, + graph: &bevy_ecs::schedule::ScheduleGraph, + ) { + let sys = &graph.get_system_at(*system).unwrap(); + let name = &sys.name(); + + let node_style = self.settings.get_system_style(*sys); + dot.add_node( + name, + &[ + ("label", &display_name(name, self.settings)), + ("tooltip", name), + ("shape", "box"), + ("fillcolor", &node_style.bg_color), + ("fontname", &self.settings.style.fontname), + ("fontcolor", &node_style.text_color), + ("color", &node_style.border_color), + ("penwidth", &node_style.border_width.to_string()), + ], + ); + } + + fn add_event(&self, dot: &mut DotGraph, event: &ComponentId, world: &World) -> String { + let component = world.components().get_info(*event).unwrap(); + // Relevant name is only what's inside "bevy::ecs::Events<(...)>" + let full_name = component.name(); + let name = full_name.split_once('<').unwrap().1; + let name = &name[0..name.len() - 1]; + let event_id = format!("event_{0}", event.index()); + let node_style = self.settings.get_event_style(component); + dot.add_node( + &event_id, + &[ + ("label", &display_name(name, self.settings)), + ("tooltip", name), + ("shape", "ellipse"), + ("fillcolor", &node_style.bg_color), + ("fontname", &self.settings.style.fontname), + ("fontcolor", &node_style.text_color), + ("color", &node_style.border_color), + ("penwidth", &node_style.border_width.to_string()), + ], + ); + event_id + } +} + +/// Formats the events into a dot graph. +pub fn events_graph_dot<'a>( + schedule: &Schedule, + world: &World, + settings: &'a Settings, +) -> EventGraphContext<'a> { + let graph = schedule.graph(); + + let mut events_tracked = HashSet::new(); + let mut event_readers = HashMap::>::new(); + let mut event_writers = HashMap::>::new(); + for (system_id, system, _condition) in graph.systems() { + if let Some(include_system) = &settings.include_system { + if !(include_system)(system) { + continue; + } + } + let accesses = system.component_access(); + for access in accesses.reads() { + let component = world.components().get_info(access).unwrap(); + let name = component.name(); + if name.starts_with("bevy_ecs::event::Events") { + events_tracked.insert(access); + match event_readers.entry(access) { + bevy_utils::hashbrown::hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().push(system_id) + } + bevy_utils::hashbrown::hash_map::Entry::Vacant(vacant) => { + vacant.insert([system_id].into()); + } + } + } + } + for access in accesses.writes() { + let component = world.components().get_info(access).unwrap(); + let name = component.name(); + if name.starts_with("bevy_ecs::event::Events") { + events_tracked.insert(access); + match event_writers.entry(access) { + bevy_utils::hashbrown::hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().push(system_id) + } + bevy_utils::hashbrown::hash_map::Entry::Vacant(vacant) => { + vacant.insert([system_id].into()); + } + } + } + } + } + EventGraphContext { + settings, + events_tracked, + event_readers, + event_writers, + schedule: Box::new(schedule.label()), + color_edge_idx: AtomicUsize::new(0), + } +} + +pub fn print_only_context( + schedules: &bevy_ecs::schedule::Schedules, + events_dotgraph: &mut DotGraph, + schedules_dotgraph: &mut DotGraph, + ctx: &EventGraphContext, + other_ctxs: &[EventGraphContext], + world: &World, +) { + let schedule = schedules + .iter() + .find(|s| (*ctx.schedule).as_dyn_eq().dyn_eq(s.0.as_dyn_eq())) + .unwrap() + .1; + let graph = schedule.graph(); + { + let all_writers = ctx.event_writers.values().flatten(); + let all_readers = ctx.event_readers.values().flatten(); + + // Deduplicate systems + let all_systems = all_writers + .chain(all_readers) + .collect::>() + .into_iter() + .collect::>(); + + for s in all_systems { + ctx.add_system(schedules_dotgraph, s, graph); + } + } + for event in ctx.events_tracked.iter() { + let readers = ctx.event_readers.get(event).cloned().unwrap_or_default(); + let writers = ctx.event_writers.get(event).cloned().unwrap_or_default(); + + let graph_to_write_to = if other_ctxs + .iter() + .filter(|c| c.events_tracked.contains(event)) + .count() + > 1 + { + &mut *events_dotgraph + } else { + &mut *schedules_dotgraph + }; + + let event_id = ctx.add_event(graph_to_write_to, event, world); + + for writer in writers { + // We have to use full names, because nodeId is schedule specific, and I want to support multiple schedules displayed + let system_name = graph.get_system_at(writer).unwrap().name(); + graph_to_write_to.add_edge( + &system_name, + &event_id, + &[ + // TODO: customize edges, colors in a same fashion as schedules + /* + ("lhead", &self.lref(to)), + ("ltail", &self.lref(from)), + ("tooltip", &self.edge_tooltip(from, to)), + */ + ("color", ctx.next_edge_color()), + ], + ); + } + for reader in readers { + let system_name = graph.get_system_at(reader).unwrap().name(); + + graph_to_write_to.add_edge( + &event_id, + &system_name, + &[ + /* + ("lhead", &self.lref(to)), + ("ltail", &self.lref(from)), + ("tooltip", &self.edge_tooltip(from, to)), + */ + ("color", ctx.next_edge_color()), + ], + ); + } + } +} + +fn display_name(name: &str, settings: &Settings) -> String { + if settings.prettify_system_names { + pretty_type_name::pretty_type_name_str(name) + } else { + name.to_string() + } +} + +pub fn print_context( + schedules: &Schedules, + ctxs: &Vec, + world: &World, + settings: &Settings, +) -> String { + let mut dot = DotGraph::new( + "", + "digraph", + &[ + ("compound", "true"), // enable ltail/lhead + ("splines", settings.style.edge_style.as_dot()), + ("rankdir", settings.style.schedule_rankdir.as_dot()), + ("bgcolor", &settings.style.color_background), + ("fontname", &settings.style.fontname), + ("nodesep", "0.15"), + ], + ) + .edge_attributes(&[("penwidth", &format!("{}", settings.style.penwidth_edge))]) + .node_attributes(&[("shape", "box"), ("style", "filled")]); + + for ctx in ctxs { + let schedule_name = format!("{:?}", ctx.schedule); + let mut schedule_graph = DotGraph::subgraph( + &schedule_name, + &[ + ("style", "rounded,filled"), + ("label", &schedule_name), + ("tooltip", &schedule_name), + ("fillcolor", &settings.style.color_schedule), + ("fontcolor", &settings.style.color_schedule_label), + ("color", &settings.style.color_schedule_border), + ("penwidth", "2"), + ], + ); + print_only_context(schedules, &mut dot, &mut schedule_graph, ctx, ctxs, world); + dot.add_sub_graph(schedule_graph); + } + dot.finish().to_string() +} diff --git a/src/event_graph/settings.rs b/src/event_graph/settings.rs new file mode 100644 index 0000000..899a8ad --- /dev/null +++ b/src/event_graph/settings.rs @@ -0,0 +1,305 @@ +use bevy_app::{First, PostUpdate, PreUpdate, Update}; +use bevy_ecs::{ + component::ComponentInfo, + schedule::{Schedule, ScheduleLabel}, + system::System, +}; +use bevy_render::color::Color; + +use super::system_style::{ + color_to_hex, event_to_style, system_to_style, ComponentInfoStyle, SystemStyle, +}; + +#[derive(Default, Clone, Copy)] +pub enum RankDir { + TopDown, + #[default] + LeftRight, +} +impl RankDir { + pub(crate) fn as_dot(&self) -> &'static str { + match self { + RankDir::TopDown => "TD", + RankDir::LeftRight => "LR", + } + } +} + +#[derive(Default, Clone, Copy)] +pub enum EdgeStyle { + None, + Line, + Polyline, + Curved, + Ortho, + #[default] + Spline, +} +impl EdgeStyle { + pub fn as_dot(&self) -> &'static str { + match self { + EdgeStyle::None => "none", + EdgeStyle::Line => "line", + EdgeStyle::Polyline => "polyline", + EdgeStyle::Curved => "curved", + EdgeStyle::Ortho => "ortho", + EdgeStyle::Spline => "spline", + } + } +} + +#[derive(Clone)] +pub struct Style { + pub schedule_rankdir: RankDir, + pub edge_style: EdgeStyle, + + pub fontname: String, + + pub color_background: String, + pub color_schedule: String, + pub color_schedule_label: String, + pub color_schedule_border: String, + pub color_edge: Vec, + + pub penwidth_edge: f32, +} +// colors are from https://iamkate.com/data/12-bit-rainbow/, without the #cc6666 +impl Style { + pub fn light() -> Style { + Style { + schedule_rankdir: RankDir::default(), + edge_style: EdgeStyle::default(), + fontname: "Helvetica".into(), + color_background: "white".into(), + color_schedule: "#00000008".into(), + color_schedule_border: "#00000040".into(), + color_schedule_label: "#000000".into(), + color_edge: vec![ + "#eede00".into(), + "#881877".into(), + "#00b0cc".into(), + "#aa3a55".into(), + "#44d488".into(), + "#0090cc".into(), + "#ee9e44".into(), + "#663699".into(), + "#3363bb".into(), + "#22c2bb".into(), + "#99d955".into(), + ], + penwidth_edge: 2.0, + } + } + + pub fn dark_discord() -> Style { + Style { + schedule_rankdir: RankDir::default(), + edge_style: EdgeStyle::default(), + fontname: "Helvetica".into(), + color_background: "#35393f".into(), + color_schedule: "#ffffff44".into(), + color_schedule_border: "#ffffff50".into(), + color_schedule_label: "#ffffff".into(), + color_edge: vec![ + "#eede00".into(), + "#881877".into(), + "#00b0cc".into(), + "#aa3a55".into(), + "#44d488".into(), + "#0090cc".into(), + "#ee9e44".into(), + "#663699".into(), + "#3363bb".into(), + "#22c2bb".into(), + "#99d955".into(), + ], + penwidth_edge: 2.0, + } + } + + pub fn dark_github() -> Style { + Style { + schedule_rankdir: RankDir::default(), + edge_style: EdgeStyle::default(), + fontname: "Helvetica".into(), + color_background: "#0d1117".into(), + color_schedule: "#ffffff44".into(), + color_schedule_border: "#ffffff50".into(), + color_schedule_label: "#ffffff".into(), + color_edge: vec![ + "#eede00".into(), + "#881877".into(), + "#00b0cc".into(), + "#aa3a55".into(), + "#44d488".into(), + "#0090cc".into(), + "#ee9e44".into(), + "#663699".into(), + "#3363bb".into(), + "#22c2bb".into(), + "#99d955".into(), + ], + penwidth_edge: 2.0, + } + } +} +impl Default for Style { + fn default() -> Self { + Style::dark_github() + } +} + +pub struct NodeStyle { + pub bg_color: String, + pub text_color: String, + pub border_color: String, + pub border_width: String, +} + +// Function that maps `System` to `T` +type SystemMapperFn = Box) -> T>; + +// Function that maps `ComponentInfo` to `T` +type ComponentInfoMapperFn = Box T>; + +// Function that maps `Schedule` to `T` +type ScheduleMapperFn = Box T>; + +pub struct Settings { + pub style: Style, + pub system_style: SystemMapperFn, + pub event_style: ComponentInfoMapperFn, + + /// When set to `Some`, will only include systems matching the predicate, and their ancestor sets + pub include_system: Option>, + pub include_schedule: Option>, + + pub prettify_system_names: bool, +} + +impl Settings { + /// Set the `include_system` predicate to match only systems for which their names matches `filter` + pub fn filter_name(mut self, filter: impl Fn(&str) -> bool + 'static) -> Self { + self.include_system = Some(Box::new(move |system| { + let name = system.name(); + filter(&name) + })); + self + } + /// Set the `include_system` predicate to only match systems from the specified crate + pub fn filter_in_crate(mut self, crate_: &str) -> Self { + let crate_ = crate_.to_owned(); + self.include_system = Some(Box::new(move |system| { + let name = system.name(); + name.starts_with(&crate_) + })); + self + } + /// Set the `include_system` predicate to only match systems from the specified crates + pub fn filter_in_crates(mut self, crates: &[&str]) -> Self { + let crates: Vec<_> = crates.iter().map(|&s| s.to_owned()).collect(); + self.include_system = Some(Box::new(move |system| { + let name = system.name(); + crates.iter().any(|crate_| name.starts_with(crate_)) + })); + self + } + + pub fn get_system_style(&self, system: &dyn System) -> NodeStyle { + let style = (self.system_style)(system); + + // Check if bg is dark + let [h, s, l, _] = style.bg_color.as_hsla_f32(); + // TODO Fix following: https://ux.stackexchange.com/q/107318 + let is_dark = l < 0.6; + + // Calculate text color based on bg + let text_color = style.text_color.unwrap_or_else(|| { + if is_dark { + Color::hsl(h, s, 0.9) + } else { + Color::hsl(h, s, 0.1) + } + }); + + // Calculate border color based on bg + let border_color = style.border_color.unwrap_or_else(|| { + let offset = if is_dark { 0.2 } else { -0.2 }; + let border_l = (l + offset).clamp(0.0, 1.0); + + Color::hsl(h, s, border_l) + }); + + NodeStyle { + bg_color: color_to_hex(style.bg_color), + text_color: color_to_hex(text_color), + border_color: color_to_hex(border_color), + border_width: style.border_width.to_string(), + } + } + + pub fn get_event_style(&self, system: &ComponentInfo) -> NodeStyle { + let style = (self.event_style)(system); + + // Check if bg is dark + let [h, s, l, _] = style.bg_color.as_hsla_f32(); + // TODO Fix following: https://ux.stackexchange.com/q/107318 + let is_dark = l < 0.6; + + // Calculate text color based on bg + let text_color = style.text_color.unwrap_or_else(|| { + if is_dark { + Color::hsl(h, s, 0.9) + } else { + Color::hsl(h, s, 0.1) + } + }); + + // Calculate border color based on bg + let border_color = style.border_color.unwrap_or_else(|| { + let offset = if is_dark { 0.2 } else { -0.2 }; + let border_l = (l + offset).clamp(0.0, 1.0); + + Color::hsl(h, s, border_l) + }); + + NodeStyle { + bg_color: color_to_hex(style.bg_color), + text_color: color_to_hex(text_color), + border_color: color_to_hex(border_color), + border_width: style.border_width.to_string(), + } + } +} + +impl Default for Settings { + fn default() -> Self { + Self { + style: Style::default(), + system_style: Box::new(system_to_style), + event_style: Box::new(event_to_style), + + include_system: Some(Box::new(exclude_bevy_event_update_system)), + include_schedule: Some(Box::new(base_schedule_update)), + + prettify_system_names: true, + } + } +} + +pub fn exclude_bevy_event_update_system(system: &dyn System) -> bool { + !system + .name() + .starts_with("bevy_ecs::event::event_update_system<") +} +pub fn base_schedule_update(schedule: &Schedule) -> bool { + let labels: Vec> = vec![ + Box::new(First), + Box::new(PreUpdate), + Box::new(Update), + Box::new(PostUpdate), + ]; + labels + .iter() + .any(|s| (*schedule.label().0).as_dyn_eq().dyn_eq((**s).as_dyn_eq())) +} diff --git a/src/event_graph/system_style.rs b/src/event_graph/system_style.rs new file mode 100644 index 0000000..ad60051 --- /dev/null +++ b/src/event_graph/system_style.rs @@ -0,0 +1,124 @@ +use once_cell::sync::Lazy; +use std::borrow::Cow; + +use bevy_ecs::{component::ComponentInfo, system::System}; +use bevy_render::color::Color; +use bevy_utils::HashMap; + +static CRATE_COLORS: Lazy> = Lazy::new(|| { + [ + // Beige/Red + ("bevy_transform", "FFE7B9"), + ("bevy_animation", "FFBDB9"), + // Greys + ("bevy_asset", "D1CBC5"), + ("bevy_scene", "BACFCB"), + ("bevy_time", "C7DDBD"), + // Greens + ("bevy_core", "3E583C"), + ("bevy_app", "639D18"), + ("bevy_ecs", "B0D34A"), + ("bevy_hierarchy", "E4FBA3"), + // Turquesa + ("bevy_audio", "98F1D1"), + // Purples/Pinks + ("bevy_winit", "664F72"), + ("bevy_a11y", "9163A6"), + ("bevy_window", "BB85D4"), + ("bevy_text", "E9BBFF"), + ("bevy_gilrs", "973977"), + ("bevy_input", "D36AAF"), + ("bevy_ui", "FFB1E5"), + // Blues + ("bevy_render", "70B9FC"), + ("bevy_pbr", "ABD5FC"), + ] + .into_iter() + .collect() +}); + +pub struct SystemStyle { + pub bg_color: Color, + pub text_color: Option, + pub border_color: Option, + pub border_width: f32, +} +pub struct ComponentInfoStyle { + pub bg_color: Color, + pub text_color: Option, + pub border_color: Option, + pub border_width: f32, +} + +pub fn color_to_hex(color: Color) -> String { + format!( + "#{:0>2x}{:0>2x}{:0>2x}", + (color.r() * 255.0) as u8, + (color.g() * 255.0) as u8, + (color.b() * 255.0) as u8, + ) +} + +pub fn system_to_style(system: &dyn System) -> SystemStyle { + let name = system.name(); + let pretty_name: Cow = pretty_type_name::pretty_type_name_str(&name).into(); + let is_apply_system_buffers = pretty_name == "apply_system_buffers"; + let name_without_event = name + .trim_start_matches("bevy_ecs::event::Events<") + .trim_end_matches(">::update_system"); + let crate_name = name_without_event.split("::").next(); + + if is_apply_system_buffers { + SystemStyle { + bg_color: Color::hex("E70000").unwrap(), + text_color: Some(Color::hex("ffffff").unwrap()), + border_color: Some(Color::hex("5A0000").unwrap()), + border_width: 2.0, + } + } else { + let bg_color = crate_name + .and_then(|n| CRATE_COLORS.get(n)) + .map(Color::hex) + .unwrap_or(Color::hex("eff1f3")) + .unwrap(); + + SystemStyle { + bg_color, + text_color: None, + border_color: None, + border_width: 1.0, + } + } +} + +pub fn event_to_style(system: &ComponentInfo) -> ComponentInfoStyle { + let name = system.name(); + let pretty_name: Cow = pretty_type_name::pretty_type_name_str(name).into(); + let is_apply_system_buffers = pretty_name == "apply_system_buffers"; + let name_without_event = name + .trim_start_matches("bevy_ecs::event::Events<") + .trim_end_matches(">::update_system"); + let crate_name = name_without_event.split("::").next(); + + if is_apply_system_buffers { + ComponentInfoStyle { + bg_color: Color::hex("E70000").unwrap(), + text_color: Some(Color::hex("ffffff").unwrap()), + border_color: Some(Color::hex("5A0000").unwrap()), + border_width: 2.0, + } + } else { + let bg_color = crate_name + .and_then(|n| CRATE_COLORS.get(n)) + .map(Color::hex) + .unwrap_or(Color::hex("eff1f3")) + .unwrap(); + + ComponentInfoStyle { + bg_color, + text_color: None, + border_color: None, + border_width: 1.0, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 37f12df..2008790 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use bevy_ecs::schedule::{ScheduleLabel, Schedules}; mod dot; +pub mod event_graph; #[cfg(feature = "render_graph")] pub mod render_graph; pub mod schedule_graph; @@ -10,6 +11,39 @@ pub mod schedule_graph; #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)] struct ScheduleDebugGroup; +/// Formats the events into a dot graph. +#[track_caller] +pub fn events_graph_dot(app: &mut App, settings: &event_graph::Settings) -> String { + app.world + .resource_scope::(|world, mut schedules| { + let ignored_ambiguities = schedules.ignored_scheduling_ambiguities.clone(); + let mut contexts: Vec = Vec::new(); + for (_l, schedule) in schedules.iter_mut() { + if let Some(include_schedule) = &settings.include_schedule { + if !(include_schedule)(schedule) { + continue; + } + } + schedule.graph_mut().initialize(world); + + let _ = schedule.graph_mut().build_schedule( + world.components(), + ScheduleDebugGroup.intern(), + &ignored_ambiguities, + ); + let context = event_graph::events_graph_dot(schedule, world, settings); + contexts.push(context); + } + event_graph::print_context(schedules.as_ref(), &contexts, world, settings) + }) +} + +/// Prints the schedule with default settings. +pub fn print_events_graph(app: &mut App) { + let dot = events_graph_dot(app, &event_graph::Settings::default()); + println!("{dot}"); +} + /// Formats the schedule into a dot graph. #[track_caller] pub fn schedule_graph_dot( @@ -64,7 +98,7 @@ pub fn render_graph_dot(app: &App, settings: &render_graph::Settings) -> String .unwrap_or_else(|_| panic!("no render app")); let render_graph = render_app.world.get_resource::().unwrap(); - render_graph::render_graph_dot(render_graph, &settings) + render_graph::render_graph_dot(render_graph, settings) } /// Prints the current render graph using [`render_graph_dot`](render_graph::render_graph_dot).