Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collision Hooks #610

Merged
merged 7 commits into from
Jan 1, 2025
Merged

Collision Hooks #610

merged 7 commits into from
Jan 1, 2025

Conversation

Jondolf
Copy link
Owner

@Jondolf Jondolf commented Dec 30, 2024

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)
  • Rapier: PhysicsHooks trait with filter_contact_pair/filter_intersection_pair and modify_solver_contacts (see docs)
  • Jolt: ContactListener with OnContactValidate, OnContactAdded, OnContactPersisted, and OnContactRemoved (see docs)

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:

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:

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.

// 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" 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.

@Jondolf Jondolf added C-Enhancement New feature or request A-Collision Relates to the broad phase, narrow phase, colliders, or other collision functionality labels Dec 30, 2024
@Jondolf Jondolf added this to the 0.3 milestone Dec 30, 2024
@Jondolf Jondolf enabled auto-merge (squash) January 1, 2025 13:16
@Jondolf Jondolf merged commit 6424aa6 into main Jan 1, 2025
5 checks passed
@Jondolf Jondolf deleted the collision-hooks branch January 1, 2025 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Collision Relates to the broad phase, narrow phase, colliders, or other collision functionality C-Enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Collision Filtering and Modification
1 participant