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

Rework Contact Pair Management #683

Merged
merged 56 commits into from
Mar 31, 2025
Merged

Rework Contact Pair Management #683

merged 56 commits into from
Mar 31, 2025

Conversation

Jondolf
Copy link
Owner

@Jondolf Jondolf commented Mar 29, 2025

Objective

Currently, contact pair management works as follows:

  1. Collect all pairs of entities with intersecting AABBs in the BroadCollisionPairs resource.
  2. Reset collision statuses during_previous_frame and during_current_frame of each existing contact to true and false respectively.
  3. Iterate over all BroadCollisionPairs in parallel. For each pair:
    1. Get the previous contacts from Collisions, if they exist, and set during_previous_frame accordingly.
    2. Compute new contact manifolds.
    3. Match contacts.
    4. Insert the new contact data to Collisions.
  4. Generate contact constraints.
  5. After the solver, report contacts (send collision events and update CollidingEntities).
  6. Remove contact pairs for which during_current_frame is false.

There are a lot of inefficiencies here.

  • We collect all broad phase pairs from scratch to a Vec every frame.
  • We iterate through all collisions at least three separate times every frame for handling contact status changes (reset statuses, report contacts, remove ended contacts).
  • We have to do a lookup for previous contacts for every contact pair.
  • The logic for resetting collision statuses involves ECS queries and confusing state management.
  • The parallel loop over BroadCollisionPairs collects collisions into new vectors every time.

Overall, there is an excessive amount of iteration and allocations, and the logic for managing contact statuses is very confusing.

In addition, the Collisions resource itself is not efficient for our purposes. There are many cases where you may need to iterate over contacts associated with a specific entity, but this currently requires iterating through all collisions because collisions are just stored in an IndexMap. To resolve this, we need a more graph-like structure.

Solution

Change Collisions to a ContactGraph, and rework contact pair management to look like the following:

  1. Find new broad phase pairs and add them to the ContactGraph directly. Duplicate pairs are avoided with fast lookups into a HashSet<PairKey>.
  2. Iterate over all pairs in the ContactGraph in parallel, maintaining thread-local bit vectors to track contact status changes. For each contact pair:
    1. Test if AABBs still overlap.
    2. If the AABBs are disjoint, set ContactPairFlags::DISJOINT_AABB and the status change bit for this contact pair. Continue to the next pair.
    3. Otherwise, update the contact manifolds.
    4. Match contacts.
    5. Set flags for whether the contact is touching, and whether it started or stopped touching.
  3. Combine thread-local bit vectors into a global bit vector with bitwise OR.
  4. Serially iterate through set bits using the count trailing zeros method. For each contact pair with a changed status:
    1. If the AABBs are disjoint, send the CollisionEnded event (if events are enabled), update CollidingEntities, and remove the pair from Collisions.
    2. If the colliders started touching, send the CollisionStared event (if events are enabled) and update CollidingEntities.
    3. If the AABBs stopped touching, send the CollisionEnded event (if events are enabled) and update CollidingEntities.
  5. Generate contact constraints.

Contact removal for removed or disabled colliders is now also handled with observers.

This improves several aspects:

  • The broad phase only adds new pairs, and adds them to the ContactGraph directly.
  • We only iterate through collisions once every frame for handling contact status changes, using bit scanning intrinsics to only iterate over pairs that actually changed.
  • We are mutably iterating over the ContactGraph directly, and don't need to do separate lookups for previous contacts or do any extra allocations.

As you may have noticed, a contact pair now exists between two colliders if their AABBs are touching, even if the actual shapes aren't. This is important for the pair management logic, though it does mean that the ContactGraph can now have a lot more contact pairs in some cases.

Contact Graph

Previously, Collisions used an IndexMap to store collisions, keyed by (Entity, Entity). The motivation was that we get vec-like iteration speed, with preserved insertion order and fast lookups by entity pairs.

However, there are scenarios where you may need to iterate over the entities colliding with a given entity, such as for simulation islands or even gameplay logic. With just an IndexMap, this requires iterating over all pairs.

This PR adds an undirected graph data structure called UnGraph, based on petgraph, simplified and tailored for our use cases.

#[derive(Clone, Debug)]
pub struct UnGraph<N, E> {
    nodes: Vec<Node<N>>,
    edges: Vec<Edge<E>>,
}

This is used for the new ContactGraph to provide faster and more powerful queries over contact pairs. The following methods are available:

  • get(&self, entity1: Entity, entity2: Entity)
  • get_mut(&mut self, entity1: Entity, entity2: Entity)
  • contains(&self, entity1: Entity, entity2: Entity)
  • contains_key(&self, pair_key: &PairKey)
  • iter(&self)
  • iter_touching(&self)
  • iter_mut(&mut self)
  • iter_touching_mut(&mut self)
  • collisions_with(&self, entity: Entity)
  • collisions_with_mut(&mut self, entity: Entity)
  • entities_colliding_with(&self, entity: Entity)

