Skip to content

Commit 6424aa6

Browse files
authored
Collision Hooks (#610)
# Objective Closes #150. Advanced contact scenarios often require filtering or modifying contacts with custom logic. Use cases include: - One-way platforms - Conveyor belts - Non-uniform friction and restitution for terrain Physics engines typically handle this with *hooks* or *callbacks* that are called during specific parts of the simulation loop. For example: - Box2D: `b2CustomFilterFcn()` and `b2PreSolveFcn` (see [docs](https://box2d.org/documentation/md_simulation.html#autotoc_md97)) - Rapier: `PhysicsHooks` trait with `filter_contact_pair`/`filter_intersection_pair` and `modify_solver_contacts` (see [docs](https://rapier.rs/docs/user_guides/bevy_plugin/advanced_collision_detection#physics-hooks)) - Jolt: `ContactListener` with `OnContactValidate`, `OnContactAdded`, `OnContactPersisted`, and `OnContactRemoved` (see [docs](https://jrouwe.github.io/JoltPhysics/class_contact_listener.html)) Currently, we just have a `PostProcessCollisions` schedule where users can freely add systems to operate on collision data before constraints are generated. However: - It doesn't support filtering broad phase pairs. - It results in unnecessary iteration. Filtering and modification should happen directly when contacts are being created or added. - It adds more scheduling overhead. - It forces us to handle collision events and other collision logic in a sub-optimal way. `PostProcessCollisions` is generally not a good approach for performance, and it is not enough for our needs, even if it is highly flexible. We need proper collision hooks that are called as part of the simulation loop. ## Solution Add a `CollisionHooks` trait for types implementing `ReadOnlySystemParam`, with a `filter_pairs` method for filtering broad phase pairs, and a `modify_contacts` method for modifying and filtering contacts computed by the narrow phase. The system parameter allows ECS access in hooks. Only read-only access is allowed, because contact modification hooks may run in parallel, *but* deferred changes are supported through `Commands` passed to the hooks. An example implementation to support interaction groups and one-way platforms might look like this: ```rust use avian2d::prelude::*; use bevy::{ecs::system::SystemParam, prelude::*}; /// A component that groups entities for interactions. Only entities in the same group can collide. #[derive(Component)] struct InteractionGroup(u32); /// A component that marks an entity as a one-way platform. #[derive(Component)] struct OneWayPlatform; // Define a `SystemParam` for the collision hooks. #[derive(SystemParam)] struct MyHooks<'w, 's> { interaction_query: Query<'w, 's, &'static InteractionGroup>, platform_query: Query<'w, 's, &'static Transform, With<OneWayPlatform>>, } // Implement the `CollisionHooks` trait. impl CollisionHooks for MyHooks<'_, '_> { fn filter_pairs(&self, entity1: Entity, entity2: Entity, _commands: &mut Commands) -> bool { // Only allow collisions between entities in the same interaction group. // This could be a basic solution for "multiple physics worlds" that don't interact. let Ok([group1, group2]) = self.interaction_query.get_many([entity1, entity2]) else { return true; }; group1.0 == group2.0 } fn modify_contacts(&self, contacts: &mut Contacts, commands: &mut Commands) -> bool { // Allow entities to pass through the bottom and sides of one-way platforms. // See the `one_way_platform_2d` example for a full implementation. let (entity1, entity2) = (contacts.entity1, contacts.entity2); !self.is_hitting_top_of_platform(entity1, entity2, &self.platform_query, &contacts, commands) } } ``` The hooks can then be added to the app using `PhysicsPlugins::with_collision_hooks`: ```rust fn main() { App::new() .add_plugins(( DefaultPlugins, PhysicsPlugins::default().with_collision_hooks::<MyHooks>(), )) .run(); } ``` > [!NOTE] > > The hooks are passed to the `BroadPhasePlugin` and `NarrowPhasePlugin` with generics. An app can only have one set of hooks defined. > > Where are the generics on `PhysicsPlugins` then? `bevy_rapier` requires them on `RapierPhysicsPlugin`, forcing people to specify generics even if hooks aren't used, like `RapierPhysicsPlugin::<()>::default()` (see dimforge/bevy_rapier#501). > > Given that this is the first thing users do with the engine, I wanted to avoid forcing unnecessary generics. I'm using a subtle trick to get around them; `PhysicsPlugins` has no generics, but there is a separate `PhysicsPluginsWithHooks` wrapper with a similar API that is returned by `with_collision_hooks`. This abstraction is largely transparent to users, and gets around unnecessary generics in the public API. It is rare to want hooks to run for every single collision pair. Thus, hooks are *only* called for collisions where at least one entity has the new `ActiveCollisionHooks` component with the corresponding flags set. By default, no hooks are called. ```rust // Spawn a collider with filtering hooks enabled. commands.spawn((Collider::capsule(0.5, 1.5), ActiveCollisionHooks::FILTER_PAIRS)); // Spawn a collider with both filtering and contact modification hooks enabled. commands.spawn(( Collider::capsule(0.5, 1.5), ActiveCollisionHooks::FILTER_PAIRS | ActiveCollisionHooks::MODIFY_CONTACTS )); // Alternatively, all hooks can be enabled with `ActiveCollisionHooks::all()`. commands.spawn((Collider::capsule(0.5, 1.5), ActiveCollisionHooks::all())); ``` ### Comparison With `bevy_rapier` The design of the collision hooks is partially inspired by `bevy_rapier`, but with what I think is a slightly friendlier and more flexible API. Some core differences: - Rapier has ["context views"](https://docs.rs/bevy_rapier3d/0.28.0/bevy_rapier3d/pipeline/struct.ContactModificationContextView.html) for both pair filters and contact modification, with a `raw` property you need to access. It provides read-only access to some internal Rapier data (using Nalgebra types) and a contact manifold, and write-access to a contact normal and "solver contacts". There seems to be no way to queue commands or otherwise perform changes to the ECS, only read-only access. - My pair filters simply provide the entities and access to `Commands`, while the contact modification hook provides mutable access to the `Contacts` (*not* necessarily just one manifold) between a contact pair, and to `Commands`. Read-only data about the involved entities can be queried with the ECS. Personally, I think `bevy_rapier`'s hooks introduce a bit too much complexity and new APIs for Bevy users; there are "context views", contact manifolds, solver contacts, a bunch of internal Rapier structures, and everything uses Nalgebra types. I tried to keep it more simple, with the same contact types people already use when accessing the `Collisions` resource, while supporting read-only ECS access using the system parameter and deferred changes using `Commands`. No weird context views or Nalgebra types. Rapier provides solver contacts, while my implementation provides raw narrow phase contact data. Both have their trade-offs, but using raw contact data introduces less new concepts, *and* it allows earlier termination, since the data for solver contacts doesn't need to be computed (though our implementation there is somewhat different from Rapier anyway). Currently, my implementation runs hooks per *collision pair* (`Contacts`), not per *manifold* (`ContactManifold`). This provides some more data and allows entire collision pairs to be ignored at once if desired. I'm not 100% sure which is preferable though; many other engines seem to have contact modification be per-manifold. There is a possibility that we change this at some point. ### Other Changes - Updated the `one_way_platform_2d` example to use collision hooks, and overall improved the example code. - The broad phase now stores `AabbIntervalFlags` instead of several booleans for AABB intervals. - `BroadPhasePlugin`, `NarrowPhasePlugin`, and many `NarrowPhase` methods now take generics for `CollisionHooks`. - Reworked some narrow phase contact logic slightly. - Updated and improved some docs. ## Follow-Up Work I have several follow-up PRs in progress: 1. Per-manifold tangent velocities (for e.g. conveyor belts) and friction and restitution (for non-uniform material properties) 2. Rename many contact types and properties for clarity, improve docs and APIs 3. Contact graph 4. Reworked contact pair management I expect them to be ready in that order. For 3-4, I would like #564 first. It is also highly likely that we will deprecate the `PostProcessCollisions` schedule in favor of these hooks. --- ## Migration Guide For custom contact filtering and modification logic, it is now recommended to define `CollisionHooks` instead of manually accessing and modifying `Collisions` in the `PostProcessCollisions` schedule. The `BroadPhasePlugin`, `NarrowPhasePlugin`, and many `NarrowPhase` methods now take generics for `CollisionHooks`.
1 parent 7e945ae commit 6424aa6

File tree

9 files changed

+705
-214
lines changed

9 files changed

+705
-214
lines changed

crates/avian2d/examples/one_way_platform_2d.rs

Lines changed: 150 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,35 @@
11
//! A 2D platformer example with one-way platforms to demonstrate
2-
//! filtering collisions with systems in the `PostProcessCollisions` schedule.
2+
//! contact modification with `CollisionHooks`.
33
//!
44
//! Move with arrow keys, jump with Space and descend through
55
//! platforms by pressing Space while holding the down arrow.
66
7+
#![allow(clippy::type_complexity)]
8+
79
use avian2d::{math::*, prelude::*};
8-
use bevy::{prelude::*, utils::HashSet};
10+
use bevy::{
11+
ecs::system::{lifetimeless::Read, SystemParam},
12+
prelude::*,
13+
utils::HashSet,
14+
};
915
use examples_common_2d::ExampleCommonPlugin;
1016

1117
fn main() {
1218
App::new()
1319
.add_plugins((
1420
DefaultPlugins,
1521
ExampleCommonPlugin,
16-
// Add physics plugins and specify a units-per-meter scaling factor, 1 meter = 20 pixels.
17-
// The unit allows the engine to tune its parameters for the scale of the world, improving stability.
18-
PhysicsPlugins::default().with_length_unit(20.0),
22+
PhysicsPlugins::default()
23+
// Specify a units-per-meter scaling factor, 1 meter = 20 pixels.
24+
// The unit allows the engine to tune its parameters for the scale of the world, improving stability.
25+
.with_length_unit(20.0)
26+
// Add our custom collision hooks.
27+
.with_collision_hooks::<PlatformerCollisionHooks>(),
1928
))
2029
.insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.1)))
2130
.insert_resource(Gravity(Vector::NEG_Y * 1000.0))
2231
.add_systems(Startup, setup)
2332
.add_systems(Update, (movement, pass_through_one_way_platform))
24-
.add_systems(PostProcessCollisions, one_way_platform)
2533
.run();
2634
}
2735

