Tainted \\ Coders

Bevy Physics: XPBD

Last updated:

Bevy XPBD is written by Joona Aalto based on an original tutorial by Johan Helsing.

At a high level the library is very similar to bevy_rapier and should feel familiar to those that have experience with it.

Extended Position-Based Dynamics (XPBD) is an extension of PBD (Position-Based Dynamics) that aims to address some of its limitations and improve accuracy.

In traditional PBD, each object in the simulation is represented by a set of particles connected by constraints. The constraints define the desired relationships between the particles, such as resting distances, angles, or collisions. Each iteration PBD updates the positions based on these constraints.

However, PBD alone had some problems that gave it a reputation for being non-physical:

  • It does not use physical quantities and units
  • The stiffness is iteration and time step dependent
  • The integration is not physically accurate
  • It is dependent on the order of constraint handling
  • It depends on mesh tessellation
  • It converges slowly

XPBD introduces additional measures to ensure the physical accuracy of the simulation and reduce the dependence on the time step size and iteration count:

  • Compliance Parameter: XPBD introduces a compliance parameter that is the inverse of the constraint stiffness parameter. This way constraints can be made infinitely stiff in a numerically stable way using a compliance of 0.
  • Implicit Integration: PBD uses an explicit integration scheme, where positions are updated based on velocities and time steps. In contrast, XPBD employs an implicit integration scheme, which takes into account the constraint forces while updating positions. Implicit integration handles the constraints more accurately, especially in scenarios with stiff constraints or fast motion.
  • Varying Time Step Sizes: PBD simulations are often sensitive to the time step size, with smaller time steps providing more accurate results. XPBD reduces this sensitivity by incorporating the constraint stiffness and implicit integration, allowing for larger time steps while maintaining stability and accuracy.

The main difference between PBD and XPBD is in how they handle their constraints. In PBD, velocities are typically not explicitly used during the constraint-solving process. Instead, PBD primarily focuses on satisfying position-based constraints, such as ensuring objects don’t overlap or maintaining specified distances between objects.

On the other hand, XPBD takes a more refined approach to handle velocities. It explicitly considers velocities during the constraint-solving process to improve accuracy. XPBD incorporates velocity-based corrections to ensure that not only the positions but also the velocities of objects satisfy the constraints.

The physics loop looks like this simplified pseudo code:

while simulating {
    // Broad phase
    collect_collsion_pairs()

    for _ in substep_count {
        // Integration is moving our bodies along their paths without
        // any constraints
        for body in rigid_bodies {
            integrate_position(&body);
            integrate_rotation(&body);
        }

        // Solving is applying the constraints to our naive positions.
        // Constraints are handled by looping through them and applying
        // positional and angular corrections to the bodies in order to
        // satisfy the constraints.
        for _ in solver_iteration_count {
            for constraint in constraints {
                solve_constraint(&constraint, &rigid_bodies);
            }
        }

        // Finally we change our original velocities to the new one
        // based on our solving above
        for body in rigid_bodies {
            update_velocity(&body);
        }

        // Velocity corrections caused by dynamic friction, restitution and 
        // joint damping are applied.
        solve_velocities(&rigid_bodies);
    }
}

Each sub-step has a simulation time budget per frame that is divided by our number of sub steps which it uses to iteratively solve the constraints.

Sub-stepping helps with long chains or stacks of objects that need many iterations for the constraints to be satisfied. This can help with tunnelling (where fast objects go through, rather than collide with each other). Not doing this would result in a much stretchier feel to our rigid bodies.

The reason why we use a solver_integration_count and loop over our solve_constraint is because constraints that depend on each other may need to be solved iteratively multiple times to be accurate.

Both substep_count and solver_iteration_count can be tweaked but its often better to keep the solver_iteration_count low and modify the substep_count to get a better trade-off as sub-stepping can provide better convergence, accuracy and energy conservation.

When we are talking about constraints we mean some sort of rule the rigid body has to follow. For example a distance constraint between two bodies where they cannot be less than some distance together. Your constraints would be any function that takes in the participating bodies and produces some kind of constraint error that is used to update the positions according to the error.

The advantage of using this over bevy_rapier is that the actual physics are done directly through the ECS. In bevy_rapier the physics engine has an internal representation of the physics that it projects back while in bevy_xpbd its native to Bevy’s ECS and doesn’t require syncing between the two contexts.

Adding Bevy XPBD to our apps

To start we can run cargo add bevy_xpbd_2d or bevy_xpbd_3d depending on our game.

The physics loop is controlled by PhysicsPlugins which we add to our Bevy app:

use bevy::prelude::*;
use bevy_xpbd_2d::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::default())
        .run();
}

The PhysicsPlugins is adding the other plugins from bevy_xpbd:

PhysicsSetupPlugin Sets up the physics engine by initializing the necessary schedules, sets and resources
PreparePlugin Runs systems at the start of each physics frame; initializes rigid bodies and colliders and updates components
BroadPhasePlugin Collects pairs of potentially colliding entities
IntegratorPlugin Integrates Newton’s 2nd law of motion, applying forces and moving entities according to their velocities
NarrowPhasePlugin Computes contacts between entities returned from the broad phase
ContactReportingPlugin Sends collision events and updates
SolverPlugin Solves positional and angular constraints, updates velocities and solves velocity constraints (dynamic friction and restitution)
SleepingPlugin Controls when bodies should be deactivated and marked as Sleeping to improve performance
SpatialQueryPlugin Handles spatial queries like ray casting and shape casting
SyncPlugin Synchronizes the engine’s Position and Rotation with Bevy’s Transform

The plugin is adding a few sets of systems to run during our app that look like the structure of the pseudo code we introduced at the beginning:

  1. Broad phase
  2. Substeps
    1. Integrate
    2. Narrow phase
    3. Solve positional and angular constraints
    4. Update velocities
    5. Solve velocity constraints (dynamic friction and restitution)
  3. Report contacts (send collision events)
  4. Sleeping
  5. Spatial queries (ray casting and shape casting)

Configuring our physics

Configuration takes place by adding resources to our app which overrides the originals provided by our PhysicsPlugin.

Changing the schedule

By default your physics will run during the PreUpdate schedule of your app but we can control the schedule our physics systems run in by providing it:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::new(FixedUpdate))
        .run();
}

Timesteps

We can use a fixed timestep for our physics by adding a PhysicsTimestep as a resource. This controls how many times per second our physics loop runs.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::default())
        // Use a 120 Hz fixed timestep instead of the default 60 Hz
        .insert_resource(Time::new_with(Physics::fixed_hz(120.0)))
        .run();
}

Accuracy vs Performance

Substepping count controls the accuracy/performance trade off in our physics loop. Higher values improve accuracy at the cost of performance.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::default())
        // Default value is normally 12
        .insert_resource(SubstepCount(30))
        .run();
}

We also have the option of controlling the IterationCount of each substep loop but its recommended to control this through the SubstepCount instead.

Sleeping

Sleeping is a technique that reduces the cost of simulating objects that are not moving to improve performance. We can tweak it by adding a SleepingThreshold resource:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PhysicsPlugins::default())
        // These are the default values
        .insert_resource(SleepingThreshold {
            linear: 0.1,
            angular: 0.2
        })
        .run();
}

Setting either of these to a negative disables sleeping for our entities.

Sleeping happens to any entity with a Sleeping component. There is additional components supporting sleeping such as TimeSleeping and SleepingDisabled.

Sleeping bodies are woken up when active bodies interact with them through a constraint.

Gravity

We set the default gravity with the Gravity resource, this is the force that is applied to all our entities.

fn main() {
App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins(PhysicsPlugins::default())
    .insert_resource(Gravity(Vec2::NEG_Y * 19.6))
    .run();
}

This resource can be changed as the game is running to modify the gravity applied universally.

If you want to set the gravity for a specific entity you can use a GravityScale component to apply a multiplication to the universal Gravity.

fn special_gravity(
    mut commands: Commands
) {
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(0.5),
        // 10% of the universal gravity for this RigidBody
        GravityScale(0.1)
    ));
}

Rigid bodies

Rigid bodies is very similar to Rapier. There are 3 kinds

// https://docs.rs/bevy_xpbd_2d/latest/bevy_xpbd_2d/components/enum.RigidBody.html
pub enum RigidBody {
    Dynamic,
    Static,
    Kinematic,
}

To spawn them we create an entity with at least a rigid body and a collider:

fn setup(
    mut commands: Commands
) {
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(0.5)
    ));
}

When you spawn an entity with a RigidBody there are a bunch of components that get added automatically:

  • Position
  • Rotation
  • LinearVelocity
  • AngularVelocity
  • ExternalForce
  • ExternalTorque
  • Friction
  • Restitution
  • Mass
  • Inertia
  • CenterOfMass

We can change these by spawning them or modifying the added ones on our entities.

Rigid bodies use their collider to set their mass automatically. If we need to override this behaviour we can spawn a MassPropertiesBundle:

commands.spawn((
    RigidBody::Dynamic,
    MassPropertiesBundle::new_computed(&Collider::circle(0.5), 1.0)
));

Dynamic

Dynamic bodies are controlled by applying forces and torques. They also automatically have forces, velocities and collisions affect them.

This means they work well as objects we want to act naturally physical in our scene, but not those we want to control directly.

If we want to control them we have to do so by modifying their components:

  • ExternalForce
  • ExternalTorque
  • LinearVelocity
  • AngularVelocity

