Skip to main content

bevy_rapier3d/plugin/
diagnostics.rs

1//! Integration of the simulation statistics with Bevy’s diagnostics.
2
3use bevy::diagnostic::{
4    Diagnostic, DiagnosticPath, Diagnostics, DiagnosticsStore, RegisterDiagnostic,
5};
6use bevy::ecs::entity::EntityHashSet;
7use bevy::ecs::{intern::Interned, schedule::ScheduleLabel};
8use bevy::prelude::*;
9
10use crate::plugin::context::{
11    RapierContextColliders, RapierContextJoints, RapierContextSimulation, RapierRigidBodySet,
12};
13use crate::plugin::PhysicsSet;
14
15/// Plugin registering Bevy [`Diagnostic`]s measuring the simulation of every Rapier context.
16///
17/// The measurements are taken after each physics update that executed at least one simulation
18/// step, from [`RapierContextSimulation::step_stats`] (which relies on Rapier’s counters) and
19/// from the current state of the contexts. They can be displayed, for example, with Bevy’s
20/// `LogDiagnosticsPlugin`.
21///
22/// The diagnostics at the paths of the associated constants (e.g. [`Self::STEP_TIME`]) are sums
23/// over all the contexts (counts and timings alike), so they measure the whole physics workload
24/// of the app. Enable [`Self::per_context`] to also measure each context separately at the paths
25/// returned by [`Self::context_path`], registered when a context is first measured.
26///
27/// Only [`Self::STEP_TIME`] and the counts are measured by default: the other timings rely on
28/// Rapier’s internal profiler and are only registered if the `profiler` feature is enabled. This
29/// plugin enables the [`PhysicsPipeline::counters`](rapier::pipeline::PhysicsPipeline::counters)
30/// of every context.
31pub struct RapierDiagnosticsPlugin {
32    schedule: Interned<dyn ScheduleLabel>,
33    /// The maximum number of measurements kept in the history of each diagnostic.
34    pub max_history_length: usize,
35    /// Whether each context is also measured separately, at the paths returned by
36    /// [`Self::context_path`] (default: `false`).
37    pub per_context: bool,
38}
39
40impl Default for RapierDiagnosticsPlugin {
41    fn default() -> Self {
42        Self {
43            schedule: PostUpdate.intern(),
44            max_history_length: bevy::diagnostic::DEFAULT_MAX_HISTORY_LENGTH,
45            per_context: false,
46        }
47    }
48}
49
50impl RapierDiagnosticsPlugin {
51    /// Total wall-clock time spent running the simulation steps of a physics update, in
52    /// milliseconds.
53    pub const STEP_TIME: DiagnosticPath = DiagnosticPath::const_new("rapier/step_time");
54    /// Number of simulation steps (including substeps) executed by a physics update.
55    pub const STEPS: DiagnosticPath = DiagnosticPath::const_new("rapier/steps");
56    /// Time spent on collision detection, in milliseconds (requires the `profiler` feature).
57    pub const COLLISION_DETECTION_TIME: DiagnosticPath =
58        DiagnosticPath::const_new("rapier/collision_detection_time");
59    /// Time spent in the broad-phase, in milliseconds (requires the `profiler` feature).
60    pub const BROAD_PHASE_TIME: DiagnosticPath =
61        DiagnosticPath::const_new("rapier/broad_phase_time");
62    /// Time spent in the narrow-phase, in milliseconds (requires the `profiler` feature).
63    pub const NARROW_PHASE_TIME: DiagnosticPath =
64        DiagnosticPath::const_new("rapier/narrow_phase_time");
65    /// Time spent building the simulation islands, in milliseconds (requires the `profiler`
66    /// feature).
67    pub const ISLAND_CONSTRUCTION_TIME: DiagnosticPath =
68        DiagnosticPath::const_new("rapier/island_construction_time");
69    /// Time spent in the constraints solver, in milliseconds (requires the `profiler` feature).
70    pub const SOLVER_TIME: DiagnosticPath = DiagnosticPath::const_new("rapier/solver_time");
71    /// Time spent on Continuous Collision Detection, in milliseconds (requires the `profiler`
72    /// feature).
73    pub const CCD_TIME: DiagnosticPath = DiagnosticPath::const_new("rapier/ccd_time");
74    /// Time spent updating the rigid-bodies’ positions and velocities, in milliseconds (requires
75    /// the `profiler` feature).
76    pub const UPDATE_TIME: DiagnosticPath = DiagnosticPath::const_new("rapier/update_time");
77    /// Number of CCD substeps executed by a physics update (zero when the CCD didn't need to act).
78    pub const CCD_SUBSTEPS: DiagnosticPath = DiagnosticPath::const_new("rapier/ccd_substeps");
79    /// Number of rigid-bodies.
80    pub const RIGID_BODIES: DiagnosticPath = DiagnosticPath::const_new("rapier/rigid_bodies");
81    /// Number of awake (active) dynamic and kinematic rigid-bodies.
82    pub const ACTIVE_BODIES: DiagnosticPath = DiagnosticPath::const_new("rapier/active_bodies");
83    /// Number of colliders.
84    pub const COLLIDERS: DiagnosticPath = DiagnosticPath::const_new("rapier/colliders");
85    /// Number of impulse joints.
86    pub const IMPULSE_JOINTS: DiagnosticPath = DiagnosticPath::const_new("rapier/impulse_joints");
87    /// Number of multibody joints.
88    pub const MULTIBODY_JOINTS: DiagnosticPath =
89        DiagnosticPath::const_new("rapier/multibody_joints");
90    /// Number of contact pairs tracked by the narrow-phase (touching or not) after the last step.
91    pub const CONTACT_PAIRS: DiagnosticPath = DiagnosticPath::const_new("rapier/contact_pairs");
92    /// Number of contact pairs with at least one active contact.
93    pub const ACTIVE_CONTACT_PAIRS: DiagnosticPath =
94        DiagnosticPath::const_new("rapier/active_contact_pairs");
95    /// Number of contact points (soft-body contacts included) handed to the constraints solver
96    /// by a physics update.
97    pub const CONTACT_CONSTRAINTS: DiagnosticPath =
98        DiagnosticPath::const_new("rapier/contact_constraints");
99    /// Number of contact manifolds and impulse joints handed to the constraints solver by a
100    /// physics update. The contacts and joints of sleeping bodies aren't counted.
101    pub const SOLVER_CONSTRAINTS: DiagnosticPath =
102        DiagnosticPath::const_new("rapier/solver_constraints");
103    /// Number of intersecting sensor pairs.
104    pub const INTERSECTIONS: DiagnosticPath = DiagnosticPath::const_new("rapier/intersections");
105
106    /// The diagnostics measured in every build, with their unit suffix.
107    const ALWAYS_MEASURED: [(DiagnosticPath, &'static str); 13] = [
108        (Self::STEP_TIME, "ms"),
109        (Self::STEPS, ""),
110        (Self::CCD_SUBSTEPS, ""),
111        (Self::RIGID_BODIES, ""),
112        (Self::ACTIVE_BODIES, ""),
113        (Self::COLLIDERS, ""),
114        (Self::IMPULSE_JOINTS, ""),
115        (Self::MULTIBODY_JOINTS, ""),
116        (Self::CONTACT_PAIRS, ""),
117        (Self::ACTIVE_CONTACT_PAIRS, ""),
118        (Self::CONTACT_CONSTRAINTS, ""),
119        (Self::SOLVER_CONSTRAINTS, ""),
120        (Self::INTERSECTIONS, ""),
121    ];
122
123    /// The timings relying on Rapier’s internal profiler.
124    #[cfg(feature = "profiler")]
125    const PROFILER_TIMINGS: [DiagnosticPath; 7] = [
126        Self::COLLISION_DETECTION_TIME,
127        Self::BROAD_PHASE_TIME,
128        Self::NARROW_PHASE_TIME,
129        Self::ISLAND_CONSTRUCTION_TIME,
130        Self::SOLVER_TIME,
131        Self::CCD_TIME,
132        Self::UPDATE_TIME,
133    ];
134
135    /// Measures the diagnostics in the given schedule (default: [`PostUpdate`]).
136    ///
137    /// This should be the schedule the physics runs in (see
138    /// [`RapierPhysicsPlugin::in_schedule`](crate::plugin::RapierPhysicsPlugin::in_schedule)).
139    pub fn in_schedule(mut self, schedule: impl ScheduleLabel) -> Self {
140        self.schedule = schedule.intern();
141        self
142    }
143
144    /// Also measures each context separately (see [`Self::per_context`]).
145    pub fn with_per_context_diagnostics(mut self, per_context: bool) -> Self {
146        self.per_context = per_context;
147        self
148    }
149
150    /// The path of the diagnostic measuring `path` (one of the associated constants, e.g.
151    /// [`Self::STEP_TIME`]) for the context `context` only, e.g. `rapier/12v0/step_time`.
152    ///
153    /// These diagnostics are only measured if [`Self::per_context`] is enabled.
154    pub fn context_path(path: &DiagnosticPath, context: Entity) -> DiagnosticPath {
155        let name = path.as_str();
156        let name = name.strip_prefix("rapier/").unwrap_or(name);
157        DiagnosticPath::new(format!("rapier/{context}/{name}"))
158    }
159
160    /// The path and unit suffix of every diagnostic measured by this plugin.
161    fn measured_paths() -> impl Iterator<Item = (DiagnosticPath, &'static str)> {
162        let always = Self::ALWAYS_MEASURED.into_iter();
163        #[cfg(feature = "profiler")]
164        let always = always.chain(Self::PROFILER_TIMINGS.into_iter().map(|path| (path, "ms")));
165        always
166    }
167
168    /// The measurements of one context, or `None` if it didn’t execute any step during the last
169    /// physics update.
170    fn measure_context(
171        simulation: &mut Mut<RapierContextSimulation>,
172        colliders: &RapierContextColliders,
173        bodies: &RapierRigidBodySet,
174        joints: &RapierContextJoints,
175    ) -> Option<Vec<(DiagnosticPath, f64)>> {
176        if !simulation.pipeline.counters.enabled() {
177            simulation
178                .bypass_change_detection()
179                .pipeline
180                .counters
181                .enable();
182        }
183
184        let stats = *simulation.step_stats();
185        if stats.num_steps == 0 {
186            return None;
187        }
188
189        #[allow(unused_mut)] // Only mutated with the `profiler` feature.
190        let mut measurements = vec![
191            (Self::STEP_TIME, stats.step_time_ms),
192            (Self::STEPS, stats.num_steps as f64),
193            (Self::CCD_SUBSTEPS, stats.ccd_substeps as f64),
194            (Self::RIGID_BODIES, bodies.bodies.len() as f64),
195            (
196                Self::ACTIVE_BODIES,
197                simulation.islands.active_bodies().count() as f64,
198            ),
199            (Self::COLLIDERS, colliders.colliders.len() as f64),
200            (Self::IMPULSE_JOINTS, joints.impulse_joints.len() as f64),
201            (
202                Self::MULTIBODY_JOINTS,
203                joints.multibody_joints.iter().count() as f64,
204            ),
205            (Self::CONTACT_PAIRS, stats.num_contact_pairs as f64),
206            (
207                Self::ACTIVE_CONTACT_PAIRS,
208                simulation
209                    .narrow_phase
210                    .contact_pairs()
211                    .filter(|pair| pair.has_any_active_contact())
212                    .count() as f64,
213            ),
214            (Self::CONTACT_CONSTRAINTS, stats.num_solver_contacts as f64),
215            (
216                Self::SOLVER_CONSTRAINTS,
217                stats.num_solver_constraints as f64,
218            ),
219            (
220                Self::INTERSECTIONS,
221                simulation
222                    .narrow_phase
223                    .intersection_pairs()
224                    .filter(|(_, _, intersecting)| *intersecting)
225                    .count() as f64,
226            ),
227        ];
228        #[cfg(feature = "profiler")]
229        measurements.extend([
230            (
231                Self::COLLISION_DETECTION_TIME,
232                stats.collision_detection_time_ms,
233            ),
234            (Self::BROAD_PHASE_TIME, stats.broad_phase_time_ms),
235            (Self::NARROW_PHASE_TIME, stats.narrow_phase_time_ms),
236            (
237                Self::ISLAND_CONSTRUCTION_TIME,
238                stats.island_construction_time_ms,
239            ),
240            (Self::SOLVER_TIME, stats.solver_time_ms),
241            (Self::CCD_TIME, stats.ccd_time_ms),
242            (Self::UPDATE_TIME, stats.update_time_ms),
243        ]);
244        Some(measurements)
245    }
246
247    /// Updates the measurements of the diagnostics registered by this plugin.
248    ///
249    /// If `per_context` is set, the per-context diagnostics of the contexts not in `registered`
250    /// are registered (their first measurement is then taken on the next update).
251    fn diagnostic_system(
252        per_context: bool,
253        max_history_length: usize,
254        commands: &mut Commands,
255        diagnostics: &mut Diagnostics,
256        contexts: &mut Query<(
257            Entity,
258            &mut RapierContextSimulation,
259            &RapierContextColliders,
260            &RapierRigidBodySet,
261            &RapierContextJoints,
262        )>,
263        registered: &mut EntityHashSet,
264    ) {
265        let mut totals: Option<Vec<(DiagnosticPath, f64)>> = None;
266
267        for (context, mut simulation, colliders, bodies, joints) in contexts.iter_mut() {
268            let Some(measurements) =
269                Self::measure_context(&mut simulation, colliders, bodies, joints)
270            else {
271                continue;
272            };
273
274            if per_context {
275                if registered.insert(context) {
276                    commands.queue(move |world: &mut World| {
277                        let mut store = world.resource_mut::<DiagnosticsStore>();
278                        for (path, suffix) in Self::measured_paths() {
279                            store.add(
280                                Diagnostic::new(Self::context_path(&path, context))
281                                    .with_suffix(suffix)
282                                    .with_max_history_length(max_history_length),
283                            );
284                        }
285                    });
286                }
287                for (path, value) in &measurements {
288                    diagnostics.add_measurement(&Self::context_path(path, context), || *value);
289                }
290            }
291
292            match &mut totals {
293                Some(totals) => {
294                    for ((_, total), (_, value)) in totals.iter_mut().zip(&measurements) {
295                        *total += value;
296                    }
297                }
298                None => totals = Some(measurements),
299            }
300        }
301
302        for (path, total) in totals.into_iter().flatten() {
303            diagnostics.add_measurement(&path, || total);
304        }
305    }
306}
307
308impl Plugin for RapierDiagnosticsPlugin {
309    fn build(&self, app: &mut App) {
310        for (path, suffix) in Self::measured_paths() {
311            app.register_diagnostic(
312                Diagnostic::new(path)
313                    .with_suffix(suffix)
314                    .with_max_history_length(self.max_history_length),
315            );
316        }
317
318        let per_context = self.per_context;
319        let max_history_length = self.max_history_length;
320        app.add_systems(
321            self.schedule,
322            (move |mut commands: Commands,
323                   mut diagnostics: Diagnostics,
324                   mut contexts: Query<(
325                Entity,
326                &mut RapierContextSimulation,
327                &RapierContextColliders,
328                &RapierRigidBodySet,
329                &RapierContextJoints,
330            )>,
331                   mut registered: Local<EntityHashSet>| {
332                Self::diagnostic_system(
333                    per_context,
334                    max_history_length,
335                    &mut commands,
336                    &mut diagnostics,
337                    &mut contexts,
338                    &mut registered,
339                )
340            })
341            .after(PhysicsSet::Writeback),
342        );
343    }
344}