Skip to content

Commit 7e80881

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 d353fbc commit 7e80881

File tree

3 files changed

+354
-0
lines changed

3 files changed

+354
-0
lines changed

Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,10 @@ path = "examples/stress_tests/many_lights.rs"
554554
name = "many_sprites"
555555
path = "examples/stress_tests/many_sprites.rs"
556556

557+
[[example]]
558+
name = "time_smoothness"
559+
path = "examples/stress_tests/time_smoothness.rs"
560+
557561
[[example]]
558562
name = "transform_hierarchy"
559563
path = "examples/stress_tests/transform_hierarchy.rs"

examples/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ Example | File | Description
267267
`many_foxes` | [`stress_tests/many_foxes.rs`](./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.
268268
`many_lights` | [`stress_tests/many_lights.rs`](./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.
269269
`many_sprites` | [`stress_tests/many_sprites.rs`](./stress_tests/many_sprites.rs) | Displays many sprites in a grid arragement! Used for performance testing.
270+
`time_smoothness` | [`stress_tests/time_smoothness.rs`](./stress_tests/time_smoothness.rs) | Scrolls a simple background behind an animated sprite. Used for performance consistency testing.
270271
`transform_hierarchy.rs` | [`stress_tests/transform_hierarchy.rs`](./stress_tests/transform_hierarchy.rs) | Various test cases for hierarchy and transform propagation performance
271272

272273
## Tests
+349
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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+
window::{PresentMode, WindowMode},
34+
};
35+
36+
// BG_WIDTH is smaller than the image's actual pixel width, because we want the
37+
// empty space on the background tiles to overlap a bit. That way there's always
38+
// a "landmark" on screen at the default window size.
39+
const BG_WIDTH: f32 = 755.0;
40+
const BG_HEIGHT: f32 = 363.0;
41+
const BG_TILES: usize = 3;
42+
const BG_SPEED: f32 = 120.0;
43+
44+
const CUSTOM_FRAME_TIME: DiagnosticId =
45+
DiagnosticId::from_u128(76860576947891895965111337840552081898);
46+
const MAX_FRAME_HISTORY: usize = 800;
47+
const FRAME_ANALYSIS_INTERVAL_SECONDS: f32 = 10.0;
48+
49+
fn main() {
50+
App::new()
51+
.insert_resource(WindowDescriptor {
52+
present_mode: PresentMode::Fifo,
53+
mode: WindowMode::Windowed,
54+
..Default::default()
55+
})
56+
.add_plugins(DefaultPlugins)
57+
// Adds frame time diagnostics
58+
.add_startup_system(setup_diagnostics)
59+
.add_system(update_diagnostics)
60+
.add_system(log_diagnostics)
61+
.insert_resource(FrameAnalysisTimer(Timer::from_seconds(
62+
FRAME_ANALYSIS_INTERVAL_SECONDS,
63+
true,
64+
)))
65+
// Main app setup
66+
.add_startup_system(setup)
67+
.insert_resource(MoveRemainder(Vec2::ZERO))
68+
.insert_resource(TimeStyle::Normal)
69+
.insert_resource(MoveStyle::WholePixel)
70+
.add_system(change_settings)
71+
.add_system(animate_runner)
72+
.add_system(scroll_background)
73+
.run();
74+
}
75+
76+
// Create a custom frame time diagnostic. We need this because
77+
// FrameTimeDiagnosticsPlugin only keeps 20 frames, which is too narrow a view
78+
// to be useful when hunting irregular blips.
79+
fn setup_diagnostics(mut diagnostics: ResMut<Diagnostics>) {
80+
diagnostics
81+
.add(Diagnostic::new(CUSTOM_FRAME_TIME, "frame_time", MAX_FRAME_HISTORY).with_suffix("s"));
82+
}
83+
84+
// Update our custom frame time diagnostic.
85+
fn update_diagnostics(mut diagnostics: ResMut<Diagnostics>, time: Res<Time>) {
86+
if time.delta_seconds_f64() != 0.0 {
87+
diagnostics.add_measurement(CUSTOM_FRAME_TIME, time.delta_seconds_f64());
88+
}
89+
}
90+
91+
// Periodically analyze recent frame times and print a summary.
92+
fn log_diagnostics(
93+
mut timer: ResMut<FrameAnalysisTimer>,
94+
diagnostics: Res<Diagnostics>,
95+
time: Res<Time>,
96+
) {
97+
timer.0.tick(time.delta());
98+
if timer.0.finished() {
99+
let frame_times = diagnostics.get(CUSTOM_FRAME_TIME).unwrap();
100+
if let Some(average) = frame_times.average() {
101+
if let Some(std_dev) = std_deviation(frame_times) {
102+
let mut sorted_times: Vec<f64> = frame_times.values().map(|v| *v).collect();
103+
sorted_times.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
104+
105+
let count = sorted_times.len();
106+
107+
// Indexes corresponding to percentile ranks:
108+
let p95 = (0.95 * count as f32).round() as usize - 1;
109+
let p99 = (0.99 * count as f32).round() as usize - 1;
110+
let p99_5 = (0.995 * count as f32).round() as usize - 1;
111+
112+
let min = sorted_times.first().unwrap();
113+
let max = sorted_times.last().unwrap();
114+
115+
info!("-------------------------");
116+
info!("Average frame time: {:.6}", average);
117+
info!("Standard deviation: {:.6}", std_dev);
118+
info!("Shortest frame: {:.6}", min);
119+
info!("95th percentile: {:.6}", sorted_times[p95]);
120+
info!("99th percentile: {:.6}", sorted_times[p99]);
121+
info!("99.5th percentile: {:.6}", sorted_times[p99_5]);
122+
info!("Longest frame: {:.6}", max);
123+
info!("-------------------------");
124+
}
125+
}
126+
}
127+
}
128+
129+
fn std_deviation(diagnostic: &Diagnostic) -> Option<f64> {
130+
if let Some(average) = diagnostic.average() {
131+
let variance = diagnostic
132+
.values()
133+
.map(|val| {
134+
let diff = average - *val;
135+
diff * diff
136+
})
137+
.sum::<f64>()
138+
/ diagnostic.history_len() as f64;
139+
Some(variance.sqrt())
140+
} else {
141+
None
142+
}
143+
}
144+
145+
// Set up entities and assets.
146+
fn setup(
147+
mut commands: Commands,
148+
asset_server: Res<AssetServer>,
149+
mut texture_atlases: ResMut<Assets<TextureAtlas>>,
150+
) {
151+
// Locate the center(ish) of the background conveyer belt, so we can
152+
// position the player and camera there.
153+
let bg_center = Vec2::new(BG_WIDTH * BG_TILES as f32 / 2.0, BG_HEIGHT / 2.0).round();
154+
155+
// Set up camera.
156+
let mut camera_bundle = OrthographicCameraBundle::new_2d();
157+
camera_bundle.orthographic_projection.scale = 1.0 / 4.0;
158+
camera_bundle.transform.translation += bg_center.extend(0.0);
159+
commands.spawn_bundle(camera_bundle);
160+
commands.spawn_bundle(UiCameraBundle::default());
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+
) {
214+
let window = windows.primary_mut();
215+
if input.just_pressed(KeyCode::P) {
216+
// P: cycle PresentMode.
217+
let next_present_mode = match window.present_mode() {
218+
PresentMode::Fifo => PresentMode::Mailbox,
219+
PresentMode::Mailbox => PresentMode::Immediate,
220+
PresentMode::Immediate => PresentMode::Fifo,
221+
};
222+
info!("Switching present mode to {:?}", next_present_mode);
223+
window.set_present_mode(next_present_mode);
224+
} else if input.just_pressed(KeyCode::W) {
225+
// W: cycle WindowMode.
226+
let next_window_mode = match window.mode() {
227+
WindowMode::Windowed => WindowMode::BorderlessFullscreen,
228+
WindowMode::BorderlessFullscreen => WindowMode::Fullscreen,
229+
WindowMode::Fullscreen => WindowMode::Windowed,
230+
_ => WindowMode::Windowed,
231+
};
232+
info!("Switching window mode to {:?}", next_window_mode);
233+
window.set_mode(next_window_mode);
234+
if next_window_mode == WindowMode::Windowed {
235+
window.set_resolution(1280.0, 720.0);
236+
}
237+
} else if input.just_pressed(KeyCode::T) {
238+
// T: cycle delta time style
239+
let next_time_style = match *time_style {
240+
TimeStyle::Normal => TimeStyle::Fixed,
241+
TimeStyle::Fixed => TimeStyle::Normal,
242+
};
243+
info!("Switching time style to {:?}", next_time_style);
244+
*time_style = next_time_style;
245+
} else if input.just_pressed(KeyCode::M) {
246+
// M: cycle scroll motion style
247+
let next_move_style = match *move_style {
248+
MoveStyle::WholePixel => MoveStyle::SubPixel,
249+
MoveStyle::SubPixel => MoveStyle::WholePixel,
250+
};
251+
info!("Switching scroll style to {:?}", next_move_style);
252+
*move_style = next_move_style;
253+
}
254+
}
255+
256+
// Increase+loop the player sprite's frame index, per its animation timer.
257+
fn animate_runner(
258+
time: Res<Time>,
259+
texture_atlases: Res<Assets<TextureAtlas>>,
260+
mut query: Query<
261+
(
262+
&mut AnimationTimer,
263+
&mut TextureAtlasSprite,
264+
&Handle<TextureAtlas>,
265+
),
266+
With<Player>,
267+
>,
268+
) {
269+
for (mut sprite_timer, mut sprite, texture_atlas_handle) in query.iter_mut() {
270+
sprite_timer.timer.tick(time.delta());
271+
if sprite_timer.timer.finished() {
272+
let texture_atlas = texture_atlases.get(texture_atlas_handle).unwrap();
273+
sprite.index = (sprite.index + 1) % texture_atlas.textures.len();
274+
}
275+
}
276+
}
277+
278+
// Scroll the background in pixel-perfect increments, re-using the sprites as
279+
// they scroll off the left side.
280+
fn scroll_background(
281+
time: Res<Time>,
282+
time_style: Res<TimeStyle>,
283+
move_style: Res<MoveStyle>,
284+
mut move_remainder: ResMut<MoveRemainder>,
285+
mut query: Query<&mut Transform, With<Background>>,
286+
) {
287+
let delta = match *time_style {
288+
TimeStyle::Normal => time.delta_seconds(),
289+
TimeStyle::Fixed => 1.0 / 60.0,
290+
};
291+
let move_input = -Vec2::X * BG_SPEED * delta;
292+
// Complain if the raw movement amount is unexpectedly big:
293+
if move_input.x.abs() > 2.5 {
294+
info!("Big jump: {} px", move_input.x.abs());
295+
}
296+
// Calculate how many pixels to scroll this frame, and save any
297+
// leftover/leftunder for future frames:
298+
move_remainder.0 += move_input;
299+
let move_pixels = match *move_style {
300+
MoveStyle::WholePixel => move_remainder.0.round(),
301+
MoveStyle::SubPixel => move_remainder.0,
302+
};
303+
move_remainder.0 -= move_pixels;
304+
305+
// Move the background tiles.
306+
for mut transform in query.iter_mut() {
307+
// First, move this tile to the back of the line if it just scrolled past zero.
308+
if transform.translation.x < 0.0 {
309+
transform.translation.x =
310+
BG_WIDTH * (BG_TILES - 1) as f32 + 1.0 - transform.translation.x;
311+
}
312+
// Next, move the amount we calculated:
313+
transform.translation += move_pixels.extend(0.0);
314+
}
315+
}
316+
317+
// Marker struct for player sprite
318+
#[derive(Component)]
319+
struct Player;
320+
321+
// Animation time for player sprite
322+
#[derive(Component)]
323+
struct AnimationTimer {
324+
timer: Timer,
325+
}
326+
327+
// Marker struct for background sprites
328+
#[derive(Component)]
329+
struct Background;
330+
331+
// Sub-pixel movement accumulator for background sprites
332+
#[derive(Component)]
333+
struct MoveRemainder(Vec2);
334+
335+
// Timer for printing frame time analysis
336+
struct FrameAnalysisTimer(Timer);
337+
338+
// Enums for changing runtime settings
339+
#[derive(Debug)]
340+
enum TimeStyle {
341+
Normal,
342+
Fixed,
343+
}
344+
345+
#[derive(Debug)]
346+
enum MoveStyle {
347+
WholePixel,
348+
SubPixel,
349+
}

0 commit comments

Comments
 (0)