@@ -34,17 +42,21 @@ struct MovementSpeed(Scalar);
3442
#[derive(Component)]
3543
struct JumpImpulse(Scalar);
3644

45+
// Enable contact modification for one-way platforms with the `ActiveCollisionHooks` component.
46+
// Here we use required components, but you could also add it manually.
3747
#[derive(Clone, Eq, PartialEq, Debug, Default, Component)]
48+
#[require(ActiveCollisionHooks(|| ActiveCollisionHooks::MODIFY_CONTACTS))]
3849
pub struct OneWayPlatform(HashSet<Entity>);
3950

51+
/// A component to control how an actor interacts with a one-way platform.
4052
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, Component, Reflect)]
4153
pub enum PassThroughOneWayPlatform {
4254
#[default]
43-
/// Passes through a `OneWayPlatform` if the contact normal is in line with the platform's local-space up vector
55+
/// Passes through a `OneWayPlatform` if the contact normal is in line with the platform's local-space up vector.
4456
ByNormal,
45-
/// Always passes through a `OneWayPlatform`, temporarily set this to allow an actor to jump down through a platform
57+
/// Always passes through a `OneWayPlatform`, temporarily set this to allow an actor to jump down through a platform.
4658
Always,
47-
/// Never passes through a `OneWayPlatform`
59+
/// Never passes through a `OneWayPlatform`.
4860
Never,
4961
}
5062

