Skip to content

Commit

Permalink
Physics Diagnostics (#653)
Browse files Browse the repository at this point in the history
# 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]>
  • Loading branch information
Jondolf and ptsd authored Feb 8, 2025
1 parent c1f9722 commit 95bbd06
Show file tree
Hide file tree
Showing 29 changed files with 1,620 additions and 146 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run cargo test
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
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

lints:
name: Lints
Expand Down
10 changes: 10 additions & 0 deletions crates/avian2d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ serialize = [
"bitflags/serde",
]

# Enables writing timer and counter information to the `DiagnosticsStore` in `bevy_diagnostic`.
bevy_diagnostic = []

# Enables the `PhysicsDiagnosticsUiPlugin` for visualizing physics diagnostics data with a debug UI.
diagnostic_ui = ["bevy_diagnostic", "bevy/bevy_ui"]

[lib]
name = "avian2d"
path = "../../src/lib.rs"
Expand Down Expand Up @@ -88,6 +94,10 @@ bytemuck = "1.19"
criterion = { version = "0.5", features = ["html_reports"] }
bevy_mod_debugdump = { version = "0.12" }

[package.metadata.docs.rs]
# Enable features when building the docs on docs.rs
features = ["diagnostic_ui"]

[[example]]
name = "dynamic_character_2d"
required-features = ["2d", "default-collider"]
Expand Down
14 changes: 14 additions & 0 deletions crates/avian3d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ serialize = [
"bitflags/serde",
]

# Enables writing timer and counter information to the `DiagnosticsStore` in `bevy_diagnostic`.
bevy_diagnostic = []

# Enables the `PhysicsDiagnosticsUiPlugin` for visualizing physics diagnostics data with a debug UI.
diagnostic_ui = ["bevy_diagnostic", "bevy/bevy_ui"]

[lib]
name = "avian3d"
path = "../../src/lib.rs"
Expand Down Expand Up @@ -92,6 +98,10 @@ approx = "0.5"
criterion = { version = "0.5", features = ["html_reports"] }
bevy_mod_debugdump = { version = "0.12" }

[package.metadata.docs.rs]
# Enable features when building the docs on docs.rs
features = ["diagnostic_ui"]

[[example]]
name = "dynamic_character_3d"
required-features = ["3d", "default-collider", "bevy_scene"]
Expand Down Expand Up @@ -148,6 +158,10 @@ required-features = ["3d", "default-collider", "bevy_scene"]
name = "collider_constructors"
required-features = ["3d", "default-collider", "bevy_scene"]

[[example]]
name = "diagnostics"
required-features = ["3d", "default-collider", "diagnostic_ui"]

[[example]]
name = "debugdump_3d"
required-features = ["3d"]
Expand Down
120 changes: 120 additions & 0 deletions crates/avian3d/examples/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! This example demonstrates how to enable and display diagnostics for physics,
//! allowing you to monitor the performance of the physics simulation.
#![allow(clippy::unnecessary_cast)]

use avian3d::{math::*, prelude::*};
use bevy::{diagnostic::FrameTimeDiagnosticsPlugin, prelude::*};

fn main() {
App::new()
.add_plugins((
DefaultPlugins,
PhysicsPlugins::default(),
// Add the `PhysicsDiagnosticsPlugin` to write physics diagnostics
// to the `DiagnosticsStore` resource in `bevy_diagnostic`.
// Requires the `bevy_diagnostic` feature.
PhysicsDiagnosticsPlugin,
// Add the `PhysicsDiagnosticsUiPlugin` to display physics diagnostics
// in a debug UI. Requires the `diagnostic_ui` feature.
PhysicsDiagnosticsUiPlugin,
// Optional: Add the `FrameTimeDiagnosticsPlugin` to display frame time.
FrameTimeDiagnosticsPlugin,
))
// The `PhysicsDiagnosticsUiSettings` resource can be used to configure the diagnostics UI.
//
// .insert_resource(PhysicsDiagnosticsUiSettings {
// enabled: false,
// ..default()
// })
.insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.1)))
.add_systems(Startup, setup)
.add_systems(Update, movement)
.run();
}

// The rest of this example is just setting up a simple scene with cubes that can be moved around.

/// The acceleration used for movement.
#[derive(Component)]
struct MovementAcceleration(Scalar);

fn setup(
mut commands: Commands,
mut materials: ResMut<Assets<StandardMaterial>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
let cube_mesh = meshes.add(Cuboid::default());

// Ground
commands.spawn((
Mesh3d(cube_mesh.clone()),
MeshMaterial3d(materials.add(Color::srgb(0.7, 0.7, 0.8))),
Transform::from_xyz(0.0, -2.0, 0.0).with_scale(Vec3::new(100.0, 1.0, 100.0)),
RigidBody::Static,
Collider::cuboid(1.0, 1.0, 1.0),
));

let cube_size = 2.0;

// Spawn cube stacks
for x in -3..3 {
for y in -3..15 {
for z in -3..3 {
let position = Vec3::new(x as f32, y as f32 + 3.0, z as f32) * (cube_size + 0.05);
commands.spawn((
Mesh3d(cube_mesh.clone()),
MeshMaterial3d(materials.add(Color::srgb(0.2, 0.7, 0.9))),
Transform::from_translation(position).with_scale(Vec3::splat(cube_size as f32)),
RigidBody::Dynamic,
Collider::cuboid(1.0, 1.0, 1.0),
MovementAcceleration(10.0),
));
}
}
}

// Directional light
commands.spawn((
DirectionalLight {
illuminance: 5000.0,
shadows_enabled: true,
..default()
},
Transform::default().looking_at(Vec3::new(-1.0, -2.5, -1.5), Vec3::Y),
));

// Camera
commands.spawn((
Camera3d::default(),
Transform::from_translation(Vec3::new(0.0, 35.0, 80.0)).looking_at(Vec3::Y * 10.0, Vec3::Y),
));
}

fn movement(
time: Res<Time>,
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&MovementAcceleration, &mut LinearVelocity)>,
) {
// Precision is adjusted so that the example works with
// both the `f32` and `f64` features. Otherwise you don't need this.
let delta_time = time.delta_secs_f64().adjust_precision();

for (movement_acceleration, mut linear_velocity) in &mut query {
let up = keyboard_input.any_pressed([KeyCode::KeyW, KeyCode::ArrowUp]);
let down = keyboard_input.any_pressed([KeyCode::KeyS, KeyCode::ArrowDown]);
let left = keyboard_input.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]);
let right = keyboard_input.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]);

let horizontal = right as i8 - left as i8;
let vertical = down as i8 - up as i8;
let direction =
Vector::new(horizontal as Scalar, 0.0, vertical as Scalar).normalize_or_zero();

// Move in input direction
if direction != Vector::ZERO {
linear_velocity.x += direction.x * movement_acceleration.0 * delta_time;
linear_velocity.z += direction.z * movement_acceleration.0 * delta_time;
}
}
}
4 changes: 3 additions & 1 deletion crates/examples_common_2d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ bevy = { version = "0.15", default-features = false, features = [
"bevy_window",
"x11", # github actions runners don't have libxkbcommon installed, so can't use wayland
] }
avian2d = { path = "../avian2d", default-features = false }
avian2d = { path = "../avian2d", default-features = false, features = [
"diagnostic_ui",
] }
111 changes: 52 additions & 59 deletions crates/examples_common_2d/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,92 +1,85 @@
use std::time::Duration;

use avian2d::prelude::*;
use bevy::{
color::palettes::css::TOMATO,
diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin},
diagnostic::FrameTimeDiagnosticsPlugin, input::common_conditions::input_just_pressed,
prelude::*,
};

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

impl Plugin for ExampleCommonPlugin {
fn build(&self, app: &mut App) {
// Add diagnostics.
app.add_plugins((
PhysicsDiagnosticsPlugin,
PhysicsDiagnosticsUiPlugin,
FrameTimeDiagnosticsPlugin,
#[cfg(feature = "use-debug-plugin")]
PhysicsDebugPlugin::default(),
))
.init_state::<AppState>()
.add_systems(Startup, setup)
.add_systems(
OnEnter(AppState::Paused),
|mut time: ResMut<Time<Physics>>| time.pause(),
)
.add_systems(
OnExit(AppState::Paused),
|mut time: ResMut<Time<Physics>>| time.unpause(),
)
.add_systems(Update, update_fps_text)
.add_systems(Update, pause_button)
.add_systems(Update, step_button.run_if(in_state(AppState::Paused)));
));

// Configure the default physics diagnostics UI.
app.insert_resource(PhysicsDiagnosticsUiSettings {
enabled: false,
..default()
});

// Spawn text instructions for keybinds.
app.add_systems(Startup, setup_key_instructions);

// Add systems for toggling the diagnostics UI and pausing and stepping the simulation.
app.add_systems(
Update,
(
toggle_diagnostics_ui.run_if(input_just_pressed(KeyCode::KeyU)),
toggle_paused.run_if(input_just_pressed(KeyCode::KeyP)),
step.run_if(physics_paused.and(input_just_pressed(KeyCode::Enter))),
),
);
}

#[cfg(feature = "use-debug-plugin")]
fn finish(&self, app: &mut App) {
// Add the physics debug plugin automatically if the `use-debug-plugin` feature is enabled
// and the plugin is not already added.
if !app.is_plugin_added::<PhysicsDebugPlugin>() {
app.add_plugins(PhysicsDebugPlugin::default());
}
}
}

#[derive(Debug, Clone, Eq, PartialEq, Hash, States, Default)]
pub enum AppState {
Paused,
#[default]
Running,
fn toggle_diagnostics_ui(mut settings: ResMut<PhysicsDiagnosticsUiSettings>) {
settings.enabled = !settings.enabled;
}

fn pause_button(
current_state: ResMut<State<AppState>>,
mut next_state: ResMut<NextState<AppState>>,
keys: Res<ButtonInput<KeyCode>>,
) {
if keys.just_pressed(KeyCode::KeyP) {
let new_state = match current_state.get() {
AppState::Paused => AppState::Running,
AppState::Running => AppState::Paused,
};
next_state.set(new_state);
}
fn physics_paused(time: Res<Time<Physics>>) -> bool {
time.is_paused()
}

fn step_button(mut time: ResMut<Time<Physics>>, keys: Res<ButtonInput<KeyCode>>) {
if keys.just_pressed(KeyCode::Enter) {
time.advance_by(Duration::from_secs_f64(1.0 / 60.0));
fn toggle_paused(mut time: ResMut<Time<Physics>>) {
if time.is_paused() {
time.unpause();
} else {
time.pause();
}
}

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

fn setup(mut commands: Commands) {
fn setup_key_instructions(mut commands: Commands) {
commands.spawn((
Text::new("FPS: "),
Text::new("U: Diagnostics UI | P: Pause/Unpause | Enter: Step"),
TextFont {
font_size: 20.0,
font_size: 10.0,
..default()
},
TextColor::from(TOMATO),
Node {
position_type: PositionType::Absolute,
top: Val::Px(5.0),
left: Val::Px(5.0),
right: Val::Px(5.0),
..default()
},
FpsText,
));
}

fn update_fps_text(diagnostics: Res<DiagnosticsStore>, mut query: Query<&mut Text, With<FpsText>>) {
for mut text in &mut query {
if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) {
if let Some(value) = fps.smoothed() {
// Update the value of the second section
text.0 = format!("FPS: {value:.2}");
}
}
}
}
4 changes: 3 additions & 1 deletion crates/examples_common_3d/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ bevy = { version = "0.15", default-features = false, features = [
"bevy_window",
"x11", # github actions runners don't have libxkbcommon installed, so can't use wayland
] }
avian3d = { path = "../avian3d", default-features = false }
avian3d = { path = "../avian3d", default-features = false, features = [
"diagnostic_ui",
] }
Loading

0 comments on commit 95bbd06

Please sign in to comment.