Bevy Physics: XPBD
Bevy XPBD is currently undergoing a major change and has moved to a newer library Avian. I am leaving this article for posterity and a new guide for Avian will be released soon.
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:
- Broad phase
- Substeps
- Integrate
- Narrow phase
- Solve positional and angular constraints
- Update velocities
- Solve velocity constraints (dynamic friction and restitution)
- Report contacts (send collision events)
- Sleeping
- 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 time step 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
Sub-stepping 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
andLinearVelocity
- 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 ofB
- The groups of
B
contain a layer that is also in the masks ofA
#[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
- Ray casts
- Shape casts
- Point projection
- 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, Dir2::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 Dir2(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, Dir2::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
Dir2::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:
- Ray casts
- Shape casts
- Point projection
- 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
Dir2::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
Dir2::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));
}
Moving on from XPBD
Unfortunately XPBD is patented by NVIDIA, which prevents it being adopted by the official Bevy project.
There are other issues with the library that have caused Jondolf to work on rebuilding a new physics library which I will write about when it becomes available.