and a few ones primarily for internals:

  • add_pair(&mut self, contacts: Contacts)
  • add_pair_with_key(&mut self, contacts: Contacts, pair_key: PairKey)
  • insert_pair(&mut self, contacts: Contacts)
  • insert_pair_with_key(&mut self, contacts: Contacts, pair_key: PairKey)
  • remove_pair(&mut self, entity1: Entity, entity2: Entity)
  • remove_collider_with(&mut self, entity: Entity, pair_callback: F)

The graph doesn't let us directly get nodes or edges by Entity ID. However, a new EntityDataIndex is used to map Entity IDs to graph nodes.

/// A container for data associated with entities in a generational arena.
#[derive(Clone, Debug, Default)]
pub struct EntityDataIndex<T> {
    data: Vec<(u32, T)>,
}

This is modeled after Rapier's Coarena.

Collisions System Parameter

The ContactGraph resource contains both touching and non-touching contacts. This may be inconvenient and confusing for new users.

To provide a simpler, more user-friendly API, a Collisions SystemParam has been added. It is similar to the old Collisions resource, and only provides access to touching contacts. It doesn't allow mutation, as contact modification and filtering should typically be handled via CollisionHooks.

#[derive(Component)]
struct PressurePlate;

fn activate_pressure_plates(mut query: Query<Entity, With<PressurePlate>>, collisions: Collisions) {
    for pressure_plate in &query {
        // Compute the total impulse applied to the pressure plate.
        let mut total_impulse = 0.0;

        for contact_pair in collisions.collisions_with(pressure_plate) {
            total_impulse += contact_pair.total_normal_impulse_magnitude();
        }

        if total_impulse > 5.0 {
            println!("Pressure plate activated!");
        }
    }
}

Contact Reporting

Previously, the ContactReportingPlugin sent the CollisionStarted, CollisionEnded, and Collision events and updated CollidingEntities for all contact pairs after the solver. This required iterating through all contacts and performing lots of queries, which had meaningful overhead, even for apps that don't need collision events, or only need them for a few entities.

Very few applications actually need collision events for all entities. In most engines, contact reporting/monitoring is entirely optional, and typically opt-in. Thus, a new CollisionEventsEnabled component has been added. Collision events are only sent if either entity in a collision has the component.

The ContactReportingPlugin has also been entirely removed, and contact reporting is now handled directly by the narrow phase when processing contact status changes. This removes the need for extra iteration or queries.

Finally, the Collision event has been removed. It was largely unnecessary, as the collision data can be accessed through Collisions directly. And semantically, it didn't feel like an "event" as it was sent every frame during continuous contact.

Performance

In the new pyramid_2d example, with a pyramid that has a base of 50 boxes, 1276 total colliders, and 6 substeps, the old timings with the parallel feature looked like the following after 500 steps:

Old multi-threaded

Now, they look like this:

New multi-threaded

Notably:

  • Narrow Phase: 4.5x as fast, despite also handling collision events
  • Collision Events: This whole separate step is gone, and handled by the narrow phase
  • Store Impulses: From 0.12 ms down to 0.03 ms, because fetching contacts is handled more efficiently
  • Other: From 0.97 ms down to 0.18 ms, largely due to wake_on_collision_ended being removed in favor of much more efficient logic integrated into the narrow phase

The total step time in this scene is reduced by 1.76 ms. The difference should be larger the more collisions there are.

Single-threaded performance is also improved, though not quite as much. In the same test scene, the old timings looked like this:

Old single-threaded

Now, they look like this:

New single-threaded

reducing the total step time in this scene by 0.7 ms.

The changes in this PR also unlock many future optimizations:

  • Generate contact constraints in parallel directly in the contact pair update loop
  • Persistent simulation islands (benefits from a contact graph)

It is worth noting that we are currently using Bevy's built-in ComputeTaskPool for parallelism, which limits the number of available threads. Using it, we are still slightly behind Rapier's narrow phase performance. However, I have measured that if we manually increase the size of the thread pool, or use rayon, we now match or even slightly outperform Rapier's narrow phase.

Future Work

  • Persistent simulation islands
  • Improved broad phase using OBVHS
  • Rename Contacts to ContactPair
  • Reuse contact manifolds and match contacts more effectively (likely not possible with Parry due to different ContactManifold types)

Migration Guide

PostProcessCollisions

