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}