avian3d/collider_tree/
optimization.rs

1use crate::{
2    collider_tree::{
3        ColliderTree, ColliderTreeDiagnostics, ColliderTreeSystems, ColliderTreeType, ColliderTrees,
4    },
5    data_structures::stable_vec::StableVec,
6    prelude::*,
7};
8use bevy::{
9    ecs::world::CommandQueue,
10    prelude::*,
11    tasks::{AsyncComputeTaskPool, Task, block_on},
12};
13
14/// A plugin that optimizes the dynamic [`ColliderTree`] to maintain good query performance.
15pub(super) struct ColliderTreeOptimizationPlugin;
16
17impl Plugin for ColliderTreeOptimizationPlugin {
18    fn build(&self, app: &mut App) {
19        app.init_resource::<ColliderTreeOptimization>()
20            .init_resource::<OptimizationTasks>();
21
22        app.add_systems(
23            PhysicsSchedule,
24            (
25                optimize_trees.in_set(ColliderTreeSystems::BeginOptimize),
26                #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
27                block_on_optimize_trees.in_set(ColliderTreeSystems::EndOptimize),
28            ),
29        );
30    }
31}
32
33/// Settings for optimizing each [`ColliderTree`].
34// TODO: Per-tree settings could be useful.
35#[derive(Resource, Debug, PartialEq, Reflect)]
36pub struct ColliderTreeOptimization {
37    /// The optimization mode for the collider tree.
38    ///
39    /// **Default**: [`TreeOptimizationMode::Adaptive`]
40    pub optimization_mode: TreeOptimizationMode,
41
42    /// If `true`, tree optimization will be performed in-place with minimal allocations.
43    /// This has the downside that the tree will be unavailable for [spatial queries]
44    /// during the simulation step while the optimization is ongoing (ex: in [collision hooks]).
45    ///
46    /// Otherwise, parts of the the tree will be cloned for the optimization,
47    /// allowing spatial queries to use the old tree during the simulation step,
48    /// but incurring additional memory allocation overhead.
49    ///
50    /// For optimal performance, set this to `true` if your application
51    /// does not perform spatial queries during the simulation step.
52    ///
53    /// **Default**: `false`
54    ///
55    /// [spatial queries]: crate::spatial_query
56    /// [collision hooks]: crate::collision::hooks
57    pub optimize_in_place: bool,
58
59    /// If `true`, tree optimization will be performed in parallel
60    /// with the narrow phase and solver using async tasks.
61    ///
62    /// **Default**: `true` (on supported platforms)
63    pub use_async_tasks: bool,
64}
65
66impl Default for ColliderTreeOptimization {
67    fn default() -> Self {
68        Self {
69            optimization_mode: TreeOptimizationMode::default(),
70            optimize_in_place: false,
71            #[cfg(any(target_arch = "wasm32", target_os = "unknown"))]
72            use_async_tasks: false,
73            #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
74            use_async_tasks: true,
75        }
76    }
77}
78
79/// The optimization mode for a [`ColliderTree`].
80#[derive(Clone, Copy, Debug, PartialEq, Reflect)]
81pub enum TreeOptimizationMode {
82    /// The tree is optimized by reinserting proxies whose AABB in the tree has changed.
83    ///
84    /// This is the fastest method when only a small portion of proxies have moved,
85    /// but is less effective for large numbers of moved proxies.
86    Reinsert,
87
88    /// The tree is optimized by performing a partial rebuild that only rebuilds
89    /// parts of the tree affected by proxies that have moved.
90    ///
91    /// This method is more effective than reinsertion when a moderate number of proxies
92    /// have moved. However, if a large portion of proxies have moved, a full rebuild
93    /// can be more effective and have less overhead.
94    PartialRebuild,
95
96    /// The tree is optimized by performing a full rebuild.
97    ///
98    /// This method can produce the highest quality tree, and can have less overhead
99    /// than other methods when a large portion of proxies have moved.
100    /// This makes it suitable for highly dynamic scenes.
101    FullRebuild,
102
103    /// The tree is optimized adaptively based on how many proxies have moved.
104    ///
105    /// - If the ratio of moved proxies to total proxies is below
106    ///   `reinsert_threshold`, [`Reinsert`](TreeOptimizationMode::Reinsert) is used.
107    /// - If the ratio is between `reinsert_threshold` and `partial_rebuild_threshold`,
108    ///   [`PartialRebuild`](TreeOptimizationMode::PartialRebuild) is used.
109    /// - Otherwise, [`FullRebuild`](TreeOptimizationMode::FullRebuild) is used.
110    ///
111    /// This is the default mode.
112    Adaptive {
113        /// The threshold ratio of moved proxies to total proxies
114        /// below which reinsertion is performed.
115        ///
116        /// **Default**: `0.15`
117        reinsert_threshold: f32,
118
119        /// The threshold ratio of moved proxies to total proxies
120        /// below which a partial rebuild is performed.
121        ///
122        /// **Default**: `0.45`
123        partial_rebuild_threshold: f32,
124    },
125}
126
127impl Default for TreeOptimizationMode {
128    fn default() -> Self {
129        TreeOptimizationMode::Adaptive {
130            reinsert_threshold: 0.15,
131            partial_rebuild_threshold: 0.45,
132        }
133    }
134}
135
136impl TreeOptimizationMode {
137    /// Resolves the optimization mode based on the ratio of moved proxies.
138    ///
139    /// `moved_ratio` is the ratio of moved proxies to total proxies in the tree.
140    #[inline]
141    pub fn resolve(&self, moved_ratio: f32) -> TreeOptimizationMode {
142        match self {
143            TreeOptimizationMode::Adaptive {
144                reinsert_threshold,
145                partial_rebuild_threshold,
146            } => {
147                if moved_ratio < *reinsert_threshold {
148                    TreeOptimizationMode::Reinsert
149                } else if moved_ratio < *partial_rebuild_threshold {
150                    TreeOptimizationMode::PartialRebuild
151                } else {
152                    TreeOptimizationMode::FullRebuild
153                }
154            }
155            other => *other,
156        }
157    }
158}
159
160/// A resource tracking ongoing optimization tasks for [`ColliderTree`]s.
161#[derive(Resource, Default, Deref, DerefMut)]
162struct OptimizationTasks(Vec<Task<CommandQueue>>);
163
164/// Begins optimizing the dynamic and kinematic [`ColliderTree`]s to maintain good query performance.
165///
166/// If [`ColliderTreeOptimization::use_async_tasks`] is enabled, this spawns an async task
167/// that runs concurrently with the simulation step. Otherwise, the optimization is performed
168/// in-place on the main thread.
169fn optimize_trees(
170    mut collider_trees: ResMut<ColliderTrees>,
171    mut optimization_tasks: ResMut<OptimizationTasks>,
172    optimization_settings: Res<ColliderTreeOptimization>,
173    mut diagnostics: ResMut<ColliderTreeDiagnostics>,
174) {
175    let start = crate::utils::Instant::now();
176
177    let task_pool = AsyncComputeTaskPool::get();
178
179    // We cannot block on wasm.
180    #[cfg(any(target_arch = "wasm32", target_os = "unknown"))]
181    let use_async_tasks = false;
182    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
183    let use_async_tasks = optimization_settings.use_async_tasks;
184
185    // Spawn optimization tasks for each tree.
186    for tree_type in ColliderTreeType::ALL {
187        let tree = collider_trees.tree_for_type_mut(tree_type);
188
189        let moved_ratio = tree.moved_proxies.len() as f32 / tree.proxies.len() as f32;
190        let optimization_strategy = optimization_settings.optimization_mode.resolve(moved_ratio);
191
192        if moved_ratio == 0.0 && optimization_strategy != TreeOptimizationMode::FullRebuild {
193            // No moved proxies, no need to optimize.
194            continue;
195        }
196
197        #[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
198        if use_async_tasks {
199            // Take or clone the BVH for the optimization task.
200            // TODO: For small changes to large trees, the cost of cloning can exceed the cost of the async task.
201            //       We could have a threshold for cloning vs in-place optimization based on tree size and moved ratio.
202            let bvh = if optimization_settings.optimize_in_place {
203                core::mem::take(&mut tree.bvh)
204            } else {
205                // TODO: Can we avoid cloning the entire BVH?
206                tree.bvh.clone()
207            };
208
209            // Create a new tree for the optimization task.
210            let new_tree = ColliderTree {
211                bvh,
212                proxies: StableVec::new(),
213                // These are not needed during the simulation step.
214                moved_proxies: core::mem::take(&mut tree.moved_proxies),
215                workspace: core::mem::take(&mut tree.workspace),
216            };
217
218            let task = spawn_optimization_task(task_pool, new_tree, tree_type, move |tree| {
219                optimize_tree_in_place(tree, optimization_strategy);
220            });
221
222            optimization_tasks.push(task);
223        }
224
225        if !use_async_tasks {
226            // Optimize in place on the main thread.
227            optimize_tree_in_place(tree, optimization_strategy);
228        }
229    }
230
231    diagnostics.optimize += start.elapsed();
232}
233
234fn optimize_tree_in_place(tree: &mut ColliderTree, optimization_strategy: TreeOptimizationMode) {
235    match optimization_strategy {
236        TreeOptimizationMode::Reinsert => {
237            let moved_leaves = tree
238                .moved_proxies
239                .iter()
240                .map(|key| tree.bvh.primitives_to_nodes[key.index()])
241                .collect::<Vec<u32>>();
242
243            tree.optimize_candidates(&moved_leaves, 1);
244        }
245        TreeOptimizationMode::PartialRebuild => {
246            let moved_leaves = tree
247                .moved_proxies
248                .iter()
249                .map(|key| tree.bvh.primitives_to_nodes[key.index()])
250                .collect::<Vec<u32>>();
251
252            tree.rebuild_partial(&moved_leaves);
253        }
254        TreeOptimizationMode::FullRebuild => {
255            tree.rebuild_full();
256        }
257
258        TreeOptimizationMode::Adaptive { .. } => unreachable!(),
259    }
260}
261
262/// Spawns and returns an async task to optimize the given collider tree
263/// using the provided optimization function.
264#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
265fn spawn_optimization_task(
266    task_pool: &AsyncComputeTaskPool,
267    mut tree: ColliderTree,
268    tree_type: ColliderTreeType,
269    optimize: impl FnOnce(&mut ColliderTree) + Send + 'static,
270) -> Task<CommandQueue> {
271    task_pool.spawn(async move {
272        optimize(&mut tree);
273
274        let mut command_queue = CommandQueue::default();
275        command_queue.push(move |world: &mut World| {
276            let mut collider_trees = world
277                .get_resource_mut::<ColliderTrees>()
278                .expect("ColliderTrees resource missing");
279            let collider_tree = collider_trees.tree_for_type_mut(tree_type);
280            collider_tree.bvh = tree.bvh;
281            collider_tree.workspace = tree.workspace;
282        });
283        command_queue
284    })
285}
286
287/// Completes the [`ColliderTree`] optimization tasks started in [`optimize_trees`].
288#[cfg(all(not(target_arch = "wasm32"), not(target_os = "unknown")))]
289fn block_on_optimize_trees(
290    mut commands: Commands,
291    mut optimization: ResMut<OptimizationTasks>,
292    mut diagnostics: ResMut<ColliderTreeDiagnostics>,
293) {
294    let start = crate::utils::Instant::now();
295
296    // Complete all ongoing optimization tasks.
297    optimization.drain(..).for_each(|task| {
298        let mut command_queue = block_on(task);
299        commands.append(&mut command_queue);
300    });
301
302    diagnostics.optimize += start.elapsed();
303}