Skip to main content

avian3d/collider_tree/
optimization.rs

1#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
2use crate::data_structures::stable_vec::StableVec;
3use crate::{
4    collider_tree::{
5        ColliderTree, ColliderTreeDiagnostics, ColliderTreeSystems, ColliderTreeType, ColliderTrees,
6    },
7    prelude::*,
8};
9#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
10use alloc::sync::Arc;
11use bevy::prelude::*;
12#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
13use bevy::tasks::{ComputeTaskPool, Task, block_on};
14#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
15use core::sync::atomic::{AtomicBool, Ordering};
16#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
17use std::sync::Mutex;
18
19/// A plugin that optimizes the dynamic [`ColliderTree`] to maintain good query performance.
20pub(super) struct ColliderTreeOptimizationPlugin;
21
22impl Plugin for ColliderTreeOptimizationPlugin {
23    fn build(&self, app: &mut App) {
24        app.init_resource::<ColliderTreeOptimization>();
25
26        #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
27        app.init_resource::<OptimizationTasks>();
28
29        app.add_systems(
30            PhysicsSchedule,
31            (
32                optimize_trees.in_set(ColliderTreeSystems::BeginOptimize),
33                #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
34                finish_optimize_trees.in_set(ColliderTreeSystems::EndOptimize),
35            ),
36        );
37    }
38}
39
40/// Settings for optimizing each [`ColliderTree`].
41// TODO: Per-tree settings could be useful.
42#[derive(Resource, Debug, PartialEq, Reflect)]
43pub struct ColliderTreeOptimization {
44    /// The optimization mode for the collider tree.
45    ///
46    /// **Default**: [`TreeOptimizationMode::Adaptive`]
47    pub optimization_mode: TreeOptimizationMode,
48
49    /// If `true`, tree optimization will be performed in-place with minimal allocations.
50    /// This has the downside that the tree will be unavailable for [spatial queries]
51    /// during the simulation step while the optimization is ongoing (ex: in [collision hooks]).
52    ///
53    /// Otherwise, parts of the the tree will be cloned for the optimization,
54    /// allowing spatial queries to use the old tree during the simulation step,
55    /// but incurring additional memory allocation overhead.
56    ///
57    /// For optimal performance, set this to `true` if your application
58    /// does not perform spatial queries during the simulation step.
59    ///
60    /// **Default**: `false`
61    ///
62    /// [spatial queries]: crate::spatial_query
63    /// [collision hooks]: crate::collision::hooks
64    pub optimize_in_place: bool,
65
66    /// If `true`, tree optimization will be performed in parallel
67    /// with the narrow phase and solver using the [`ComputeTaskPool`].
68    ///
69    /// This typically hides most of the optimization overhead
70    /// for scenes where the narrow phase and solver are the bottleneck.
71    ///
72    /// **Default**: `true` (on supported platforms)
73    pub use_compute_task: bool,
74}
75
76impl Default for ColliderTreeOptimization {
77    fn default() -> Self {
78        Self {
79            optimization_mode: TreeOptimizationMode::default(),
80            optimize_in_place: false,
81            #[cfg(any(target_arch = "wasm32", target_os = "unknown"))]
82            use_compute_task: false,
83            #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
84            use_compute_task: true,
85        }
86    }
87}
88
89/// The optimization mode for a [`ColliderTree`].
90#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
91pub enum TreeOptimizationMode {
92    /// The tree is optimized by reinserting proxies whose AABB in the tree has changed.
93    ///
94    /// This is the fastest method when only a small portion of proxies have moved,
95    /// but is less effective for large numbers of moved proxies.
96    Reinsert,
97
98    /// The tree is optimized by performing a partial rebuild that only rebuilds
99    /// parts of the tree affected by proxies that have moved.
100    ///
101    /// This method is more effective than reinsertion when a moderate number of proxies
102    /// have moved. However, if a large portion of proxies have moved, a full rebuild
103    /// can be more effective and have less overhead.
104    PartialRebuild,
105
106    /// The tree is optimized by performing a full rebuild.
107    ///
108    /// This method can produce the highest quality tree, and can have less overhead
109    /// than other methods when a large portion of proxies have moved.
110    /// This makes it suitable for highly dynamic scenes.
111    FullRebuild,
112
113    /// The tree is optimized adaptively based on how many proxies have moved.
114    ///
115    /// - If the ratio of moved proxies to total proxies is below
116    ///   `reinsert_threshold`, [`Reinsert`](TreeOptimizationMode::Reinsert) is used.
117    /// - If the ratio is between `reinsert_threshold` and `partial_rebuild_threshold`,
118    ///   [`PartialRebuild`](TreeOptimizationMode::PartialRebuild) is used.
119    /// - Otherwise, [`FullRebuild`](TreeOptimizationMode::FullRebuild) is used.
120    ///
121    /// This is the default mode.
122    Adaptive {
123        /// The threshold ratio of moved proxies to total proxies
124        /// below which reinsertion is performed.
125        ///
126        /// **Default**: `0.15`
127        reinsert_threshold: f32,
128
129        /// The threshold ratio of moved proxies to total proxies
130        /// below which a partial rebuild is performed.
131        ///
132        /// **Default**: `0.45`
133        partial_rebuild_threshold: f32,
134    },
135}
136
137impl Default for TreeOptimizationMode {
138    fn default() -> Self {
139        TreeOptimizationMode::Adaptive {
140            reinsert_threshold: 0.15,
141            partial_rebuild_threshold: 0.45,
142        }
143    }
144}
145
146impl TreeOptimizationMode {
147    /// Resolves the optimization mode based on the ratio of moved proxies.
148    ///
149    /// `moved_ratio` is the ratio of moved proxies to total proxies in the tree.
150    #[inline]
151    pub fn resolve(&self, moved_ratio: f32) -> TreeOptimizationMode {
152        match self {
153            TreeOptimizationMode::Adaptive {
154                reinsert_threshold,
155                partial_rebuild_threshold,
156            } => {
157                if moved_ratio < *reinsert_threshold {
158                    TreeOptimizationMode::Reinsert
159                } else if moved_ratio < *partial_rebuild_threshold {
160                    TreeOptimizationMode::PartialRebuild
161                } else {
162                    TreeOptimizationMode::FullRebuild
163                }
164            }
165            other => *other,
166        }
167    }
168}
169
170/// An optimization job for a single [`ColliderTree`],
171/// claimable by whichever thread reaches it first.
172#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
173struct OptimizationJob {
174    /// The tree being optimized.
175    tree: Mutex<ColliderTree>,
176    /// The type of the tree being optimized.
177    tree_type: ColliderTreeType,
178    /// The resolved optimization strategy.
179    mode: TreeOptimizationMode,
180    /// Whether the optimization has been claimed by a thread.
181    claimed: AtomicBool,
182}
183
184#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
185impl OptimizationJob {
186    /// Performs the optimization if it has not been claimed by another thread yet.
187    ///
188    /// Returns `true` if this call performed the optimization.
189    fn try_run(&self) -> bool {
190        if self.claimed.swap(true, Ordering::AcqRel) {
191            // Another thread already claimed this job.
192            return false;
193        }
194
195        // We have exclusive access to the tree.
196        let mut tree = self.tree.lock().unwrap_or_else(|err| err.into_inner());
197        optimize_tree_in_place(&mut tree, self.mode);
198
199        true
200    }
201}
202
203/// A resource tracking the ongoing optimization of [`ColliderTree`]s.
204///
205/// All trees are optimized by a single task, one after another,
206/// to leave as many threads as possible for the narrow phase and solver.
207/// The jobs are still claimed individually, so [`finish_optimize_trees`]
208/// can pick up the ones the task has not reached yet and run them alongside it.
209#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
210#[derive(Resource, Default)]
211struct OptimizationTasks {
212    jobs: Vec<Arc<OptimizationJob>>,
213    task: Option<Task<()>>,
214}
215
216/// Begins optimizing the dynamic and kinematic [`ColliderTree`]s to maintain good query performance.
217///
218/// If [`ColliderTreeOptimization::use_compute_task`] is enabled, this spawns a task that
219/// runs concurrently with the simulation step. Otherwise, the optimization is performed
220/// in-place on the main thread.
221fn optimize_trees(
222    mut collider_trees: ResMut<ColliderTrees>,
223    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
224    mut optimization_tasks: ResMut<OptimizationTasks>,
225    optimization_settings: Res<ColliderTreeOptimization>,
226    mut diagnostics: ResMut<ColliderTreeDiagnostics>,
227) {
228    let start = crate::utils::Instant::now();
229
230    // We cannot block on wasm.
231    #[cfg(any(target_arch = "wasm32", target_os = "unknown"))]
232    let use_compute_task = false;
233    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
234    let use_compute_task = optimization_settings.use_compute_task;
235
236    // Spawn optimization tasks for each tree.
237    for tree_type in ColliderTreeType::ALL {
238        let tree = collider_trees.tree_for_type_mut(tree_type);
239
240        let moved_ratio = tree.moved_proxies.len() as f32 / tree.proxies.len() as f32;
241        let optimization_strategy = optimization_settings.optimization_mode.resolve(moved_ratio);
242
243        if moved_ratio == 0.0 && optimization_strategy != TreeOptimizationMode::FullRebuild {
244            // No moved proxies, no need to optimize.
245            continue;
246        }
247
248        #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
249        if use_compute_task {
250            // Take or clone the BVH for the optimization task.
251            // TODO: For small changes to large trees, the cost of cloning can exceed the cost of the task.
252            //       We could have a threshold for cloning vs in-place optimization based on tree size and moved ratio.
253            let bvh = if optimization_settings.optimize_in_place {
254                core::mem::take(&mut tree.bvh)
255            } else {
256                // TODO: Can we avoid cloning the entire BVH?
257                tree.bvh.clone()
258            };
259
260            // Create a new tree for the optimization task.
261            let new_tree = ColliderTree {
262                bvh,
263                proxies: StableVec::new(),
264                // These are not needed during the simulation step.
265                moved_proxies: core::mem::take(&mut tree.moved_proxies),
266                workspace: core::mem::take(&mut tree.workspace),
267            };
268
269            optimization_tasks.jobs.push(Arc::new(OptimizationJob {
270                tree: Mutex::new(new_tree),
271                tree_type,
272                mode: optimization_strategy,
273                claimed: AtomicBool::new(false),
274            }));
275        }
276
277        if !use_compute_task {
278            // Optimize in place on the main thread.
279            optimize_tree_in_place(tree, optimization_strategy);
280        }
281    }
282
283    // Spawn a single task that claims and performs each optimization job in order.
284    // This leaves as many threads as possible for the narrow phase and solver.
285    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
286    if !optimization_tasks.jobs.is_empty() {
287        let jobs = optimization_tasks.jobs.clone();
288        optimization_tasks.task = Some(ComputeTaskPool::get().spawn(async move {
289            for job in &jobs {
290                job.try_run();
291            }
292        }));
293    }
294
295    diagnostics.optimize += start.elapsed();
296}
297
298fn optimize_tree_in_place(tree: &mut ColliderTree, optimization_strategy: TreeOptimizationMode) {
299    match optimization_strategy {
300        TreeOptimizationMode::Reinsert => {
301            let moved_leaves = tree
302                .moved_proxies
303                .iter()
304                .map(|key| tree.bvh.primitives_to_nodes[key.index()])
305                .collect::<Vec<u32>>();
306
307            tree.optimize_candidates(&moved_leaves, 1);
308        }
309        TreeOptimizationMode::PartialRebuild => {
310            let moved_leaves = tree
311                .moved_proxies
312                .iter()
313                .map(|key| tree.bvh.primitives_to_nodes[key.index()])
314                .collect::<Vec<u32>>();
315
316            tree.rebuild_partial(&moved_leaves);
317        }
318        TreeOptimizationMode::FullRebuild => {
319            tree.rebuild_full();
320        }
321
322        TreeOptimizationMode::Adaptive { .. } => unreachable!(),
323    }
324}
325
326/// Completes the [`ColliderTree`] optimization jobs started in [`optimize_trees`].
327#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
328fn finish_optimize_trees(
329    mut collider_trees: ResMut<ColliderTrees>,
330    mut optimization_tasks: ResMut<OptimizationTasks>,
331    mut diagnostics: ResMut<ColliderTreeDiagnostics>,
332) {
333    let start = crate::utils::Instant::now();
334
335    let Some(task) = optimization_tasks.task.take() else {
336        return;
337    };
338    let jobs = core::mem::take(&mut optimization_tasks.jobs);
339
340    // Claim every job the task has not reached yet and perform it on the main thread.
341    let mut claimed_all = true;
342    for job in &jobs {
343        if !job.try_run() {
344            claimed_all = false;
345        }
346    }
347
348    if claimed_all {
349        // The task has nothing left to do.
350        drop(task);
351    } else {
352        // The task is still working on some job. Block until it's done.
353        block_on(task);
354    }
355
356    // Every job has been completed by either the main thread or the task.
357    for job in jobs {
358        let mut tree = job.tree.lock().unwrap_or_else(|err| err.into_inner());
359        let collider_tree = collider_trees.tree_for_type_mut(job.tree_type);
360        collider_tree.bvh = core::mem::take(&mut tree.bvh);
361        collider_tree.workspace = core::mem::take(&mut tree.workspace);
362    }
363
364    diagnostics.optimize += start.elapsed();
365}