The PostProcessCollisions schedule and NarrowPhaseSet::PostProcess system set have been removed, as it is incompatible with new optimizations to narrow phase collision detection. Instead, use CollisionHooks for contact modification.

Contact Reporting

The ContactReportingPlugin and PhysicsStepSet have been removed. Contact reporting is now handled by the NarrowPhasePlugin directly.

The Collision event no longer exists. Instead, use Collisions directly, or get colliding entities using the CollidingEntities component.

The CollisionStarted and CollisionEnded events are now only sent if either entity in the collision has the CollisionEventsEnabled component. If you'd like to revert to the old behavior of having collision events for all entities, consider making CollisionEventsEnabled a required component for Collider:

app.register_required_components::<Collider, CollisionEventsEnabled>();

Collisions

The Collisions resource is now a SystemParam.

// Old
fn iter_collisions(collisions: Res<Collisions>) {
    todo!()
}

// New
fn iter_collisions(collisions: Collisions) {
    todo!()
}

Internally, Collisions now stores a ContactGraph that stores both touching and non-touching contact pairs. The Collisions system parameter is just a wrapper that provides a simpler API and only returns touching contacts.

The collisions_with_entity method has also been renamed to collisions_with, and all methods that mutatate, add, or remove contact pairs have been removed from Collisions. However, the following mutating methods are available on ContactGraph:

  • get_mut
  • iter_mut
  • iter_touching_mut
  • collisions_with_mut
  • add_pair/add_pair_with_key
  • insert_pair/insert_pair_with_key
  • remove_pair
  • remove_collider_with

For most scenarios, contact modification and removal are intended to be handled with CollisionHooks.

Contacts

The is_sensor, during_current_frame, and during_previous_frame properties of Contacts have been removed in favor of a flags property storing information in a more compact bitflag format. The is_sensor, is_touching, collision_started, and collision_ended helper methods can be used instead.

ContactManifold

Methods such as AnyCollider::contact_manifolds_with_context now take &mut Vec<ContactManifold> instead of returning a new vector every time. This allows manifolds to be persisted more effectively, and reduces unnecessary allocations.

BroadCollisionPairs

The BroadCollisionPairs resource has been removed. Use the ContactGraph resource instead.

AabbIntersections

The AabbIntersections component has been removed. Use ContactGraph::entities_colliding_with instead.

- The broad phase now emits new collision pairs and stores all pairs in a HashSet

- Contact status and kind is now tracked with `ContactPairFlags` instead of booleans

- The narrow phase adds new collision pairs, updates existing pairs, and responds to state changes separately instead of overwriting and doing extra work for persistent contact

- State changes are tracked with bit vectors (bit sets), which are fast to iterate serially

- The narrow phase is responsible for collision events instead of the `ContactReportingPlugin`
- Renamed `BroadCollisionPairs` to `BroadPhasePairSet`
- Added `BroadPhasePairSet` for fast pair lookup with new `PairKey`
- Improve broad phase docs
…pt-in

- Removed `BroadPhaseAddedPairs`
- Renamed `BroadPhasePairSet` to `BroadPhasePairs`
- Moved contact creation to broad phase to improve persistence
- Removed some graph querying overhead from contact pair removal by using the `EdgeIndex` directly
- Made collision events opt-in with `CollisionEventsEnabled` component
- Improved a lot of docs
@Jondolf Jondolf added C-Enhancement New feature or request C-Performance Improvements or questions related to performance A-Collision Relates to the broad phase, narrow phase, colliders, or other collision functionality C-Breaking-Change This change removes or changes behavior or APIs, requiring users to adapt D-Complex Challenging from a design or technical perspective. Ask for help if you'd like to help tackle this! labels Mar 29, 2025
@Jondolf Jondolf added this to the 0.3 milestone Mar 29, 2025
@Jondolf Jondolf merged commit e7d1a27 into main Mar 31, 2025
5 checks passed
@Jondolf Jondolf deleted the contact-graph branch March 31, 2025 14:40
Jondolf added a commit that referenced this pull request Mar 31, 2025
# Objective

Especially after #683, we use the term "contact pair" a lot. However, the contact pair type is called `Contacts`. This name is very ambiguous: does it represent all contacts in the world, all contacts between two entities, contacts belonging to a specific contact surface, or something else?

## Solution

Rename `Contacts` to `ContactPair`. It much more accurately describes what the type represents: contact data for a pair of entities that may be in contact. This is also the name used by Rapier.

---

## Migration Guide

`Contacts` has been renamed to `ContactPair`.
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-Breaking-Change This change removes or changes behavior or APIs, requiring users to adapt C-Enhancement New feature or request C-Performance Improvements or questions related to performance D-Complex Challenging from a design or technical perspective. Ask for help if you'd like to help tackle this!
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant