Add Exponential Moving Average into diagnostics (#4992)

# Objective

- Add Time-Adjusted Rolling EMA-based smoothing to diagnostics.
- Closes #4983; see that issue for more more information.

## Terms

- EMA - [Exponential Moving Average](https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average)
- SMA - [Simple Moving Average](https://en.wikipedia.org/wiki/Moving_average#Simple_moving_average)

## Solution

- We use a fairly standard approximation of a true EMA where $EMA_{\text{frame}} = EMA_{\text{previous}} + \alpha \left( x_{\text{frame}} - EMA_{\text{previous}} \right)$ where $\alpha = \Delta t / \tau$ and $\tau$ is an arbitrary smoothness factor. (See #4983 for more discussion of the math.)
- The smoothness factor is here defaulted to $2 / 21$; this was chosen fairly arbitrarily as supposedly related to the existing 20-bucket SMA.
- The smoothness factor can be set on a per-diagnostic basis via `Diagnostic::with_smoothing_factor`.

---

## Changelog

### Added

- `Diagnostic::smoothed` - provides an exponentially smoothed view of a recorded diagnostic, to e.g. reduce jitter in frametime readings.

### Changed
- `LogDiagnosticsPlugin` now records the smoothed value rather than the raw value.
  - For diagnostics recorded less often than every 0.1 seconds, this change to defaults will have no visible effect.
  - For discrete diagnostics where this smoothing is not desirable, set a smoothing factor of 0 to disable smoothing.
  - The average of the recent history is still shown when available.
This commit is contained in:
Christopher Durham 2022-10-24 13:46:37 +00:00
parent 2b96530947
commit c19aa5939d
6 changed files with 84 additions and 41 deletions

View file

@ -37,6 +37,8 @@ pub struct Diagnostic {
pub suffix: Cow<'static, str>,
history: VecDeque<DiagnosticMeasurement>,
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<f64> {
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<f64> {
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()

View file

@ -25,7 +25,8 @@ impl FrameTimeDiagnosticsPlugin {
pub fn setup_system(mut diagnostics: ResMut<Diagnostics>) {
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(

View file

@ -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:<name_width$}: {value:>11.6}{suffix:1} (avg {average:>.6}{suffix:})",
"{name:<name_width$}: {value:>11.6}{suffix:2} (avg {average:>.6}{suffix:})",
name = diagnostic.name,
suffix = diagnostic.suffix,
name_width = crate::MAX_DIAGNOSTIC_NAME_WIDTH,

View file

@ -96,35 +96,28 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
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}");
}
};
}

View file

@ -93,9 +93,9 @@ fn text_color_system(time: Res<Time>, mut query: Query<&mut Text, With<ColorText
fn text_update_system(diagnostics: Res<Diagnostics>, mut query: Query<&mut Text, With<FpsText>>) {
for mut text in &mut query {
if let Some(fps) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) {
if let Some(average) = fps.average() {
if let Some(value) = fps.smoothed() {
// Update the value of the second section
text.sections[1].value = format!("{average:.2}");
text.sections[1].value = format!("{value:.2}");
}
}
}

View file

@ -157,16 +157,16 @@ fn change_text_system(
for mut text in &mut query {
let mut fps = 0.0;
if let Some(fps_diagnostic) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) {
if let Some(fps_avg) = fps_diagnostic.average() {
fps = fps_avg;
if let Some(fps_smoothed) = fps_diagnostic.smoothed() {
fps = fps_smoothed;
}
}
let mut frame_time = time.delta_seconds_f64();
if let Some(frame_time_diagnostic) = diagnostics.get(FrameTimeDiagnosticsPlugin::FRAME_TIME)
{
if let Some(frame_time_avg) = frame_time_diagnostic.average() {
frame_time = frame_time_avg;
if let Some(frame_time_smoothed) = frame_time_diagnostic.smoothed() {
frame_time = frame_time_smoothed;
}
}