mirror of
https://github.com/bevyengine/bevy
synced 2024-11-22 12:43:34 +00:00
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:
parent
2b96530947
commit
c19aa5939d
6 changed files with 84 additions and 41 deletions
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue