Skip to content

Commit 95bbd06

Browse files
Jondolfptsd
andauthored
Physics Diagnostics (#653)
# Objective Fixes #564. Expands significantly on #576. For both benchmarking and optimizing Avian itself, and monitoring physics performance on the user side, it can be very useful to have timing information and various other statistics tracked by the physics engine. This is also done by engines such as Rapier and Box2D. ## Solution Summary: - Track physics timers and counters in resources like `CollisionDiagnostics` and `SolverDiagnostics`. - Add `bevy_diagnostic` feature and `PhysicsDiagnosticsPlugin` for optionally writing these diagnostics to `bevy_diagnostic::DiagnosticsStore`. - Add `diagnostic_ui` feature and `PhysicsDiagnosticsUiPlugin` for optionally displaying physics diagnostics with a debug UI. ### Physics Diagnostics Resources The natural place for diagnostics in Bevy would be `bevy_diagnostic::DiagnosticsStore`. However: - It is not suitable for tracking timers spanning across several substeps without storing a timer externally. - It likely has a small amount of additional overhead. - All measurements for Bevy's diagnostics use `f64`, which makes counters a bit more awkward. Thus, most diagnostics are tracked in separate resources such as `SolverDiagnostics`: ```rust /// Diagnostics for the physics solver. #[derive(Resource, Debug, Default, Reflect)] #[reflect(Resource, Debug)] pub struct SolverDiagnostics { /// Time spent integrating velocities. pub integrate_velocities: Duration, /// Time spent warm starting the solver. pub warm_start: Duration, /// Time spent solving constraints with bias. pub solve_constraints: Duration, /// Time spent integrating positions. pub integrate_positions: Duration, /// Time spent relaxing velocities. pub relax_velocities: Duration, /// Time spent applying restitution. pub apply_restitution: Duration, /// Time spent finalizing positions. pub finalize: Duration, /// Time spent storing impulses for warm starting. pub store_impulses: Duration, /// Time spent on swept CCD. pub swept_ccd: Duration, /// The number of contact constraints generated. pub contact_constraint_count: u32, } ``` These are updated in the relevant systems. Timers should have a very small, fixed cost, so they are currently tracked by default and *cannot* be disabled (same as Box2D), aside from disabling e.g. the `SolverPlugin` itself. If it is deemed to have measurable overhead down the line, we could try putting timers behind a feature flag, though it does add some complexity. ### Integration With `DiagnosticsStore` It can still be valuable to *also* record physics diagnostics to `bevy_diagnostic::DiagnosticsStore` to benefit from the history and smoothing functionality, and to monitor things from a single shared resource. This is supported by adding the `PhysicsDiagnosticsPlugin` with the `bevy_diagnostic` feature enabled. There is some boilerplate required for registering the diagnostics, clearing the timers and counters, and writing them to the `DiagnosticsStore`. To keep things more manageable, this has been abstracted with a `PhysicsDiagnostics` trait, `register_physics_diagnostics` method, and `impl_diagnostic_paths!` macro. For the earlier `SolverDiagnostics`, the implementation looks like this: ```rust impl PhysicsDiagnostics for SolverDiagnostics { fn timer_paths(&self) -> Vec<(&'static DiagnosticPath, Duration)> { vec![ (Self::INTEGRATE_VELOCITIES, self.integrate_velocities), (Self::WARM_START, self.warm_start), (Self::SOLVE_CONSTRAINTS, self.solve_constraints), (Self::INTEGRATE_POSITIONS, self.integrate_positions), (Self::RELAX_VELOCITIES, self.relax_velocities), (Self::APPLY_RESTITUTION, self.apply_restitution), (Self::FINALIZE, self.finalize), (Self::STORE_IMPULSES, self.store_impulses), (Self::SWEPT_CCD, self.swept_ccd), ] } fn counter_paths(&self) -> Vec<(&'static DiagnosticPath, u32)> { vec![( Self::CONTACT_CONSTRAINT_COUNT, self.contact_constraint_count, )] } } impl_diagnostic_paths! { impl SolverDiagnostics { INTEGRATE_VELOCITIES: "avian/solver/integrate_velocities", WARM_START: "avian/solver/warm_start", SOLVE_CONSTRAINTS: "avian/solver/solve_constraints", INTEGRATE_POSITIONS: "avian/solver/integrate_positions", RELAX_VELOCITIES: "avian/solver/relax_velocities", APPLY_RESTITUTION: "avian/solver/apply_restitution", FINALIZE: "avian/solver/finalize", STORE_IMPULSES: "avian/solver/store_impulses", SWEPT_CCD: "avian/solver/swept_ccd", CONTACT_CONSTRAINT_COUNT: "avian/solver/contact_constraint_count", } } ``` The `SolverPlugin` can then simply call `app.register_physics_diagnostics::<SolverDiagnostics>()`, and everything should work automatically. The timers will only be written to the `DiagnosticsStore` if `PhysicsDiagnosticsPlugin` is enabled, keeping overhead small if the use of `DiagnosticsStore` is not needed. A nice benefit here is that each plugin is responsible for adding its own diagnostics, rather than there being a single place where all diagnostics are registered and stored. This is nice for modularity, and means that e.g. `SpatialQueryDiagnostics` are only added if the `SpatialQueryPlugin` is added. ### Physics Diagnostics UI Having all of these diagnostics available is nice, but viewing and displaying them in a useful way involves a decent amount of code and effort. To make this easier (and prettier), an optional debug UI for displaying physics diagnostics is provided with the `diagnostic_ui` feature and `PhysicsDiagnosticsUiPlugin`. It displays all active built-in physics diagnostics in neat groups, with both current and smoothed times shown. ![Diagnostics UI](https://github.com/user-attachments/assets/5caa3532-fefa-49e7-869b-ae40f4e20842) The visibility and settings of the UI can be configured using the `PhysicsDiagnosticsUiSettings`. ### Example Improvements The `ExampleCommonPlugin` has been updated to replace the FPS counter with these physics diagnostics, and there are now text instructions to show what the different keys do. ![move_marbles example](https://github.com/user-attachments/assets/592ed029-055b-4052-8d8e-a66d015298fd) The diagnostics UI is hidden by default in the examples. ## Differences to #576 #576 by @ptsd has an initial WIP diagnostics implementation with a simpler approach that more closely matches my original proposal in #564. It was a valuable base to build this PR on top of, but as I iterated on it, I ultimately went with quite a different approach. That PR used a single `PhysicsDiagnosticsPlugin` that set up all diagnostics manually. Timers were implemented by adding system sets before and after various parts of the simulation, and adding systems there to record spans, which were then written to the `DiagnosticsStore`. I didn't go with this approach, because: - Adding so many system sets just for diagnostics didn't feel very appealing - Adding two systems for each span adds more overhead, and might not provide as accurate timing information as just tracking time inside the actual systems - The spans in that PR were not suitable for substepping, as they didn't support accumulating time over several substeps - Registering all diagnostics in a single plugin makes things less modular, and means that you might end up with unnecessary diagnostics Instead, I simply have each plugin define its own resource for its diagnostics where relevant. Timers are handled by tracking elapsed time inside systems with `bevy::utils::Instant` (for wide platform support), and stored as `Duration`s. Writing this information to the `DiagnosticsStore` is optional. This is more modular, has less overhead, and works with substepping. It does add some complexity to the actual diagnostic management though, and makes diagnostics more spread out over the codebase, for better and for worse. --------- Co-authored-by: Patrick Dobbs <[email protected]>
1 parent c1f9722 commit 95bbd06

File tree

29 files changed

+1620
-146
lines changed

29 files changed

+1620
-146
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- uses: actions/checkout@v4
4343
- uses: dtolnay/rust-toolchain@stable
4444
- name: Run cargo test
45-
run: cargo test --no-default-features --features enhanced-determinism,collider-from-mesh,serialize,debug-plugin,avian2d/2d,avian3d/3d,avian2d/f64,avian3d/f64,default-collider,parry-f64,bevy_scene,bevy_picking
45+
run: cargo test --no-default-features --features enhanced-determinism,collider-from-mesh,serialize,debug-plugin,avian2d/2d,avian3d/3d,avian2d/f64,avian3d/f64,default-collider,parry-f64,bevy_scene,bevy_picking,diagnostic_ui
4646

4747
lints:
4848
name: Lints

crates/avian2d/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ serialize = [
5454
"bitflags/serde",
5555
]
5656

57+
# Enables writing timer and counter information to the `DiagnosticsStore` in `bevy_diagnostic`.
58+
bevy_diagnostic = []
59+
60+
# Enables the `PhysicsDiagnosticsUiPlugin` for visualizing physics diagnostics data with a debug UI.
61+
diagnostic_ui = ["bevy_diagnostic", "bevy/bevy_ui"]
62+
5763
[lib]
5864
name = "avian2d"
5965
path = "../../src/lib.rs"
@@ -88,6 +94,10 @@ bytemuck = "1.19"
8894
criterion = { version = "0.5", features = ["html_reports"] }
8995
bevy_mod_debugdump = { version = "0.12" }
9096

97+
[package.metadata.docs.rs]
98+
# Enable features when building the docs on docs.rs
99+
features = ["diagnostic_ui"]
100+
91101
[[example]]
92102
name = "dynamic_character_2d"
93103
required-features = ["2d", "default-collider"]

crates/avian3d/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ serialize = [
5656
"bitflags/serde",
5757
]
5858

59+
# Enables writing timer and counter information to the `DiagnosticsStore` in `bevy_diagnostic`.
60+
bevy_diagnostic = []
61+
62+
# Enables the `PhysicsDiagnosticsUiPlugin` for visualizing physics diagnostics data with a debug UI.
63+
diagnostic_ui = ["bevy_diagnostic", "bevy/bevy_ui"]
64+
5965
[lib]
6066
name = "avian3d"
6167
path = "../../src/lib.rs"
@@ -92,6 +98,10 @@ approx = "0.5"
9298
criterion = { version = "0.5", features = ["html_reports"] }
9399
bevy_mod_debugdump = { version = "0.12" }
94100

101+
[package.metadata.docs.rs]
102+
# Enable features when building the docs on docs.rs
103+
features = ["diagnostic_ui"]
104+
95105
[[example]]
96106
name = "dynamic_character_3d"
97107
required-features = ["3d", "default-collider", "bevy_scene"]
@@ -148,6 +158,10 @@ required-features = ["3d", "default-collider", "bevy_scene"]
148158
name = "collider_constructors"
149159
required-features = ["3d", "default-collider", "bevy_scene"]
150160

161+
[[example]]
162+
name = "diagnostics"
163+
required-features = ["3d", "default-collider", "diagnostic_ui"]
164+
151165
[[example]]
152166
name = "debugdump_3d"
153167
required-features = ["3d"]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! This example demonstrates how to enable and display diagnostics for physics,
2+
//! allowing you to monitor the performance of the physics simulation.
3+
4+
#![allow(clippy::unnecessary_cast)]
5+
6+
use avian3d::{math::*, prelude::*};
7+
use bevy::{diagnostic::FrameTimeDiagnosticsPlugin, prelude::*};
8+
9+
fn main() {
10+
App::new()
11+
.add_plugins((
12+
DefaultPlugins,
13+
PhysicsPlugins::default(),
14+
// Add the `PhysicsDiagnosticsPlugin` to write physics diagnostics
15+
// to the `DiagnosticsStore` resource in `bevy_diagnostic`.
16+
// Requires the `bevy_diagnostic` feature.
17+
PhysicsDiagnosticsPlugin,
18+
// Add the `PhysicsDiagnosticsUiPlugin` to display physics diagnostics
19+
// in a debug UI. Requires the `diagnostic_ui` feature.
20+
PhysicsDiagnosticsUiPlugin,
21+
// Optional: Add the `FrameTimeDiagnosticsPlugin` to display frame time.
22+
FrameTimeDiagnosticsPlugin,
23+
))
24+
// The `PhysicsDiagnosticsUiSettings` resource can be used to configure the diagnostics UI.
25+
//
26+
// .insert_resource(PhysicsDiagnosticsUiSettings {
27+
// enabled: false,
28+
// ..default()
29+
// })
30+
.insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.1)))
31+
.add_systems(Startup, setup)
32+
.add_systems(Update, movement)
33+
.run();
34+
}
35+
36+
// The rest of this example is just setting up a simple scene with cubes that can be moved around.
37+
38+
/// The acceleration used for movement.
39+
#[derive(Component)]
40+
struct MovementAcceleration(Scalar);
41+
42+
fn setup(
43+
mut commands: Commands,
44+
mut materials: ResMut<Assets<StandardMaterial>>,
45+
mut meshes: ResMut<Assets<Mesh>>,
46+
) {
47+
let cube_mesh = meshes.add(Cuboid::default());
48+
49+
// Ground
50+
commands.spawn((
51+
Mesh3d(cube_mesh.clone()),
52+
MeshMaterial3d(materials.add(Color::srgb(0.7, 0.7, 0.8))),
53+
Transform::from_xyz(0.0, -2.0, 0.0).with_scale(Vec3::new(100.0, 1.0, 100.0)),
54+
RigidBody::Static,
55+
Collider::cuboid(1.0, 1.0, 1.0),
56+
));
57+
58+
let cube_size = 2.0;
59+
60+
// Spawn cube stacks
61+
for x in -3..3 {
62+
for y in -3..15 {
63+
for z in -3..3 {
64+
let position = Vec3::new(x as f32, y as f32 + 3.0, z as f32) * (cube_size + 0.05);
65+
commands.spawn((
66+
Mesh3d(cube_mesh.clone()),
67+
MeshMaterial3d(materials.add(Color::srgb(0.2, 0.7, 0.9))),
68+
Transform::from_translation(position).with_scale(Vec3::splat(cube_size as f32)),
69+
RigidBody::Dynamic,
70+
Collider::cuboid(1.0, 1.0, 1.0),
71+
MovementAcceleration(10.0),
72+
));
73+
}
74+
}
75+
}
76+
77+
// Directional light
78+
commands.spawn((
79+
DirectionalLight {
80+
illuminance: 5000.0,
81+
shadows_enabled: true,
82+
..default()
83+
},
84+
Transform::default().looking_at(Vec3::new(-1.0, -2.5, -1.5), Vec3::Y),
85+
));
86+
87+
// Camera
88+
commands.spawn((
89+
Camera3d::default(),
90+
Transform::from_translation(Vec3::new(0.0, 35.0, 80.0)).looking_at(Vec3::Y * 10.0, Vec3::Y),
91+
));
92+
}
93+
94+
fn movement(
95+
time: Res<Time>,
96+
keyboard_input: Res<ButtonInput<KeyCode>>,
97+
mut query: Query<(&MovementAcceleration, &mut LinearVelocity)>,
98+
) {
99+
// Precision is adjusted so that the example works with
100+
// both the `f32` and `f64` features. Otherwise you don't need this.
101+
let delta_time = time.delta_secs_f64().adjust_precision();
102+
103+
for (movement_acceleration, mut linear_velocity) in &mut query {
104+
let up = keyboard_input.any_pressed([KeyCode::KeyW, KeyCode::ArrowUp]);
105+
let down = keyboard_input.any_pressed([KeyCode::KeyS, KeyCode::ArrowDown]);
106+
let left = keyboard_input.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]);
107+
let right = keyboard_input.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]);
108+
109+
let horizontal = right as i8 - left as i8;
110+
let vertical = down as i8 - up as i8;
111+
let direction =
112+
Vector::new(horizontal as Scalar, 0.0, vertical as Scalar).normalize_or_zero();
113+
114+
// Move in input direction
115+
if direction != Vector::ZERO {
116+
linear_velocity.x += direction.x * movement_acceleration.0 * delta_time;
117+
linear_velocity.z += direction.z * movement_acceleration.0 * delta_time;
118+
}
119+
}
120+
}

crates/examples_common_2d/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ bevy = { version = "0.15", default-features = false, features = [
2525
"bevy_window",
2626
"x11", # github actions runners don't have libxkbcommon installed, so can't use wayland
2727
] }
28-
avian2d = { path = "../avian2d", default-features = false }
28+
avian2d = { path = "../avian2d", default-features = false, features = [
29+
"diagnostic_ui",
30+
] }
Lines changed: 52 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,85 @@
1-
use std::time::Duration;
2-
31
use avian2d::prelude::*;
42
use bevy::{
5-
color::palettes::css::TOMATO,
6-
diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin},
3+
diagnostic::FrameTimeDiagnosticsPlugin, input::common_conditions::input_just_pressed,
74
prelude::*,
85
};
96

7+
/// A plugin that adds common functionality used by examples,
8+
/// such as physics diagnostics UI and the ability to pause and step the simulation.
109
pub struct ExampleCommonPlugin;
1110

1211
impl Plugin for ExampleCommonPlugin {
1312
fn build(&self, app: &mut App) {
13+
// Add diagnostics.
1414
app.add_plugins((
15+
PhysicsDiagnosticsPlugin,
16+
PhysicsDiagnosticsUiPlugin,
1517
FrameTimeDiagnosticsPlugin,
16-
#[cfg(feature = "use-debug-plugin")]
17-
PhysicsDebugPlugin::default(),
18-
))
19-
.init_state::<AppState>()
20-
.add_systems(Startup, setup)
21-
.add_systems(
22-
OnEnter(AppState::Paused),
23-
|mut time: ResMut<Time<Physics>>| time.pause(),
24-
)
25-
.add_systems(
26-
OnExit(AppState::Paused),
27-
|mut time: ResMut<Time<Physics>>| time.unpause(),
28-
)
29-
.add_systems(Update, update_fps_text)
30-
.add_systems(Update, pause_button)
31-
.add_systems(Update, step_button.run_if(in_state(AppState::Paused)));
18+
));
19+
20+
// Configure the default physics diagnostics UI.
21+
app.insert_resource(PhysicsDiagnosticsUiSettings {
22+
enabled: false,
23+
..default()
24+
});
25+
26+
// Spawn text instructions for keybinds.
27+
app.add_systems(Startup, setup_key_instructions);
28+
29+
// Add systems for toggling the diagnostics UI and pausing and stepping the simulation.
30+
app.add_systems(
31+
Update,
32+
(
33+
toggle_diagnostics_ui.run_if(input_just_pressed(KeyCode::KeyU)),
34+
toggle_paused.run_if(input_just_pressed(KeyCode::KeyP)),
35+
step.run_if(physics_paused.and(input_just_pressed(KeyCode::Enter))),
36+
),
37+
);
38+
}
39+
40+
#[cfg(feature = "use-debug-plugin")]
41+
fn finish(&self, app: &mut App) {
42+
// Add the physics debug plugin automatically if the `use-debug-plugin` feature is enabled
43+
// and the plugin is not already added.
44+
if !app.is_plugin_added::<PhysicsDebugPlugin>() {
45+
app.add_plugins(PhysicsDebugPlugin::default());
46+
}
3247
}
3348
}
3449

35-
#[derive(Debug, Clone, Eq, PartialEq, Hash, States, Default)]
36-
pub enum AppState {
37-
Paused,
38-
#[default]
39-
Running,
50+
fn toggle_diagnostics_ui(mut settings: ResMut<PhysicsDiagnosticsUiSettings>) {
51+
settings.enabled = !settings.enabled;
4052
}
4153

42-
fn pause_button(
43-
current_state: ResMut<State<AppState>>,
44-
mut next_state: ResMut<NextState<AppState>>,
45-
keys: Res<ButtonInput<KeyCode>>,
46-
) {
47-
if keys.just_pressed(KeyCode::KeyP) {
48-
let new_state = match current_state.get() {
49-
AppState::Paused => AppState::Running,
50-
AppState::Running => AppState::Paused,
51-
};
52-
next_state.set(new_state);
53-
}
54+
fn physics_paused(time: Res<Time<Physics>>) -> bool {
55+
time.is_paused()
5456
}
5557

56-
fn step_button(mut time: ResMut<Time<Physics>>, keys: Res<ButtonInput<KeyCode>>) {
57-
if keys.just_pressed(KeyCode::Enter) {
58-
time.advance_by(Duration::from_secs_f64(1.0 / 60.0));
58+
fn toggle_paused(mut time: ResMut<Time<Physics>>) {
59+
if time.is_paused() {
60+
time.unpause();
61+
} else {
62+
time.pause();
5963
}
6064
}
6165

62-
#[derive(Component)]
63-
struct FpsText;
66+
/// Advances the physics simulation by one `Time<Fixed>` time step.
67+
fn step(mut physics_time: ResMut<Time<Physics>>, fixed_time: Res<Time<Fixed>>) {
68+
physics_time.advance_by(fixed_time.delta());
69+
}
6470

65-
fn setup(mut commands: Commands) {
71+
fn setup_key_instructions(mut commands: Commands) {
6672
commands.spawn((
67-
Text::new("FPS: "),
73+
Text::new("U: Diagnostics UI | P: Pause/Unpause | Enter: Step"),
6874
TextFont {
69-
font_size: 20.0,
75+
font_size: 10.0,
7076
..default()
7177
},
72-
TextColor::from(TOMATO),
7378
Node {
7479
position_type: PositionType::Absolute,
7580
top: Val::Px(5.0),
76-
left: Val::Px(5.0),
81+
right: Val::Px(5.0),
7782
..default()
7883
},
79-
FpsText,
8084
));
8185
}
82-
83-
fn update_fps_text(diagnostics: Res<DiagnosticsStore>, mut query: Query<&mut Text, With<FpsText>>) {
84-
for mut text in &mut query {
85-
if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) {
86-
if let Some(value) = fps.smoothed() {
87-
// Update the value of the second section
88-
text.0 = format!("FPS: {value:.2}");
89-
}
90-
}
91-
}
92-
}

crates/examples_common_3d/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,6 @@ bevy = { version = "0.15", default-features = false, features = [
2828
"bevy_window",
2929
"x11", # github actions runners don't have libxkbcommon installed, so can't use wayland
3030
] }
31-
avian3d = { path = "../avian3d", default-features = false }
31+
avian3d = { path = "../avian3d", default-features = false, features = [
32+
"diagnostic_ui",
33+
] }

0 commit comments

Comments
 (0)