bevy_diagnostic_visualizer/
lib.rs

1#![warn(
2    clippy::all,
3    clippy::pedantic,
4    clippy::nursery,
5    clippy::cargo,
6    clippy::wildcard_imports,
7    future_incompatible,
8    nonstandard_style,
9    rust_2018_idioms,
10    unused,
11    missing_docs
12)]
13#![allow(
14    clippy::missing_const_for_fn,
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::multiple_crate_versions
18)]
19
20//! This crate provides visualizations for Bevy game engine diagnostics.
21//!
22//! ## Usage
23//!
24//! Here's a minimal usage example:
25//!
26//! ```
27#![doc = include_str!("../examples/minimal.rs")]
28//! ```
29
30use bevy::{
31    diagnostic::{Diagnostic, DiagnosticId, Diagnostics},
32    prelude::*,
33};
34use bevy_egui::{
35    egui::{
36        epaint::{PathShape, RectShape},
37        pos2, remap, vec2, CollapsingHeader, Color32, Rect, Rgba, Rounding, Sense, Shape, Stroke,
38        TextStyle, Ui, Vec2, WidgetText, Window,
39    },
40    EguiContext, EguiPlugin,
41};
42use std::{
43    collections::{HashMap, HashSet, VecDeque},
44    time::Duration,
45};
46
47/// Diagnostics visualizer plugin
48pub struct DiagnosticVisualizerPlugin {
49    wait_duration: Duration,
50    filter: DiagnosticIds,
51    style: Style,
52}
53
54#[derive(Clone)]
55struct Style {
56    text_color: Color32,
57    rectangle_stroke: Stroke,
58    line_stroke: Stroke,
59    width: f32,
60    height: f32,
61}
62
63#[derive(Clone)]
64enum DiagnosticIds {
65    All,
66    Include(HashSet<DiagnosticId>),
67    Exclude(HashSet<DiagnosticId>),
68}
69
70impl DiagnosticIds {
71    fn should_include(&self, diagnostic_id: &DiagnosticId) -> bool {
72        match self {
73            Self::All => true,
74            Self::Include(ids) => ids.contains(diagnostic_id),
75            Self::Exclude(ids) => !ids.contains(diagnostic_id),
76        }
77    }
78}
79
80impl Default for DiagnosticVisualizerPlugin {
81    fn default() -> Self {
82        Self {
83            wait_duration: Duration::from_millis(20),
84            filter: DiagnosticIds::All,
85            style: Style::default(),
86        }
87    }
88}
89
90impl Default for Style {
91    fn default() -> Self {
92        Self {
93            text_color: Color32::WHITE,
94            rectangle_stroke: Stroke::new(1., Color32::WHITE),
95            line_stroke: Stroke::new(1., Color32::WHITE),
96            width: 200.,
97            height: 100.,
98        }
99    }
100}
101
102impl DiagnosticVisualizerPlugin {
103    /// How often to update measurements
104    #[must_use]
105    pub fn wait_duration(mut self, wait_duration: Duration) -> Self {
106        self.wait_duration = wait_duration;
107        self
108    }
109
110    /// Include a specific diagnostic ID.
111    #[must_use]
112    pub fn include(mut self, diagnostic_id: DiagnosticId) -> Self {
113        match &mut self.filter {
114            DiagnosticIds::Include(hash_set) => {
115                hash_set.insert(diagnostic_id);
116            }
117            filter => {
118                let mut hash_set = HashSet::new();
119                hash_set.insert(diagnostic_id);
120                *filter = DiagnosticIds::Include(hash_set);
121            }
122        };
123        self
124    }
125
126    /// Exclude a specific diagnostic ID.
127    #[must_use]
128    pub fn exclude(mut self, diagnostic_id: DiagnosticId) -> Self {
129        match &mut self.filter {
130            DiagnosticIds::Exclude(hash_set) => {
131                hash_set.insert(diagnostic_id);
132            }
133            filter => {
134                let mut hash_set = HashSet::new();
135                hash_set.insert(diagnostic_id);
136                *filter = DiagnosticIds::Exclude(hash_set);
137            }
138        };
139        self
140    }
141}
142
143struct State {
144    timer: Timer,
145    filter: DiagnosticIds,
146    measurements: HashMap<DiagnosticId, VecDeque<f64>>,
147    is_open: bool,
148    style: Style,
149}
150
151impl Plugin for DiagnosticVisualizerPlugin {
152    fn build(&self, app: &mut App) {
153        if !app.world.contains_resource::<EguiContext>() {
154            app.add_plugin(EguiPlugin);
155        }
156
157        app.insert_resource(State {
158            timer: Timer::new(self.wait_duration, true),
159            filter: self.filter.clone(),
160            measurements: HashMap::default(),
161            is_open: true,
162            style: self.style.clone(),
163        })
164        .add_system_to_stage(CoreStage::PostUpdate, plot_diagnostics_system);
165    }
166}
167
168#[allow(clippy::needless_pass_by_value)]
169fn plot_diagnostics_system(
170    mut state: ResMut<'_, State>,
171    time: Res<'_, Time>,
172    diagnostics: Res<'_, Diagnostics>,
173    mut egui_context: ResMut<'_, EguiContext>,
174) {
175    let State {
176        is_open,
177        measurements,
178        filter,
179        style,
180        timer,
181        ..
182    } = state.as_mut();
183
184    if !*is_open {
185        return;
186    }
187
188    let is_tick_finished = timer.tick(time.delta()).finished();
189
190    Window::new("Diagnostics")
191        .open(is_open)
192        .default_width(250.0)
193        .default_height(diagnostics.iter().count() as f32 * 170.0)
194        .vscroll(true)
195        .show(egui_context.ctx_mut(), |ui| {
196            diagnostics
197                .iter()
198                .filter(|diagnostic| diagnostic.is_enabled)
199                .filter(|diagnostic| filter.should_include(&diagnostic.id))
200                .for_each(|diagnostic| {
201                    if is_tick_finished {
202                        track_diagnostic(measurements, diagnostic);
203                    }
204                    plot_diagnostic(measurements, diagnostic, ui, style);
205                });
206        });
207}
208
209fn track_diagnostic(
210    measurements: &mut HashMap<DiagnosticId, VecDeque<f64>>,
211    diagnostic: &Diagnostic,
212) {
213    let measurements = measurements.entry(diagnostic.id).or_default();
214    if let Some(last) = diagnostic.average() {
215        measurements.push_back(last);
216        if measurements.len() > 100 {
217            measurements.pop_front();
218        }
219    }
220    measurements.make_contiguous();
221}
222
223fn plot_diagnostic(
224    measurements: &mut HashMap<DiagnosticId, VecDeque<f64>>,
225    diagnostic: &Diagnostic,
226    ui: &mut Ui,
227    style: &Style,
228) {
229    let values = measurements.entry(diagnostic.id).or_default().as_slices().0;
230    CollapsingHeader::new(diagnostic.name.as_ref())
231        .default_open(true)
232        .show(ui, |ui| show_graph(ui, style, values));
233}
234
235fn show_graph(ui: &mut Ui, style: &Style, values: &[f64]) {
236    if values.is_empty() {
237        return;
238    }
239
240    ui.vertical(|ui| {
241        let last_value = values.last().unwrap();
242
243        let min = 0.0;
244        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
245
246        let spacing_x = ui.spacing().item_spacing.x;
247
248        let last_text: WidgetText = format!("{:.2}", last_value).into();
249        let galley = last_text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Button);
250        let (outer_rect, _) = ui.allocate_exact_size(
251            Vec2::new(style.width + galley.size().x + spacing_x, style.height),
252            Sense::hover(),
253        );
254        let rect = Rect::from_min_size(outer_rect.left_top(), vec2(style.width, style.height));
255        let text_pos = rect.right_center() + vec2(spacing_x / 2.0, -galley.size().y / 2.);
256        galley.paint_with_fallback_color(
257            &ui.painter().with_clip_rect(outer_rect),
258            text_pos,
259            style.text_color,
260        );
261
262        let body = Shape::Rect(RectShape {
263            rect,
264            rounding: Rounding::none(),
265            fill: Rgba::TRANSPARENT.into(),
266            stroke: style.rectangle_stroke,
267        });
268        ui.painter().add(body);
269        let init_point = rect.left_bottom();
270
271        let size = values.len();
272        let points = values
273            .iter()
274            .enumerate()
275            .map(|(i, value)| {
276                let x = remap(i as f32, 0.0..=size as f32, 0.0..=style.width);
277                let y = remap(
278                    (*value) as f32,
279                    (min as f32)..=(max as f32),
280                    0.0..=style.height,
281                );
282
283                pos2(x + init_point.x, init_point.y - y)
284            })
285            .collect();
286
287        let path = PathShape::line(points, style.line_stroke);
288        ui.painter().add(path);
289
290        {
291            let text: WidgetText = format!("{:.0}", max).into();
292            let galley = text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Button);
293            let text_pos =
294                rect.left_top() + Vec2::new(0.0, galley.size().y / 2.) + vec2(spacing_x, 0.0);
295            galley.paint_with_fallback_color(
296                &ui.painter().with_clip_rect(rect),
297                text_pos,
298                style.text_color,
299            );
300        }
301        {
302            let text: WidgetText = format!("{:.0}", min).into();
303            let galley = text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Button);
304            let text_pos =
305                rect.left_bottom() - Vec2::new(0.0, galley.size().y * 1.5) + vec2(spacing_x, 0.0);
306            galley.paint_with_fallback_color(
307                &ui.painter().with_clip_rect(rect),
308                text_pos,
309                style.text_color,
310            );
311        }
312    });
313}