@@ -171,70 +183,70 @@ fn pass_through_one_way_platform(
171183
}
172184
}
173185

174-
/// Allows entities to pass through [`OneWayPlatform`] entities.
175-
///
176-
/// Passing through is achieved by removing the collisions between the [`OneWayPlatform`]
177-
/// and the other entity if the entity should pass through.
178-
/// If a [`PassThroughOneWayPlatform`] is present on the non-platform entity,
179-
/// the value of the component dictates the pass-through behaviour.
180-
///
181-
/// Entities known to be passing through each [`OneWayPlatform`] are stored in the
182-
/// [`OneWayPlatform`]. If an entity is known to be passing through a [`OneWayPlatform`],
183-
/// it is allowed to continue to do so, even if [`PassThroughOneWayPlatform`] has been
184-
/// set to disallow passing through.
185-
///
186-
/// > Note that this is a very simplistic implementation of one-way
187-
/// > platforms to demonstrate filtering collisions via [`PostProcessCollisions`].
188-
/// > You will probably want something more robust to implement one-way
189-
/// > platforms properly, or may elect to use a sensor collider for your entities instead,
190-
/// > which means you won't need to filter collisions at all.
191-
///
192-
/// #### When an entity is known to already be passing through the [`OneWayPlatform`]
193-
///
194-
/// Any time an entity begins passing through a [`OneWayPlatform`], it is added to the
195-
/// [`OneWayPlatform`]'s set of currently active penetrations, and will be allowed to
196-
/// continue to pass through the platform until it is no longer penetrating the platform.
197-
///
198-
/// The entity is allowed to continue to pass through the platform as long as at least
199-
/// one contact is penetrating.
200-
///
201-
/// Once all of the contacts are no longer penetrating the [`OneWayPlatform`], or all contacts
202-
/// have stopped, the entity is forgotten about and the logic falls through to the next part.
203-
///
204-
/// #### When an entity is NOT known to be passing through the [`OneWayPlatform`]
205-
///
206-
/// Depending on the setting of [`PassThroughOneWayPlatform`], the entity may be allowed to
207-
/// pass through.
208-
///
209-
/// If no [`PassThroughOneWayPlatform`] is present, [`PassThroughOneWayPlatform::ByNormal`] is used.
210-
///
211-
/// [`PassThroughOneWayPlatform`] may be in one of three states:
212-
/// 1. [`PassThroughOneWayPlatform::ByNormal`]
213-
/// - This is the default state
214-
/// - The entity may be allowed to pass through the [`OneWayPlatform`] depending on the contact normal
215-
/// - If all contact normals are in line with the [`OneWayPlatform`]'s local-space up vector,
216-
/// the entity is allowed to pass through
217-
/// 2. [`PassThroughOneWayPlatform::Always`]
218-
/// - The entity will always pass through the [`OneWayPlatform`], regardless of contact normal
219-
/// - This is useful for allowing an entity to jump down through a platform
220-
/// 3. [`PassThroughOneWayPlatform::Never`]
221-
/// - The entity will never pass through the [`OneWayPlatform`], meaning the platform will act
222-
/// as normal hard collision for this entity
223-
///
224-
/// Even if an entity is changed to [`PassThroughOneWayPlatform::Never`], it will be allowed to pass
225-
/// through a [`OneWayPlatform`] if it is already penetrating the platform. Once it exits the platform,
226-
/// it will no longer be allowed to pass through.
227-
fn one_way_platform(
228-
mut one_way_platforms_query: Query<&mut OneWayPlatform>,
186+
// Define a custom `SystemParam` for our collision hooks.
187+
// It can have read-only access to queries, resources, and other system parameters.
188+
#[derive(SystemParam)]
189+
struct PlatformerCollisionHooks<'w, 's> {
190+
one_way_platforms_query: Query<'w, 's, Read<OneWayPlatform>>,
191+
// NOTE: This precludes a `OneWayPlatform` passing through a `OneWayPlatform`.
229192
other_colliders_query: Query<
230-
Option<&PassThroughOneWayPlatform>,
231-
(With<Collider>, Without<OneWayPlatform>), // NOTE: This precludes OneWayPlatform passing through a OneWayPlatform
193+
'w,
194+
's,
195+
Option<Read<PassThroughOneWayPlatform>>,
196+
(With<Collider>, Without<OneWayPlatform>),
232197
>,
233-
mut collisions: ResMut<Collisions>,
234-
) {
235-
// This assumes that Collisions contains empty entries for entities
236-
// that were once colliding but no longer are.
237-
collisions.retain(|contacts| {
198+
}
199+
200+
// Implement the `CollisionHooks` trait for our custom system parameter.
201+
impl CollisionHooks for PlatformerCollisionHooks<'_, '_> {
202+
// Below is a description of the logic used for one-way platforms.
203+
204+
/// Allows entities to pass through [`OneWayPlatform`] entities.
205+
///
206+
/// Passing through is achieved by removing the collisions between the [`OneWayPlatform`]
207+
/// and the other entity if the entity should pass through.
208+
/// If a [`PassThroughOneWayPlatform`] is present on the non-platform entity,
209+
/// the value of the component dictates the pass-through behaviour.
210+
///
211+
/// Entities known to be passing through each [`OneWayPlatform`] are stored in the
212+
/// [`OneWayPlatform`]. If an entity is known to be passing through a [`OneWayPlatform`],
213+
/// it is allowed to continue to do so, even if [`PassThroughOneWayPlatform`] has been
214+
/// set to disallow passing through.
215+
///
216+
/// #### When an entity is known to already be passing through the [`OneWayPlatform`]
217+
///
218+
/// When an entity begins passing through a [`OneWayPlatform`], it is added to the
219+
/// [`OneWayPlatform`]'s set of active penetrations, and will be allowed to continue
220+
/// to pass through until it is no longer penetrating the platform.
221+
///
222+
/// #### When an entity is *not* known to be passing through the [`OneWayPlatform`]
223+
///
224+
/// Depending on the setting of [`PassThroughOneWayPlatform`], the entity may be allowed to
225+
/// pass through.
226+
///
227+
/// If no [`PassThroughOneWayPlatform`] is present, [`PassThroughOneWayPlatform::ByNormal`] is used.
228+
///
229+
/// [`PassThroughOneWayPlatform`] may be in one of three states:
230+
/// 1. [`PassThroughOneWayPlatform::ByNormal`]
231+
/// - This is the default state
232+
/// - The entity may be allowed to pass through the [`OneWayPlatform`] depending on the contact normal
233+
/// - If all contact normals are in line with the [`OneWayPlatform`]'s local-space up vector,
234+
/// the entity is allowed to pass through
235+
/// 2. [`PassThroughOneWayPlatform::Always`]
236+
/// - The entity will always pass through the [`OneWayPlatform`], regardless of contact normal
237+
/// - This is useful for allowing an entity to jump down through a platform
238+
/// 3. [`PassThroughOneWayPlatform::Never`]
239+
/// - The entity will never pass through the [`OneWayPlatform`], meaning the platform will act
240+
/// as normal hard collision for this entity
241+
///
242+
/// Even if an entity is changed to [`PassThroughOneWayPlatform::Never`], it will be allowed to pass
243+
/// through a [`OneWayPlatform`] if it is already penetrating the platform. Once it exits the platform,
244+
/// it will no longer be allowed to pass through.
245+
fn modify_contacts(&self, contacts: &mut Contacts, commands: &mut Commands) -> bool {
246+
// This is the contact modification hook, called after collision detection,
247+
// but before constraints are created for the solver. Mutable access to the ECS
248+
// is not allowed, but we can queue commands to perform deferred changes.
249+
238250
// Differentiate between which normal of the manifold we should use
239251
enum RelevantNormal {
240252
Normal1,
@@ -243,11 +255,22 @@ fn one_way_platform(
243255

244256
// First, figure out which entity is the one-way platform, and which is the other.
245257
// Choose the appropriate normal for pass-through depending on which is which.
246-
let (mut one_way_platform, other_entity, relevant_normal) =
247-
if let Ok(one_way_platform) = one_way_platforms_query.get_mut(contacts.entity1) {
248-
(one_way_platform, contacts.entity2, RelevantNormal::Normal1)
249-
} else if let Ok(one_way_platform) = one_way_platforms_query.get_mut(contacts.entity2) {
250-
(one_way_platform, contacts.entity1, RelevantNormal::Normal2)
258+
let (platform_entity, one_way_platform, other_entity, relevant_normal) =
259+
if let Ok(one_way_platform) = self.one_way_platforms_query.get(contacts.entity1) {
260+
(
261+
contacts.entity1,
262+
one_way_platform,
263+
contacts.entity2,
264+
RelevantNormal::Normal1,
265+
)
266+
} else if let Ok(one_way_platform) = self.one_way_platforms_query.get(contacts.entity2)
267+
{
268+
(
269+
contacts.entity2,
270+
one_way_platform,
271+
contacts.entity1,
272+
RelevantNormal::Normal2,
273+
)
251274
} else {
252275
// Neither is a one-way-platform, so accept the collision:
253276
// we're done here.
@@ -268,17 +291,23 @@ fn one_way_platform(
268291
return false;
269292
} else {
270293
// If it's no longer penetrating us, forget it.
271-
one_way_platform.0.remove(&other_entity);
294+
commands.queue(OneWayPlatformCommand::Remove {
295+
platform_entity,
296+
entity: other_entity,
297+
});
272298
}
273299
}
274300

275-
match other_colliders_query.get(other_entity) {
301+
match self.other_colliders_query.get(other_entity) {
276302
// Pass-through is set to never, so accept the collision.
277303
Ok(Some(PassThroughOneWayPlatform::Never)) => true,
278304
// Pass-through is set to always, so always ignore this collision
279305
// and register it as an entity that's currently penetrating.
280306
Ok(Some(PassThroughOneWayPlatform::Always)) => {
281-
one_way_platform.0.insert(other_entity);
307+
commands.queue(OneWayPlatformCommand::Add {
308+
platform_entity,
309+
entity: other_entity,
310+
});
282311
false
283312
}
284313
// Default behaviour is "by normal".
@@ -297,10 +326,50 @@ fn one_way_platform(
297326
} else {
298327
// Otherwise, ignore the collision and register
299328
// the other entity as one that's currently penetrating.
300-
one_way_platform.0.insert(other_entity);
329+
commands.queue(OneWayPlatformCommand::Add {
330+
platform_entity,
331+
entity: other_entity,
332+
});
301333
false
302334
}
303335
}
304336
}
305-
});
337+
}
338+
}
339+
340+
/// A command to add/remove entities to/from the set of entities
341+
/// that are currently in contact with a one-way platform.
342+
enum OneWayPlatformCommand {
343+
Add {
344+
platform_entity: Entity,
345+
entity: Entity,
346+
},
347+
Remove {
348+
platform_entity: Entity,
349+
entity: Entity,
350+
},
351+
}
352+
353+
impl Command for OneWayPlatformCommand {
354+
fn apply(self, world: &mut World) {
355+
match self {
356+
OneWayPlatformCommand::Add {
357+
platform_entity,
358+
entity,
359+
} => {
360+
if let Some(mut platform) = world.get_mut::<OneWayPlatform>(platform_entity) {
361+
platform.0.insert(entity);
362+
}
363+
}
364+
365+
OneWayPlatformCommand::Remove {
366+
platform_entity,
367+
entity,
368+
} => {
369+
if let Some(mut platform) = world.get_mut::<OneWayPlatform>(platform_entity) {
370+
platform.0.remove(&entity);
371+
}
372+
}
373+
}
374+
}
306375
}

0 commit comments

Comments
 (0)