Skip to content

Commit c19aa59

Browse files
committed
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.
1 parent 2b96530 commit c19aa59

File tree

6 files changed

+84
-41
lines changed

6 files changed

+84
-41
lines changed

crates/bevy_diagnostic/src/diagnostic.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub struct Diagnostic {
3737
pub suffix: Cow<'static, str>,
3838
history: VecDeque<DiagnosticMeasurement>,
3939
sum: f64,
40+
ema: f64,
41+
ema_smoothing_factor: f64,
4042
max_history_length: usize,
4143
pub is_enabled: bool,
4244
}
@@ -45,6 +47,15 @@ impl Diagnostic {
4547
/// Add a new value as a [`DiagnosticMeasurement`]. Its timestamp will be [`Instant::now`].
4648
pub fn add_measurement(&mut self, value: f64) {
4749
let time = Instant::now();
50+
51+
if let Some(previous) = self.measurement() {
52+
let delta = (time - previous.time).as_secs_f64();
53+
let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0);
54+
self.ema += alpha * (value - self.ema);
55+
} else {
56+
self.ema = value;
57+
}
58+
4859
if self.max_history_length > 1 {
4960
if self.history.len() == self.max_history_length {
5061
if let Some(removed_diagnostic) = self.history.pop_front() {
@@ -57,6 +68,7 @@ impl Diagnostic {
5768
self.history.clear();
5869
self.sum = value;
5970
}
71+
6072
self.history
6173
.push_back(DiagnosticMeasurement { time, value });
6274
}
@@ -83,6 +95,8 @@ impl Diagnostic {
8395
history: VecDeque::with_capacity(max_history_length),
8496
max_history_length,
8597
sum: 0.0,
98+
ema: 0.0,
99+
ema_smoothing_factor: 2.0 / 21.0,
86100
is_enabled: true,
87101
}
88102
}
@@ -94,6 +108,22 @@ impl Diagnostic {
94108
self
95109
}
96110

111+
/// The smoothing factor used for the exponential smoothing used for
112+
/// [`smoothed`](Self::smoothed).
113+
///
114+
/// If measurements come in less fequently than `smoothing_factor` seconds
115+
/// apart, no smoothing will be applied. As measurements come in more
116+
/// frequently, the smoothing takes a greater effect such that it takes
117+
/// approximately `smoothing_factor` seconds for 83% of an instantaneous
118+
/// change in measurement to e reflected in the smoothed value.
119+
///
120+
/// A smoothing factor of 0.0 will effectively disable smoothing.
121+
#[must_use]
122+
pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self {
123+
self.ema_smoothing_factor = smoothing_factor;
124+
self
125+
}
126+
97127
/// Get the latest measurement from this diagnostic.
98128
#[inline]
99129
pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {
@@ -105,7 +135,7 @@ impl Diagnostic {
105135
self.measurement().map(|measurement| measurement.value)
106136
}
107137

108-
/// Return the mean (average) of this diagnostic's values.
138+
/// Return the simple moving average of this diagnostic's recent values.
109139
/// N.B. this a cheap operation as the sum is cached.
110140
pub fn average(&self) -> Option<f64> {
111141
if !self.history.is_empty() {
@@ -115,6 +145,19 @@ impl Diagnostic {
115145
}
116146
}
117147

148+
/// Return the exponential moving average of this diagnostic.
149+
///
150+
/// This is by default tuned to behave reasonably well for a typical
151+
/// measurement that changes every frame such as frametime. This can be
152+
/// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor).
153+
pub fn smoothed(&self) -> Option<f64> {
154+
if !self.history.is_empty() {
155+
Some(self.ema)
156+
} else {
157+
None
158+
}
159+
}
160+
118161
/// Return the number of elements for this diagnostic.
119162
pub fn history_len(&self) -> usize {
120163
self.history.len()

crates/bevy_diagnostic/src/frame_time_diagnostics_plugin.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ impl FrameTimeDiagnosticsPlugin {
2525
pub fn setup_system(mut diagnostics: ResMut<Diagnostics>) {
2626
diagnostics.add(Diagnostic::new(Self::FRAME_TIME, "frame_time", 20).with_suffix("ms"));
2727
diagnostics.add(Diagnostic::new(Self::FPS, "fps", 20));
28-
diagnostics.add(Diagnostic::new(Self::FRAME_COUNT, "frame_count", 1));
28+
diagnostics
29+
.add(Diagnostic::new(Self::FRAME_COUNT, "frame_count", 1).with_smoothing_factor(0.0));
2930
}
3031

3132
pub fn diagnostic_system(

crates/bevy_diagnostic/src/log_diagnostics_plugin.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,16 @@ impl LogDiagnosticsPlugin {
5353
}
5454

5555
fn log_diagnostic(diagnostic: &Diagnostic) {
56-
if let Some(value) = diagnostic.value() {
56+
if let Some(value) = diagnostic.smoothed() {
5757
if diagnostic.get_max_history_length() > 1 {
5858
if let Some(average) = diagnostic.average() {
5959
info!(
6060
target: "bevy diagnostic",
61-
// Suffix is only used for 's' as in seconds currently,
62-
// so we reserve one column for it; however,
63-
// Do not reserve one column for the suffix in the average
61+
// Suffix is only used for 's' or 'ms' currently,
62+
// so we reserve two columns for it; however,
63+
// Do not reserve columns for the suffix in the average
6464
// The ) hugging the value is more aesthetically pleasing
65-
"{name:<name_width$}: {value:>11.6}{suffix:1} (avg {average:>.6}{suffix:})",
65+
"{name:<name_width$}: {value:>11.6}{suffix:2} (avg {average:>.6}{suffix:})",
6666
name = diagnostic.name,
6767
suffix = diagnostic.suffix,
6868
name_width = crate::MAX_DIAGNOSTIC_NAME_WIDTH,

examples/stress_tests/bevymark.rs

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -96,35 +96,28 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
9696

9797
let texture = asset_server.load("branding/icon.png");
9898

99+
let text_section = move |color, value: &str| {
100+
TextSection::new(
101+
value,
102+
TextStyle {
103+
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
104+
font_size: 40.0,
105+
color,
106+
},
107+
)
108+
};
109+
99110
commands.spawn(Camera2dBundle::default());
100111
commands.spawn((
101112
TextBundle::from_sections([
102-
TextSection::new(
103-
"Bird Count: ",
104-
TextStyle {
105-
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
106-
font_size: 40.0,
107-
color: Color::rgb(0.0, 1.0, 0.0),
108-
},
109-
),
110-
TextSection::from_style(TextStyle {
111-
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
112-
font_size: 40.0,
113-
color: Color::rgb(0.0, 1.0, 1.0),
114-
}),
115-
TextSection::new(
116-
"\nAverage FPS: ",
117-
TextStyle {
118-
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
119-
font_size: 40.0,
120-
color: Color::rgb(0.0, 1.0, 0.0),
121-
},
122-
),
123-
TextSection::from_style(TextStyle {
124-
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
125-
font_size: 40.0,
126-
color: Color::rgb(0.0, 1.0, 1.0),
127-
}),
113+
text_section(Color::GREEN, "Bird Count"),
114+
text_section(Color::CYAN, ""),
115+
text_section(Color::GREEN, "\nFPS (raw): "),
116+
text_section(Color::CYAN, ""),
117+
text_section(Color::GREEN, "\nFPS (SMA): "),
118+
text_section(Color::CYAN, ""),
119+
text_section(Color::GREEN, "\nFPS (EMA): "),
120+
text_section(Color::CYAN, ""),
128121
])
129122
.with_style(Style {
130123
position_type: PositionType::Absolute,
@@ -261,8 +254,14 @@ fn counter_system(
261254
}
262255

263256
if let Some(fps) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) {
264-
if let Some(average) = fps.average() {
265-
text.sections[3].value = format!("{average:.2}");
257+
if let Some(raw) = fps.value() {
258+
text.sections[3].value = format!("{raw:.2}");
259+
}
260+
if let Some(sma) = fps.average() {
261+
text.sections[5].value = format!("{sma:.2}");
262+
}
263+
if let Some(ema) = fps.smoothed() {
264+
text.sections[7].value = format!("{ema:.2}");
266265
}
267266
};
268267
}

examples/ui/text.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ fn text_color_system(time: Res<Time>, mut query: Query<&mut Text, With<ColorText
9393
fn text_update_system(diagnostics: Res<Diagnostics>, mut query: Query<&mut Text, With<FpsText>>) {
9494
for mut text in &mut query {
9595
if let Some(fps) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) {
96-
if let Some(average) = fps.average() {
96+
if let Some(value) = fps.smoothed() {
9797
// Update the value of the second section
98-
text.sections[1].value = format!("{average:.2}");
98+
text.sections[1].value = format!("{value:.2}");
9999
}
100100
}
101101
}

examples/ui/text_debug.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,16 @@ fn change_text_system(
157157
for mut text in &mut query {
158158
let mut fps = 0.0;
159159
if let Some(fps_diagnostic) = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS) {
160-
if let Some(fps_avg) = fps_diagnostic.average() {
161-
fps = fps_avg;
160+
if let Some(fps_smoothed) = fps_diagnostic.smoothed() {
161+
fps = fps_smoothed;
162162
}
163163
}
164164

165165
let mut frame_time = time.delta_seconds_f64();
166166
if let Some(frame_time_diagnostic) = diagnostics.get(FrameTimeDiagnosticsPlugin::FRAME_TIME)
167167
{
168-
if let Some(frame_time_avg) = frame_time_diagnostic.average() {
169-
frame_time = frame_time_avg;
168+
if let Some(frame_time_smoothed) = frame_time_diagnostic.smoothed() {
169+
frame_time = frame_time_smoothed;
170170
}
171171
}
172172

0 commit comments

Comments
 (0)