From 024d77bbb7abd8a75c245e7f2f75ea991285ec05 Mon Sep 17 00:00:00 2001 From: Christopher Durham Date: Thu, 20 Oct 2022 19:50:59 -0500 Subject: [PATCH] Add Exponential Moving Average into diagnostics --- crates/bevy_diagnostic/src/diagnostic.rs | 45 ++++++++++++++- .../src/frame_time_diagnostics_plugin.rs | 3 +- .../src/log_diagnostics_plugin.rs | 10 ++-- examples/stress_tests/bevymark.rs | 55 +++++++++---------- examples/ui/text.rs | 4 +- examples/ui/text_debug.rs | 8 +-- 6 files changed, 84 insertions(+), 41 deletions(-) diff --git a/crates/bevy_diagnostic/src/diagnostic.rs b/crates/bevy_diagnostic/src/diagnostic.rs index 4d01a48586862..7d947359db3c3 100644 --- a/crates/bevy_diagnostic/src/diagnostic.rs +++ b/crates/bevy_diagnostic/src/diagnostic.rs @@ -37,6 +37,8 @@ pub struct Diagnostic { pub suffix: Cow<'static, str>, history: VecDeque, sum: f64, + ema: f64, + ema_smoothing_factor: f64, max_history_length: usize, pub is_enabled: bool, } @@ -45,6 +47,15 @@ impl Diagnostic { /// Add a new value as a [`DiagnosticMeasurement`]. Its timestamp will be [`Instant::now`]. pub fn add_measurement(&mut self, value: f64) { let time = Instant::now(); + + if let Some(previous) = self.measurement() { + let delta = (time - previous.time).as_secs_f64(); + let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0); + self.ema += alpha * (value - self.ema); + } else { + self.ema = value; + } + if self.max_history_length > 1 { if self.history.len() == self.max_history_length { if let Some(removed_diagnostic) = self.history.pop_front() { @@ -57,6 +68,7 @@ impl Diagnostic { self.history.clear(); self.sum = value; } + self.history .push_back(DiagnosticMeasurement { time, value }); } @@ -83,6 +95,8 @@ impl Diagnostic { history: VecDeque::with_capacity(max_history_length), max_history_length, sum: 0.0, + ema: 0.0, + ema_smoothing_factor: 2.0 / 21.0, is_enabled: true, } } @@ -94,6 +108,22 @@ impl Diagnostic { self } + /// The smoothing factor used for the exponential smoothing used for + /// [`smoothed`](Self::smoothed). + /// + /// If measurements come in less fequently than `smoothing_factor` seconds + /// apart, no smoothing will be applied. As measurements come in more + /// frequently, the smoothing takes a greater effect such that it takes + /// approximately `smoothing_factor` seconds for 83% of an instantaneous + /// change in measurement to e reflected in the smoothed value. + /// + /// A smoothing factor of 0.0 will effectively disable smoothing. + #[must_use] + pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self { + self.ema_smoothing_factor = smoothing_factor; + self + } + /// Get the latest measurement from this diagnostic. #[inline] pub fn measurement(&self) -> Option<&DiagnosticMeasurement> { @@ -105,7 +135,7 @@ impl Diagnostic { self.measurement().map(|measurement| measurement.value) } - /// Return the mean (average) of this diagnostic's values. + /// Return the simple moving average of this diagnostic's recent values. /// N.B. this a cheap operation as the sum is cached. pub fn average(&self) -> Option { if !self.history.is_empty() { @@ -115,6 +145,19 @@ impl Diagnostic { } } + /// Return the exponential moving average of this diagnostic. + /// + /// This is by default tuned to behave reasonably well for a typical + /// measurement that changes every frame such as frametime. This can be + /// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor). + pub fn smoothed(&self) -> Option { + if !self.history.is_empty() { + Some(self.ema) + } else { + None + } + } + /// Return the number of elements for this diagnostic. pub fn history_len(&self) -> usize { self.history.len() diff --git a/crates/bevy_diagnostic/src/frame_time_diagnostics_plugin.rs b/crates/bevy_diagnostic/src/frame_time_diagnostics_plugin.rs index ec9ea2d9469c1..be03d38d270fd 100644 --- a/crates/bevy_diagnostic/src/frame_time_diagnostics_plugin.rs +++ b/crates/bevy_diagnostic/src/frame_time_diagnostics_plugin.rs @@ -25,7 +25,8 @@ impl FrameTimeDiagnosticsPlugin { pub fn setup_system(mut diagnostics: ResMut) { diagnostics.add(Diagnostic::new(Self::FRAME_TIME, "frame_time", 20).with_suffix("ms")); diagnostics.add(Diagnostic::new(Self::FPS, "fps", 20)); - diagnostics.add(Diagnostic::new(Self::FRAME_COUNT, "frame_count", 1)); + diagnostics + .add(Diagnostic::new(Self::FRAME_COUNT, "frame_count", 1).with_smoothing_factor(0.0)); } pub fn diagnostic_system( diff --git a/crates/bevy_diagnostic/src/log_diagnostics_plugin.rs b/crates/bevy_diagnostic/src/log_diagnostics_plugin.rs index 9966157cb0cf5..248a9b84e8987 100644 --- a/crates/bevy_diagnostic/src/log_diagnostics_plugin.rs +++ b/crates/bevy_diagnostic/src/log_diagnostics_plugin.rs @@ -53,16 +53,16 @@ impl LogDiagnosticsPlugin { } fn log_diagnostic(diagnostic: &Diagnostic) { - if let Some(value) = diagnostic.value() { + if let Some(value) = diagnostic.smoothed() { if diagnostic.get_max_history_length() > 1 { if let Some(average) = diagnostic.average() { info!( target: "bevy diagnostic", - // Suffix is only used for 's' as in seconds currently, - // so we reserve one column for it; however, - // Do not reserve one column for the suffix in the average + // Suffix is only used for 's' or 'ms' currently, + // so we reserve two columns for it; however, + // Do not reserve columns for the suffix in the average // The ) hugging the value is more aesthetically pleasing - "{name:11.6}{suffix:1} (avg {average:>.6}{suffix:})", + "{name:11.6}{suffix:2} (avg {average:>.6}{suffix:})", name = diagnostic.name, suffix = diagnostic.suffix, name_width = crate::MAX_DIAGNOSTIC_NAME_WIDTH, diff --git a/examples/stress_tests/bevymark.rs b/examples/stress_tests/bevymark.rs index fbf250cf64c1a..6d40ec2d23220 100644 --- a/examples/stress_tests/bevymark.rs +++ b/examples/stress_tests/bevymark.rs @@ -96,35 +96,28 @@ fn setup(mut commands: Commands, asset_server: Res) { let texture = asset_server.load("branding/icon.png"); + let text_section = move |color, value: &str| { + TextSection::new( + value, + TextStyle { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 40.0, + color, + }, + ) + }; + commands.spawn(Camera2dBundle::default()); commands.spawn(( TextBundle::from_sections([ - TextSection::new( - "Bird Count: ", - TextStyle { - font: asset_server.load("fonts/FiraSans-Bold.ttf"), - font_size: 40.0, - color: Color::rgb(0.0, 1.0, 0.0), - }, - ), - TextSection::from_style(TextStyle { - font: asset_server.load("fonts/FiraSans-Bold.ttf"), - font_size: 40.0, - color: Color::rgb(0.0, 1.0, 1.0), - }), - TextSection::new( - "\nAverage FPS: ", - TextStyle { - font: asset_server.load("fonts/FiraSans-Bold.ttf"), - font_size: 40.0, - color: Color::rgb(0.0, 1.0, 0.0), - }, - ), - TextSection::from_style(TextStyle { - font: asset_server.load("fonts/FiraSans-Bold.ttf"), - font_size: 40.0, - color: Color::rgb(0.0, 1.0, 1.0), - }), + text_section(Color::GREEN, "Bird Count"), + text_section(Color::CYAN, ""), + text_section(Color::GREEN, "\nFPS (raw): "), + text_section(Color::CYAN, ""), + text_section(Color::GREEN, "\nFPS (SMA): "), + text_section(Color::CYAN, ""), + text_section(Color::GREEN, "\nFPS (EMA): "), + text_section(Color::CYAN, ""), ]) .with_style(Style { position_type: PositionType::Absolute, @@ -261,8 +254,14 @@ fn counter_system( } if let Some(fps) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) { - if let Some(average) = fps.average() { - text.sections[3].value = format!("{average:.2}"); + if let Some(raw) = fps.value() { + text.sections[3].value = format!("{raw:.2}"); + } + if let Some(sma) = fps.average() { + text.sections[5].value = format!("{sma:.2}"); + } + if let Some(ema) = fps.smoothed() { + text.sections[7].value = format!("{ema:.2}"); } }; } diff --git a/examples/ui/text.rs b/examples/ui/text.rs index ddfaf940fb0b6..cb92baab64300 100644 --- a/examples/ui/text.rs +++ b/examples/ui/text.rs @@ -93,9 +93,9 @@ fn text_color_system(time: Res