Skip to content

Commit 80580eb

Browse files
committed
Add example to test the smoothness/accuracy of the timestep
Bevy's timestep is sometimes inaccurate, and sometimes it misses its frame timings. Both of these can manifest as hiccups and stutters in what should be smooth motion. This is most clearly visible when using pixel-perfect movement with large visible pixels. This new example (in the stress tests directory) scrolls a background behind a character at 120 pixels per second; by default, it makes sure to always move in whole-pixel increments. You can modify some behavior while it's running by pressing keys: - P: cycle between different `window::PresentMode`s. - W: cycle between different `window::WindowMode`s. - T: cycle between updating scroll position based on `Time.delta_seconds()`, and updating it by just assuming a 16.667 millisecond timestep (only useful in `PresentMode::Fifo` on a 60fps display). - M: cycle between whole-pixel movement and simple sub-pixel movement.
1 parent a138804 commit 80580eb

File tree

3 files changed

+361
-0
lines changed

3 files changed

+361
-0
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,10 @@ description = "Displays many sprites in a grid arragement! Used for performance
12461246
category = "Stress Tests"
12471247
wasm = true
12481248

1249+
[[example]]
1250+
name = "time_smoothness"
1251+
path = "examples/stress_tests/time_smoothness.rs"
1252+
12491253
[[example]]
12501254
name = "transform_hierarchy"
12511255
path = "examples/stress_tests/transform_hierarchy.rs"

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ Example | Description
279279
[Many Foxes](../examples/stress_tests/many_foxes.rs) | Loads an animated fox model and spawns lots of them. Good for testing skinned mesh performance. Takes an unsigned integer argument for the number of foxes to spawn. Defaults to 1000
280280
[Many Lights](../examples/stress_tests/many_lights.rs) | Simple benchmark to test rendering many point lights. Run with `WGPU_SETTINGS_PRIO=webgl2` to restrict to uniform buffers and max 256 lights
281281
[Many Sprites](../examples/stress_tests/many_sprites.rs) | Displays many sprites in a grid arragement! Used for performance testing
282+
[Time Smoothness](./stress_tests/time_smoothness.rs) | Scrolls a simple background behind an animated sprite. Used for performance consistency testing.
282283
[Transform Hierarchy](../examples/stress_tests/transform_hierarchy.rs) | Various test cases for hierarchy and transform propagation performance
283284

