bevy_diagnostic_visualizer/
lib.rs1#![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#![doc = include_str!("../examples/minimal.rs")]
28use 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
47pub 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 #[must_use]
105 pub fn wait_duration(mut self, wait_duration: Duration) -> Self {
106 self.wait_duration = wait_duration;
107 self
108 }
109
110 #[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 #[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}