For example, a ball being thrown across the room would be a good candidate. We want it to react to forces like gravity and changes in its velocity as it arcs through the air.

Static

Static bodies are completely opposite of dynamic ones. They are not affected by any forces, collisions or velocities and act as if they have an infinite mass.

So we would think of these as the ground or walls of our game. We want things to collide with them, but the walls themselves should not move or react when someone runs into them.

Kinematic

If you designed a character you move with WASD and made it a RigidBody::Dynamic you would find it quite difficult to get precise movement.

Kinematic bodies are for when:

  • You would like to use Position and LinearVelocity
  • You want to affect things physically around you, but not having the things around you affect you.

They are also like RigidBody::Static in that they have unlimited mass and will not be affected by the collisions from other dynamic bodies.

Movement and forces

We can move by applying an external force on our RigidBody::Dynamic bodies:

fn spawn_spinning_body(
    mut commands: Commands
) {
    commands.spawn((
        RigidBody::Dynamic,
        ExternalForce::new(Vec2::Y),
        ExternalTorque::new(0.2)
    ));
}

Other properties for coeffecients can be added:

fn spawn_with_coeffecients(
    mut commands: Commands
) {
    commands.spawn((
        RigidBody::Dynamic,
        Rotation::from_degrees(90.0),
        Friction::new(0.1),
        Restitution::new(0.4)
            .with_combine_rule(CoefficientCombine::Multiply),
        LinearDamping(0.1),
        AngularDamping(0.1)
    ));
}

CoeffecientCombine is available on all such combines and determines how we should combine the various factors together.

Collisions

For our entities to actually collide we need to give them a Collider.

If we don’t want them to collide but still register collision events we can add a Sensor marker component as well.

fn setup(mut commands: Commands) {
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(0.5),
        // Add this depending on if we want to collide or just emit events
        Sensor
    ));
}

Just like a RigidBody when you add a Collider it will automatically add some additional components:

  • ColliderAabb
  • CollidingEntities
  • ColliderMassProperties

ColliderEntities lets us query who is touching us each frame:

fn check_colliding_entities(
    query: Query<(Entity, &CollidingEntities)>
) {
    for (entity, colliding_entities) in &query {
        info!(
            "{:?} is colliding with the following entities: {:?}",
            entity,
            colliding_entities
        );
    }
}

Collision detection starts in the BroadPhase set of systems. During the broad phase a sweep and prune algorithm identifies pairs of potentially colliding entities and adds them into a BroadCollisionPairs using AABB intersection checks.

This broad phase is saving us from having to calculate precisely the collisions for units that are unlikely to be colliding saving some performance.

During the narrow phase a PenetrationConstraint are created for each contact pair. The constraints are resolved by moving the bodies so that they no longer penetrate.

Collision Events

As collisions happen there are 3 events we can respond to:

  • Collision
  • CollisionStarted
  • CollisionEnded

Which we can read and react to in our systems:

fn read_collisions(
    mut events: EventReader<Collision>
) {
    for event in events.read() {
        info!(
            "{:?} and {:?} are colliding",
            event.0.entity1,
            event.0.entity2
        );
    }
}

Collision Layers

Colliders have layers that they belong to and can interact with which is controlled via a CollisionLayers component on our entities.

Groups indicate what layers the collider is a part of. Masks indicate what layers the collider can interact with.

Two colliders A and B can interact if and only if:

  • The groups of A contain a layer that is also in the masks of B
  • The groups of B contain a layer that is also in the masks of A
#[derive(PhysicsLayer)]
enum Layer {
    Player,
    Enemy,
    Ground,
}

fn spawn_ball(mut commands: Commands) {
    commands.spawn((
        RigidBody::Dynamic,
        Collider::circle(0.5),
        // Player collides with enemies and the ground, but not with other players
        CollisionLayers::new(
            [Layer::Player],
            [Layer::Enemy, Layer::Ground]
        )
    ));
}

Queries

There are 4 types of spatial queries in bevy_xpbd

  1. Ray casts
  2. Shape casts
  3. Point projection
  4. Intersection tests

Ray and shape casting

For ray casting the easiest is to add a RayCaster component to our entities:

fn spawn_raycast(
    mut commands: Commands
) {
    commands.spawn((
        RigidBody::Dynamic,
        // Adds an additional RayHits component to register our hits
        // automatically. This ray will point from our position to the right
        RayCaster::new(Vec2::ZERO, Direction2d::X)
    ));
}

fn log_hits(
    query: Query<(&RayCaster, &RayHits)>
) {
    for (ray, hits) in &query {
        // For the faster iterator that isn't sorted, use `.iter()`
        for hit in hits.iter_sorted() {
            info!(
                "Hit entity {:?} at {} with normal {}",
                hit.entity,
                hit.time_of_impact,
                hit.normal,
            );
        }
    }
}

A Direction2d(Vec2) is just a normalized vector representing a direction. Raycasts represent a line stretching out to infinity which we can check for collisions, so a finite Vec2 wouldn’t be as clear.

If we want to set the maximum distance the ray should travel we do so with the max_time_of_impact parameter:

RayCaster::new(Vec2::ZERO, Direction2d::X)
    .with_max_time_of_impact(100.0)

Shapes work the exact same way with a ShapeCaster component.

fn spawn_shapecast(
    mut commands: Commands
) {
    // Spawn a shape caster with a ball shape at the center travelling right
    commands.spawn(ShapeCaster::new(
        Collider::circle(0.5), // Shape
        Vec2::ZERO,            // Origin
        0.0,                   // Shape rotation
        Direction2d::X         // Direction
    ));
}

fn log_shapecast_hits(
    query: Query<&ShapeHits>
) {
    for hits in &query {
        for hit in hits.iter() {
            info!(
                "Hit entity {:?}",
                hit.entity
            );
        }
    }
}

Spatial queries

Spatial queries are geometric queries about our game world. There are 4 different types:

  1. Ray casts
  2. Shape casts
  3. Point projection
  4. Intersection tests

SpatialQuery is a SystemParam with an API to spatially query what we want.

All spatial queries that return many items contain both a callback and non callback based version. So ray_hits and ray_hits_callback are both available, same for shape_hits and shape_hits_callback, etc.

Casting a ray

Ray casting finds any intersection with a Collider on an infinite line starting from some point. A ray is defined by an origin and direction. When we cast the ray we follow along the line (up to some limit) and find any collisions.

fn find_raycast_hit(
    spatial_query: SpatialQuery
) {
    // Cast ray and print first hit
    if let Some(first_hit) = spatial_query.cast_ray(
        Vec2::ZERO,                    // Origin
        Direction2d::X,                // Direction
        100.0,                         // Maximum time of impact (travel distance)
        true,                          // Does the ray treat colliders as "solid"
        SpatialQueryFilter::default(), // Query filter
    ) {
        info!("First hit: {:?}", first_hit);
    }
}

If we wanted every entity hit with our ray we would use ray_hits or ray_hits_callback:

fn find_all_raycast_hits(
    spatial_query: SpatialQuery
) {
    let mut hits = vec![];

    // Cast ray and get up to 20 hits
    // Could have also used `ray_hits` without the callback
    spatial_query.ray_hits_callback(
        Vec2::ZERO,                    // Origin
        Direction2d::X,                       // Direction
        100.0,                         // Maximum time of impact (travel distance)
        true,                          // Does the ray treat colliders as "solid"
        SpatialQueryFilter::default(), // Query filter
        |hit| {                        // Callback function
            hits.push(hit);
            true
        },
    );

    for hit in hits.iter() {
        println!("Hit: {:?}", hit);
    }
}

Projecting a point

This lets you check if a point (Vec2/Vec3) on your game world currently contains a collision with any of your Collider components.

fn find_projected_point_hits(
    spatial_query: SpatialQuery
) {
    if let Some(projection) = spatial_query.project_point(
        Vec2::ZERO,                    // Point
        true,                          // Are colliders treated as "solid"
        SpatialQueryFilter::default(), // Query filter
    ) {
        println!("Projection: {:?}", projection);
    }

    let mut intersections = vec![];

    spatial_query.point_intersections_callback(
        Vec2::ZERO,                     // Point
        SpatialQueryFilter::default(),  // Query filter
        |entity| {                      // Callback function
            intersections.push(entity);
            true
        },
    );

    for entity in intersections.iter() {
        println!("Entity: {:?}", entity);
    }
}

Intersection tests

These tests are used to tell you which entities are intersecting with a shape you provide:

fn find_intersections(
    spatial_query: SpatialQuery
) {
    let aabb = Collider::circle(0.5).aabb(Vec2::ZERO, 0.);
    let aabb_intersections = spatial_query.aabb_intersections_with_aabb(aabb);

    for entity in aabb_intersections.iter() {
        println!("Entity: {:?}", entity);
    }
}

There is also point_intersections and shape_intersections for working with points or shapes and their overlaps with any Collider.

Spatial query filters

To filter the entities we cast for we can use a SpatialQueryFilter

fn spawn_raycaster(
    mut commands: Commands
) {
    let object = commands.spawn(Collider::circle(0.5)).id();

    // A query filter that has three collision masks and excludes the `object` entity
    let query_filter = SpatialQueryFilter::from_mask(0b1011)
        .with_excluded_entities([object]);

    // Spawn a ray caster with the query filter
    commands.spawn(RayCaster::default().with_query_filter(query_filter));
}

Read more