bevy_tnua_avian3d/
lib.rs

1//! # avian3d Integration for bevy-tnua
2//!
3//! In addition to the instruction in bevy-tnua's documentation:
4//!
5//! * Add [`TnuaAvian3dPlugin`] to the Bevy app.
6//! * Optionally: Add [`TnuaAvian3dSensorShape`] to either entity of the character controller by
7//!   Tnua or to the to the sensor entities. If exists, the shape on the sensor entity overrides
8//!   the one on the character entity for that specific sensor.
9mod spatial_ext;
10
11use avian3d::{prelude::*, schedule::PhysicsStepSystems};
12use bevy::ecs::schedule::{InternedScheduleLabel, ScheduleLabel};
13use bevy::prelude::*;
14use bevy_tnua_physics_integration_layer::math::AsF32;
15use bevy_tnua_physics_integration_layer::math::Float;
16use bevy_tnua_physics_integration_layer::math::Vector3;
17use bevy_tnua_physics_integration_layer::math::{AdjustPrecision, Quaternion};
18use ordered_float::OrderedFloat;
19pub use spatial_ext::TnuaSpatialExtAvian3d;
20
21use bevy_tnua_physics_integration_layer::TnuaPipelineSystems;
22use bevy_tnua_physics_integration_layer::TnuaSystems;
23use bevy_tnua_physics_integration_layer::data_for_backends::TnuaGravity;
24use bevy_tnua_physics_integration_layer::data_for_backends::TnuaToggle;
25use bevy_tnua_physics_integration_layer::data_for_backends::{TnuaGhostPlatform, TnuaNotPlatform};
26use bevy_tnua_physics_integration_layer::data_for_backends::{TnuaGhostSensor, TnuaSensorOf};
27use bevy_tnua_physics_integration_layer::data_for_backends::{
28    TnuaMotor, TnuaProximitySensor, TnuaProximitySensorOutput, TnuaRigidBodyTracker,
29};
30use bevy_tnua_physics_integration_layer::obstacle_radar::TnuaObstacleRadar;
31
32pub mod prelude {
33    pub use crate::{TnuaAvian3dPlugin, TnuaAvian3dSensorShape, TnuaSpatialExtAvian3d};
34}
35
36/// Add this plugin to use avian3d as a physics backend.
37///
38/// This plugin should be used in addition to `TnuaControllerPlugin`.
39/// Note that you should make sure both of these plugins use the same schedule.
40/// This should usually be `PhysicsSchedule`, which by default is `FixedUpdate`.
41///
42/// # Example
43///
44/// ```ignore
45/// App::new()
46///     .add_plugins((
47///         DefaultPlugins,
48///         PhysicsPlugins::default(),
49///         TnuaControllerPlugin::new(PhysicsSchedule),
50///         TnuaAvian3dPlugin::new(PhysicsSchedule),
51///     ));
52/// ```
53pub struct TnuaAvian3dPlugin {
54    schedule: InternedScheduleLabel,
55}
56
57impl TnuaAvian3dPlugin {
58    pub fn new(schedule: impl ScheduleLabel) -> Self {
59        Self {
60            schedule: schedule.intern(),
61        }
62    }
63}
64
65impl Plugin for TnuaAvian3dPlugin {
66    fn build(&self, app: &mut App) {
67        app.configure_sets(
68            self.schedule,
69            TnuaSystems
70                // Need to run _before_ `First`, not after it. The documentation is misleading. See
71                // https://github.com/Jondolf/avian/issues/675
72                .before(PhysicsStepSystems::First)
73                .run_if(|physics_time: Res<Time<Physics>>| !physics_time.is_paused()),
74        );
75        app.add_systems(
76            self.schedule,
77            (
78                update_rigid_body_trackers_system,
79                update_proximity_sensors_system,
80                update_obstacle_radars_system
81                    // Both use SpatialQuery, which uses a ResMut
82                    .ambiguous_with(update_proximity_sensors_system),
83            )
84                .in_set(TnuaPipelineSystems::Sensors),
85        );
86        app.add_systems(
87            self.schedule,
88            apply_motors_system.in_set(TnuaPipelineSystems::Motors),
89        );
90        app.register_required_components_with::<TnuaGravity, GravityScale>(|| GravityScale(0.0));
91    }
92}
93
94/// Add this component to make [`TnuaProximitySensor`] cast a shape instead of a ray.
95#[derive(Component)]
96pub struct TnuaAvian3dSensorShape(pub Collider);
97
98#[allow(clippy::type_complexity)]
99fn update_rigid_body_trackers_system(
100    gravity: Res<Gravity>,
101    mut query: Query<(
102        &Position,
103        &Rotation,
104        &LinearVelocity,
105        &AngularVelocity,
106        &mut TnuaRigidBodyTracker,
107        Option<&TnuaToggle>,
108        Option<&TnuaGravity>,
109    )>,
110) {
111    for (
112        position,
113        rotation,
114        linaer_velocity,
115        angular_velocity,
116        mut tracker,
117        tnua_toggle,
118        tnua_gravity,
119    ) in query.iter_mut()
120    {
121        match tnua_toggle.copied().unwrap_or_default() {
122            TnuaToggle::Disabled => continue,
123            TnuaToggle::SenseOnly => {}
124            TnuaToggle::Enabled => {}
125        }
126        *tracker = TnuaRigidBodyTracker {
127            translation: position.adjust_precision(),
128            rotation: rotation.adjust_precision(),
129            velocity: linaer_velocity.0.adjust_precision(),
130            angvel: angular_velocity.0.adjust_precision(),
131            gravity: tnua_gravity.map(|g| g.0).unwrap_or(gravity.0),
132        };
133    }
134}
135
136#[allow(clippy::type_complexity)]
137fn update_proximity_sensors_system(
138    spatial_query: SpatialQuery,
139    mut sensor_query: Query<(
140        &mut TnuaProximitySensor,
141        &TnuaSensorOf,
142        Option<&TnuaAvian3dSensorShape>,
143        Option<&mut TnuaGhostSensor>,
144    )>,
145    owner_query: Query<(
146        &Position,
147        &Rotation,
148        Option<&Collider>,
149        Option<&TnuaAvian3dSensorShape>,
150        Option<&TnuaToggle>,
151    )>,
152    collision_layers_query: Query<&CollisionLayers>,
153    other_object_query: Query<(
154        Option<(
155            &Position,
156            &LinearVelocity,
157            &AngularVelocity,
158            Option<&RigidBody>,
159        )>,
160        Option<&CollisionLayers>,
161        Option<&ColliderOf>,
162        Has<TnuaGhostPlatform>,
163        Has<Sensor>,
164        Has<TnuaNotPlatform>,
165    )>,
166) {
167    sensor_query.par_iter_mut().for_each(
168        |(mut sensor, &TnuaSensorOf(owner_entity), shape, mut ghost_sensor)| {
169            let Ok((position, rotation, collider, owner_shape, tnua_toggle)) =
170                owner_query.get(owner_entity)
171            else {
172                return;
173            };
174            let shape = shape.or(owner_shape);
175            match tnua_toggle.copied().unwrap_or_default() {
176                TnuaToggle::Disabled => return,
177                TnuaToggle::SenseOnly => {}
178                TnuaToggle::Enabled => {}
179            }
180            let transform = Transform {
181                translation: position.0.f32(),
182                rotation: rotation.0.f32(),
183                scale: collider
184                    .map(|collider| collider.scale().f32())
185                    .unwrap_or(Vec3::ONE),
186            };
187
188            // TODO: is there any point in doing these transformations as f64 when that feature
189            // flag is active?
190            let cast_origin = transform
191                .transform_point(sensor.cast_origin.f32())
192                .adjust_precision();
193            let cast_direction = sensor.cast_direction;
194
195            struct CastResult {
196                entity: Entity,
197                proximity: Float,
198                intersection_point: Vector3,
199                normal: Dir3,
200            }
201
202            let collision_layers = collision_layers_query.get(owner_entity).ok();
203
204            let mut final_sensor_output: Option<TnuaProximitySensorOutput> = None;
205            if let Some(ghost_sensor) = ghost_sensor.as_mut() {
206                ghost_sensor.0.clear();
207            }
208            let mut apply_cast = |cast_result: CastResult| {
209                let CastResult {
210                    entity,
211                    proximity,
212                    intersection_point,
213                    normal,
214                } = cast_result;
215
216                let Ok((
217                    mut entity_kinematic_data,
218                    mut entity_collision_layers,
219                    entity_collider_of,
220                    mut entity_is_ghost,
221                    mut entity_is_sensor,
222                    mut entity_is_not_platform,
223                )) = other_object_query.get(entity)
224                else {
225                    return false;
226                };
227
228                if let Some(collider_of) = entity_collider_of {
229                    let parent_entity = collider_of.body;
230
231                    // Collider is child of our rigid body. ignore.
232                    if parent_entity == owner_entity {
233                        return true;
234                    }
235
236                    if let Ok((
237                        parent_kinematic_data,
238                        parent_collision_layers,
239                        _,
240                        parent_is_ghost,
241                        parent_is_sensor,
242                        parent_is_not_platform,
243                    )) = other_object_query.get(parent_entity)
244                    {
245                        if entity_kinematic_data.is_none() {
246                            entity_kinematic_data = parent_kinematic_data;
247                        }
248                        if entity_collision_layers.is_none() {
249                            entity_collision_layers = parent_collision_layers;
250                        }
251                        entity_is_ghost = entity_is_ghost || parent_is_ghost;
252                        entity_is_sensor = entity_is_sensor || parent_is_sensor;
253                        entity_is_not_platform = entity_is_not_platform || parent_is_not_platform;
254                    }
255                }
256
257                if entity_is_not_platform {
258                    return true;
259                }
260
261                let entity_linvel;
262                let entity_angvel;
263                if let Some((
264                    entity_position,
265                    entity_linear_velocity,
266                    entity_angular_velocity,
267                    rigid_body,
268                )) = entity_kinematic_data
269                {
270                    if rigid_body == Some(&RigidBody::Static) {
271                        entity_angvel = Vector3::ZERO;
272                        entity_linvel = Vector3::ZERO;
273                    } else {
274                        entity_angvel = entity_angular_velocity.0.adjust_precision();
275                        entity_linvel = entity_linear_velocity.0.adjust_precision()
276                            + if 0.0 < entity_angvel.length_squared() {
277                                let relative_point =
278                                    intersection_point - entity_position.adjust_precision();
279                                // NOTE: no need to project relative_point on the
280                                // rotation plane, it will not affect the cross
281                                // product.
282                                entity_angvel.cross(relative_point)
283                            } else {
284                                Vector3::ZERO
285                            };
286                    }
287                } else {
288                    entity_angvel = Vector3::ZERO;
289                    entity_linvel = Vector3::ZERO;
290                }
291                let sensor_output = TnuaProximitySensorOutput {
292                    entity,
293                    proximity,
294                    normal,
295                    entity_linvel,
296                    entity_angvel,
297                };
298
299                let excluded_by_collision_layers = || {
300                    let collision_layers = collision_layers.copied().unwrap_or_default();
301                    let entity_collision_layers =
302                        entity_collision_layers.copied().unwrap_or_default();
303                    !collision_layers.interacts_with(entity_collision_layers)
304                };
305
306                if entity_is_ghost {
307                    if let Some(ghost_sensor) = ghost_sensor.as_mut() {
308                        ghost_sensor.0.push(sensor_output);
309                    }
310                    true
311                } else if entity_is_sensor || excluded_by_collision_layers() {
312                    true
313                } else {
314                    if final_sensor_output.as_ref().is_none_or(|current_output| {
315                        sensor_output.proximity < current_output.proximity
316                    }) {
317                        // Hits are not guaranteed to be ordered, so we need to make them ordered.
318                        // See https://github.com/idanarye/bevy-tnua/issues/123
319                        final_sensor_output = Some(sensor_output);
320                    }
321                    false
322                }
323            };
324
325            let query_filter = SpatialQueryFilter::from_excluded_entities([owner_entity]);
326            if let Some(TnuaAvian3dSensorShape(shape)) = shape {
327                // TODO: can I bake `owner_rotation` into
328                // `sensor.cast_shape_rotation`?
329                let owner_rotation = Quaternion::from_axis_angle(
330                    cast_direction.adjust_precision(),
331                    rotation
332                        .to_scaled_axis()
333                        .dot(cast_direction.adjust_precision()),
334                );
335                spatial_query.shape_hits_callback(
336                    shape,
337                    cast_origin,
338                    owner_rotation.mul_quat(sensor.cast_shape_rotation.adjust_precision()),
339                    cast_direction,
340                    &ShapeCastConfig {
341                        max_distance: sensor.cast_range,
342                        ignore_origin_penetration: true,
343                        ..default()
344                    },
345                    &query_filter,
346                    |shape_hit_data| {
347                        apply_cast(CastResult {
348                            entity: shape_hit_data.entity,
349                            proximity: shape_hit_data.distance,
350                            intersection_point: shape_hit_data.point1,
351                            normal: Dir3::new(shape_hit_data.normal1.f32())
352                                .unwrap_or_else(|_| -cast_direction),
353                        })
354                    },
355                );
356            } else {
357                spatial_query.ray_hits_callback(
358                    cast_origin,
359                    cast_direction,
360                    sensor.cast_range,
361                    true,
362                    &query_filter,
363                    |ray_hit_data| {
364                        apply_cast(CastResult {
365                            entity: ray_hit_data.entity,
366                            proximity: ray_hit_data.distance,
367                            intersection_point: cast_origin
368                                + ray_hit_data.distance * cast_direction.adjust_precision(),
369                            normal: Dir3::new(ray_hit_data.normal.f32())
370                                .unwrap_or_else(|_| -cast_direction),
371                        })
372                    },
373                );
374            }
375            if let Some(ghost_sensor) = ghost_sensor.as_mut() {
376                // Hits are not guaranteed to be ordered, so we need to make them ordered.
377                // See https://github.com/idanarye/bevy-tnua/issues/123
378                if let Some(final_sensor_output) = final_sensor_output.as_ref() {
379                    ghost_sensor
380                        .0
381                        .retain(|ghost_hit| ghost_hit.proximity < final_sensor_output.proximity);
382                }
383                ghost_sensor
384                    .0
385                    .sort_by_key(|ghost_hit| OrderedFloat(ghost_hit.proximity));
386            }
387            sensor.output = final_sensor_output;
388        },
389    );
390}
391
392fn update_obstacle_radars_system(
393    spatial_query: SpatialQuery,
394    gravity: Res<Gravity>,
395    mut radars_query: Query<(Entity, &mut TnuaObstacleRadar, &Position)>,
396) {
397    if radars_query.is_empty() {
398        return;
399    }
400    for (radar_owner_entity, mut radar, radar_position) in radars_query.iter_mut() {
401        radar.pre_marking_update(
402            radar_owner_entity,
403            radar_position.0,
404            Dir3::new(gravity.0.f32()).unwrap_or(Dir3::Y),
405        );
406        spatial_query.shape_intersections_callback(
407            &Collider::cylinder(radar.radius, radar.height),
408            radar_position.0,
409            Default::default(),
410            &SpatialQueryFilter::DEFAULT,
411            |obstacle_entity| {
412                if radar_owner_entity == obstacle_entity {
413                    return true;
414                }
415                radar.mark_seen(obstacle_entity);
416                true
417            },
418        );
419    }
420}
421
422#[allow(clippy::type_complexity)]
423fn apply_motors_system(
424    mut query: Query<(
425        &TnuaMotor,
426        &ComputedMass,
427        &ComputedAngularInertia,
428        Forces,
429        Option<&TnuaToggle>,
430        Option<&TnuaGravity>,
431    )>,
432) {
433    for (motor, mass, inertia, mut forces, tnua_toggle, tnua_gravity) in query.iter_mut() {
434        match tnua_toggle.copied().unwrap_or_default() {
435            TnuaToggle::Disabled | TnuaToggle::SenseOnly => {
436                // *external_force = Default::default();
437                return;
438            }
439            TnuaToggle::Enabled => {}
440        }
441        if motor.lin.boost.is_finite() {
442            forces.apply_linear_impulse(motor.lin.boost * mass.value());
443        }
444        if motor.lin.acceleration.is_finite() {
445            forces.apply_linear_acceleration(motor.lin.acceleration);
446        }
447        if motor.ang.boost.is_finite() {
448            forces.apply_angular_impulse(inertia.value() * motor.ang.boost);
449        }
450        if motor.ang.acceleration.is_finite() {
451            // NOTE: I did not actually verify that this is correct. Nothing uses angular
452            // acceleration yet - only angular impulses.
453            forces.apply_angular_acceleration(motor.ang.acceleration);
454        }
455        if let Some(gravity) = tnua_gravity {
456            forces.apply_force(gravity.0 * mass.value());
457        }
458    }
459}