284285
## Tools
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
//! Renders a scrolling background behind an animated sprite at a high zoom
2+
//! level, to test consistency and smoothness of performance.
3+
//!
4+
//! To measure performance realistically, be sure to run this in release mode.
5+
//! `cargo run --example time_smoothness --release`
6+
//!
7+
//! By default, this example scrolls the background at 120 pixels per second,
8+
//! and always moves in whole-pixel increments (since limiting movement to whole
9+
//! pixels at high zoom seems to make it easier to perceive any problems with
10+
//! frame consistency). There are several keyboard controls for changing the
11+
//! example's behavior at runtime:
12+
//!
13+
//! - P: Cycle the `PresentMode` between `Fifo`, `Mailbox`, and `Immediate`.
14+
//! - W: Cycle the `WindowMode` between `Windowed`, `BorderlessFullscreen`, and
15+
//! `Fullscreen`.
16+
//! - T: Cycle the delta time between normal (measured from the `Time` resource)
17+
//! and fixed-interval. This can be useful when trying to distinguish slow
18+
//! frametimes from inaccurate time measurement. Fixed-interval delta time is
19+
//! hardcoded to 1/60th of a second per frame, which will look highly wacky
20+
//! unless you're using Fifo mode on a 60hz display.
21+
//! - M: Cycle the scrolling motion style between whole-pixel and sub-pixel
22+
//! Transform increments.
23+
//!
24+
//! A number of factors contribute to scrolling without perceptible
25+
//! hiccups/stutters/jank, including the accuracy of the delta time measurement,
26+
//! the ability to consistently present frames to the GPU at the expected pace,
27+
//! etc. This example doesn't isolate all of those factors, but it can help
28+
//! identify when a problem exists and provide a starting point for further
29+
//! investigation.
30+
use bevy::{
31+
diagnostic::{Diagnostic, DiagnosticId, Diagnostics},
32+
prelude::*,
33+
render::texture::ImageSettings,
34+
window::{PresentMode, WindowMode},
35+
};
36+
37+
// BG_WIDTH is smaller than the image's actual pixel width, because we want the
38+
// empty space on the background tiles to overlap a bit. That way there's always
39+
// a "landmark" on screen at the default window size.
40+
const BG_WIDTH: f32 = 755.0;
41+
const BG_HEIGHT: f32 = 363.0;
42+
const BG_TILES: usize = 3;
43+
const BG_SPEED: f32 = 120.0;
44+
45+
const CUSTOM_FRAME_TIME: DiagnosticId =
46+
DiagnosticId::from_u128(76860576947891895965111337840552081898);
47+
const MAX_FRAME_HISTORY: usize = 800;
48+
const FRAME_ANALYSIS_INTERVAL_SECONDS: f32 = 10.0;
49+
50+
fn main() {
51+
App::new()
52+
.insert_resource(WindowDescriptor {
53+
present_mode: PresentMode::Fifo,
54+
mode: WindowMode::Windowed,
55+
..Default::default()
56+
})
57+
// Prevents blurry sprites
58+
.insert_resource(ImageSettings::default_nearest())
59+
.add_plugins(DefaultPlugins)
60+
// Adds frame time diagnostics
61+
.add_startup_system(setup_diagnostics)
62+
.add_system(update_diagnostics)
63+
.add_system(log_diagnostics)
64+
.insert_resource(FrameAnalysisTimer(Timer::from_seconds(
65+
FRAME_ANALYSIS_INTERVAL_SECONDS,
66+
true,
67+
)))
68+
// Main app setup
69+
.add_startup_system(setup)
70+
.insert_resource(MoveRemainder(Vec2::ZERO))
71+
.insert_resource(TimeStyle::Normal)
72+
.insert_resource(MoveStyle::WholePixel)
73+
.add_system(change_settings)
74+
.add_system(animate_runner)
75+
.add_system(scroll_background)
76+
.run();
77+
}
78+
79+
// Create a custom frame time diagnostic. We need this because
80+
// FrameTimeDiagnosticsPlugin only keeps 20 frames, which is too narrow a view
81+
// to be useful when hunting irregular blips.
82+
fn setup_diagnostics(mut diagnostics: ResMut<Diagnostics>) {
83+
diagnostics
84+
.add(Diagnostic::new(CUSTOM_FRAME_TIME, "frame_time", MAX_FRAME_HISTORY).with_suffix("s"));
85+
}
86+
87+
// Update our custom frame time diagnostic with the delta time in milliseconds.
88+
fn update_diagnostics(mut diagnostics: ResMut<Diagnostics>, time: Res<Time>) {
89+
diagnostics.add_measurement(CUSTOM_FRAME_TIME, || time.delta_seconds_f64() * 1000.0);
90+
}
91+
92+
// Periodically analyze recent frame times and print a summary.
93+
fn log_diagnostics(
94+
mut timer: ResMut<FrameAnalysisTimer>,
95+
diagnostics: Res<Diagnostics>,
96+
time: Res<Time>,
97+
) {
98+
timer.0.tick(time.delta());
99+
if timer.0.finished() {
100+
let frame_times = diagnostics.get(CUSTOM_FRAME_TIME).unwrap();
101+
if let Some(average) = frame_times.average() {
102+
if let Some(std_dev) = std_deviation(frame_times) {
103+
let mut sorted_times: Vec<f64> = frame_times.values().copied().collect();
104+
sorted_times.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
105+
106+
let count = sorted_times.len();
107+
108+
// Indexes corresponding to percentile ranks:
109+
let p95 = (0.95 * count as f32).round() as usize - 1;
110+
let p99 = (0.99 * count as f32).round() as usize - 1;
111+
let p99_5 = (0.995 * count as f32).round() as usize - 1;
112+
113+
let min = sorted_times.first().unwrap();
114+
let max = sorted_times.last().unwrap();
115+
116+
info!("-------------------------");
117+
info!("Average frame time: {:.6} ms", average);
118+
info!("Standard deviation: {:.6} ms", std_dev);
119+
info!("Shortest frame: {:.6} ms", min);
120+
info!("95th percentile: {:.6} ms", sorted_times[p95]);
121+
info!("99th percentile: {:.6} ms", sorted_times[p99]);
122+
info!("99.5th percentile: {:.6} ms", sorted_times[p99_5]);
123+
info!("Longest frame: {:.6} ms", max);
124+
info!("-------------------------");
125+
}
126+
}
127+
}
128+
}
129+
130+
fn std_deviation(diagnostic: &Diagnostic) -> Option<f64> {
131+
if let Some(average) = diagnostic.average() {
132+
let variance = diagnostic
133+
.values()
134+
.map(|val| {
135+
let diff = average - *val;
136+
diff * diff
137+
})
138+
.sum::<f64>()
139+
/ diagnostic.history_len() as f64;
140+
Some(variance.sqrt())
141+
} else {
142+
None
143+
}
144+
}
145+
146+
// Set up entities and assets.
147+
fn setup(
148+
mut commands: Commands,
149+
asset_server: Res<AssetServer>,
150+
mut texture_atlases: ResMut<Assets<TextureAtlas>>,
151+
) {
152+
// Locate the center(ish) of the background conveyer belt, so we can
153+
// position the player and camera there.
154+
let bg_center = Vec2::new(BG_WIDTH * BG_TILES as f32 / 2.0, BG_HEIGHT / 2.0).round();
155+
156+
// Set up camera.
157+
let mut camera_bundle = Camera2dBundle::default();
158+
camera_bundle.projection.scale = 1.0 / 4.0;
159+
camera_bundle.transform.translation += bg_center.extend(0.0);
160+
commands.spawn_bundle(camera_bundle);
161+
162+
// Set up animated player sprite.
163+
let runner_texture: Handle<Image> =
164+
asset_server.load("textures/rpg/chars/mani/mani-idle-run.png");
165+
let mut runner_atlas = TextureAtlas::from_grid(runner_texture, Vec2::new(24.0, 24.0), 7, 1);
166+
// Drop the first (idle) frame so we just have the run frames.
167+
runner_atlas.textures = runner_atlas.textures[1..].into();
168+
let runner_handle = texture_atlases.add(runner_atlas);
169+
// Offset by half our size to find where we should place our bottom left corner.
170+
let runner_location = bg_center - Vec2::new(12.0, 12.0);
171+
commands
172+
.spawn_bundle(SpriteSheetBundle {
173+
texture_atlas: runner_handle,
174+
sprite: TextureAtlasSprite {
175+
anchor: bevy::sprite::Anchor::BottomLeft,
176+
..Default::default()
177+
},
178+
transform: Transform::from_translation(runner_location.extend(3.0)),
179+
..Default::default()
180+
})
181+
.insert(Player)
182+
.insert(AnimationTimer {
183+
timer: Timer::from_seconds(0.1, true),
184+
});
185+
186+
// Set up scrolling background, using a conveyor belt of three long sprites.
187+
let background_texture: Handle<Image> = asset_server.load("branding/banner.png");
188+
for i in 0..BG_TILES {
189+
commands
190+
.spawn_bundle(SpriteBundle {
191+
sprite: Sprite {
192+
anchor: bevy::sprite::Anchor::BottomLeft,
193+
..Default::default()
194+
},
195+
transform: Transform::from_translation(Vec3::new(
196+
i as f32 * BG_WIDTH + 1.0,
197+
0.0,
198+
0.0,
199+
)),
200+
texture: background_texture.clone(),
201+
..Default::default()
202+
})
203+
.insert(Background);
204+
}
205+
}
206+
207+
// Change settings on demand, to display different behaviors without recompiling.
208+
fn change_settings(
209+
input: Res<Input<KeyCode>>,
210+
mut windows: ResMut<Windows>,
211+
mut time_style: ResMut<TimeStyle>,
212+
mut move_style: ResMut<MoveStyle>,
213+
mut background_query: Query<&mut Transform, With<Background>>,
214+
) {
215+
let window = windows.primary_mut();
216+
if input.just_pressed(KeyCode::P) {
217+
// P: cycle PresentMode.
218+
let next_present_mode = match window.present_mode() {
219+
PresentMode::Fifo => PresentMode::Mailbox,
220+
PresentMode::Mailbox => PresentMode::Immediate,
221+
PresentMode::Immediate => PresentMode::Fifo,
222+
};
223+
info!("Switching present mode to {:?}", next_present_mode);
224+
window.set_present_mode(next_present_mode);
225+
} else if input.just_pressed(KeyCode::W) {
226+
// W: cycle WindowMode.
227+
let next_window_mode = match window.mode() {
228+
WindowMode::Windowed => WindowMode::BorderlessFullscreen,
229+
WindowMode::BorderlessFullscreen => WindowMode::Fullscreen,
230+
WindowMode::Fullscreen => WindowMode::Windowed,
231+
_ => WindowMode::Windowed,
232+
};
233+
info!("Switching window mode to {:?}", next_window_mode);
234+
window.set_mode(next_window_mode);
235+
if next_window_mode == WindowMode::Windowed {
236+
window.set_resolution(1280.0, 720.0);
237+
}
238+
} else if input.just_pressed(KeyCode::T) {
239+
// T: cycle delta time style
240+
let next_time_style = match *time_style {
241+
TimeStyle::Normal => TimeStyle::Fixed,
242+
TimeStyle::Fixed => TimeStyle::Normal,
243+
};
244+
info!("Switching time style to {:?}", next_time_style);
245+
*time_style = next_time_style;
246+
} else if input.just_pressed(KeyCode::M) {
247+
// M: cycle scroll motion style
248+
let next_move_style = match *move_style {
249+
MoveStyle::WholePixel => MoveStyle::SubPixel,
250+
MoveStyle::SubPixel => {
251+
// Re-lock the background positions to whole-pixel boundaries
252+
for mut transform in background_query.iter_mut() {
253+
transform.translation = transform.translation.round();
254+
}
255+
MoveStyle::WholePixel
256+
},
257+
};
258+
info!("Switching scroll style to {:?}", next_move_style);
259+
*move_style = next_move_style;
260+
}
261+
}
262+
263+
// Increase+loop the player sprite's frame index, per its animation timer.
264+
fn animate_runner(
265+
time: Res<Time>,
266+
texture_atlases: Res<Assets<TextureAtlas>>,
267+
mut query: Query<
268+
(
269+
&mut AnimationTimer,
270+
&mut TextureAtlasSprite,
271+
&Handle<TextureAtlas>,
272+
),
273+
With<Player>,
274+
>,
275+
) {
276+
for (mut sprite_timer, mut sprite, texture_atlas_handle) in query.iter_mut() {
277+
sprite_timer.timer.tick(time.delta());
278+
if sprite_timer.timer.finished() {
279+
let texture_atlas = texture_atlases.get(texture_atlas_handle).unwrap();
280+
sprite.index = (sprite.index + 1) % texture_atlas.textures.len();
281+
}
282+
}
283+
}
284+
285+
// Scroll the background in pixel-perfect increments, re-using the sprites as
286+
// they scroll off the left side.
287+
fn scroll_background(
288+
time: Res<Time>,
289+
time_style: Res<TimeStyle>,
290+
move_style: Res<MoveStyle>,
291+
mut move_remainder: ResMut<MoveRemainder>,
292+
mut query: Query<&mut Transform, With<Background>>,
293+
) {
294+
let delta = match *time_style {
295+
TimeStyle::Normal => time.delta_seconds(),
296+
TimeStyle::Fixed => 1.0 / 60.0,
297+
};
298+
let move_input = -Vec2::X * BG_SPEED * delta;
299+
// Complain if the raw movement amount is unexpectedly big:
300+
if move_input.x.abs() > 2.5 {
301+
info!("Big jump: {} px", move_input.x.abs());
302+
}
303+
// Calculate how many pixels to scroll this frame, and save any
304+
// leftover/leftunder for future frames:
305+
move_remainder.0 += move_input;
306+
let move_pixels = match *move_style {
307+
MoveStyle::WholePixel => move_remainder.0.round(),
308+
MoveStyle::SubPixel => move_remainder.0,
309+
};
310+
move_remainder.0 -= move_pixels;
311+
312+
// Move the background tiles.
313+
for mut transform in query.iter_mut() {
314+
// First, move this tile to the back of the line if it just scrolled past zero.
315+
if transform.translation.x < 0.0 {
316+
transform.translation.x =
317+
BG_WIDTH * (BG_TILES - 1) as f32 + 1.0 - transform.translation.x;
318+
}
319+
// Next, move the amount we calculated:
320+
transform.translation += move_pixels.extend(0.0);
321+
}
322+
}
323+
324+
// Marker struct for player sprite
325+
#[derive(Component)]
326+
struct Player;
327+
328+
// Animation time for player sprite
329+
#[derive(Component)]
330+
struct AnimationTimer {
331+
timer: Timer,
332+
}
333+
334+
// Marker struct for background sprites
335+
#[derive(Component)]
336+
struct Background;
337+
338+
// Sub-pixel movement accumulator for background sprites
339+
#[derive(Component)]
340+
struct MoveRemainder(Vec2);
341+
342+
// Timer for printing frame time analysis
343+
struct FrameAnalysisTimer(Timer);
344+
345+
// Enums for changing runtime settings
346+
#[derive(Debug)]
347+
enum TimeStyle {
348+
Normal,
349+
Fixed,
350+
}
351+
352+
#[derive(Debug)]
353+
enum MoveStyle {
354+
WholePixel,
355+
SubPixel,
356+
}

0 commit comments

Comments
 (0)