Physics Engine Integration in C++
Integrating PhysX, Bullet, Jolt, and Box2D into C++ game engines: rigid body dynamics, collision detection, constraint solvers, and determinism.
Physics engine landscape
| Engine | Language | License | Best for |
|---|---|---|---|
| Jolt Physics | C++17 | MIT | AAA games, excellent SIMD, modern API |
| PhysX 5 | C++17 | BSD | Unreal Engine default, GPU acceleration |
| Bullet3 | C++ | zlib | Open-source, widely embedded |
| Box2D v3 | C | MIT | 2D games, simple integration |
| Havok | C++ | Commercial | Console AAA (historical standard) |
Jolt Physics — the modern choice
Jolt is the fastest open-source 3D physics engine as of 2024. Designed from scratch for multi-core CPUs with SIMD throughout.
#include <Jolt/Jolt.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
// Initialize Jolt (once at startup)
JPH::RegisterDefaultAllocator();
JPH::Factory::sInstance = new JPH::Factory();
JPH::RegisterTypes();
JPH::TempAllocatorImpl temp_allocator(10 * 1024 * 1024); // 10 MB
JPH::JobSystemThreadPool job_system(JPH::cMaxPhysicsJobs,
JPH::cMaxPhysicsBarriers,
std::thread::hardware_concurrency() - 1);
// Create physics system
JPH::PhysicsSystem physics;
physics.Init(
1024, // max bodies
0, // num body mutexes (0 = auto)
1024, // max body pairs
1024, // max contact constraints
broad_phase_layer_interface,
object_vs_broad_phase_layer_filter,
object_vs_object_layer_filter
);
// Create a rigid body
JPH::BodyCreationSettings settings(
new JPH::BoxShape(JPH::Vec3(1.0f, 1.0f, 1.0f)),
JPH::RVec3(0.0f, 10.0f, 0.0f), // position
JPH::Quat::sIdentity(), // rotation
JPH::EMotionType::Dynamic,
object_layer_moving
);
JPH::Body* box = physics.GetBodyInterface().CreateBody(settings);
physics.GetBodyInterface().AddBody(box->GetID(), JPH::EActivation::Activate);Physics step loop
Physics runs at a fixed timestep (typically 60 Hz or 120 Hz), decoupled from the render frame rate:
class PhysicsWorld {
JPH::PhysicsSystem m_system;
float m_accumulator = 0.0f;
static constexpr float FIXED_DT = 1.0f / 60.0f;
public:
void Update(float delta_time) {
m_accumulator += delta_time;
while (m_accumulator >= FIXED_DT) {
// Fixed timestep update
m_system.Update(FIXED_DT, 1, &m_temp_alloc, &m_job_system);
m_accumulator -= FIXED_DT;
}
// Interpolation factor for rendering (between 0 and 1)
float alpha = m_accumulator / FIXED_DT;
// Use alpha to interpolate rendered positions between
// prev_transform and current_transform
}
};Why fixed timestep? Variable dt makes physics non-deterministic. Two runs of the same simulation with different frame times produce different results. Fixed timestep ensures determinism and stability.
Broad phase vs narrow phase
Collision detection is split into two stages:
All bodies Candidate pairs Actual contacts
│ │ │
▼ ▼ ▼
[Broad phase] ──────────▶ [Narrow phase] ──────────▶ [Contact solver]
(AABB tree / (GJK/EPA or (constraint
spatial hash) shape-specific) resolution)Broad phase rejects pairs that can't possibly collide (AABB overlap test). This is O(N log N) instead of O(N²).
Narrow phase computes exact contact points, normals, and penetration depths for candidate pairs.
// Custom collision filter — only collide specific layers
class MyContactListener : public JPH::ContactListener {
public:
JPH::ValidateResult OnContactValidate(
const JPH::Body& body1, const JPH::Body& body2,
JPH::RVec3Arg base_offset,
const JPH::CollideShapeResult& result) override
{
// Return ValidateResult::AcceptContact or RejectContact
if (body1.GetUserData() == body2.GetUserData())
return JPH::ValidateResult::RejectContact; // same faction, no collide
return JPH::ValidateResult::AcceptContact;
}
void OnContactAdded(const JPH::Body& body1, const JPH::Body& body2,
const JPH::ContactManifold& manifold,
JPH::ContactSettings& settings) override
{
// Play collision sound, spawn particles, etc.
}
};Character controller
Character controllers are kinematic bodies (no physics simulation), moved by code but colliding with the world:
#include <Jolt/Physics/Character/CharacterVirtual.h>
JPH::CharacterVirtualSettings char_settings;
char_settings.mShape = new JPH::CapsuleShape(0.9f, 0.3f); // height, radius
char_settings.mMaxSlopeAngle = JPH::DegreesToRadians(45.0f);
char_settings.mMaxStrength = 100.0f;
char_settings.mMass = 70.0f;
auto character = std::make_unique<JPH::CharacterVirtual>(
&char_settings, JPH::RVec3(0, 2, 0), JPH::Quat::sIdentity(), &physics
);
void UpdateCharacter(float dt, JPH::Vec3 move_input, bool jump) {
JPH::Vec3 velocity = character->GetLinearVelocity();
// Apply gravity
velocity.SetY(velocity.GetY() - 9.81f * dt);
// Apply movement
if (character->IsSupported()) {
velocity.SetX(move_input.GetX() * 5.0f);
velocity.SetZ(move_input.GetZ() * 5.0f);
if (jump) velocity.SetY(5.0f);
}
character->SetLinearVelocity(velocity);
character->Update(dt, gravity, broad_phase_layer_filter,
object_layer_filter, body_filter, {}, temp_alloc);
}Determinism and multiplayer
For deterministic multiplayer physics:
- Use fixed-point math or be very careful about float ordering
- Run same timestep on all machines (fixed 60 Hz)
- Seed random number generators identically
- Avoid non-deterministic operations:
std::unordered_mapiteration order, thread scheduling
// Deterministic step — same inputs MUST produce same output
struct PhysicsInput {
uint64_t frame;
std::array<PlayerInput, MAX_PLAYERS> inputs;
};
// Lock-step: all clients simulate the same frame simultaneously
void SimulateFrame(const PhysicsInput& input) {
// Apply inputs in deterministic order
for (int i = 0; i < MAX_PLAYERS; ++i) {
ApplyPlayerInput(i, input.inputs[i]);
}
physics.Update(FIXED_DT, 1, &temp_alloc, &job_system);
}Performance tips
- Sleep inactive bodies — most physics engines do this automatically; ensure your active body count stays reasonable
- Compound shapes — one compound shape per entity is faster than many separate bodies
- Simplified collision meshes — use convex hulls or simple primitives for most objects; only use triangle meshes for terrain
- Layer filtering — assign layers and filter early in broad phase (e.g., projectiles don't collide with other projectiles)
- Query caching — cache raycast results if you cast the same ray multiple times per frame
// Use layer filtering to avoid O(N²) collision pairs
// Layer 0: static world geometry
// Layer 1: dynamic objects
// Layer 2: sensors/triggers (no collision response)
// Layer 3: projectiles (collide with 0,1 but not 3)
constexpr JPH::ObjectLayer LAYER_STATIC = 0;
constexpr JPH::ObjectLayer LAYER_DYNAMIC = 1;
constexpr JPH::ObjectLayer LAYER_SENSOR = 2;
constexpr JPH::ObjectLayer LAYER_PROJECTILE = 3;