Tutorial 96: Physics Integration (Box2D / Bullet)

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • Why CNA ships no physics engine, and what that means for your architecture.
  • Integrating Box2D v3 for 2D and Bullet for 3D.
  • Stepping the simulation from Update() and syncing bodies back to CNA transforms.
  • Drawing physics shapes for debugging.

Before you start — Tutorial 48: Fixed vs Variable Timestep (physics wants a fixed step) and Tutorial 16: Basic Collision Detection (the built-in collision this replaces).

CNA does not include physics

CNA provides math types (Vector2, Vector3, Matrix) and rendering, but no physics engine. Choose a physics library appropriate for your game: Box2D v3 for 2D games, Bullet Physics for 3D games. Both integrate cleanly through CNA's Update() method.

ⓘ

Third-party APIs move. The Box2D and Bullet calls on this page are third-party code that CNA neither ships nor tests. Box2D v3 in particular is a young C API whose function names and structures have changed between releases (later releases, for example, read a body’s angle through its rotation rather than b2Body_GetAngle), so check the calls below against the headers of the revision you clone. Only the CNA side — the game loop, the transform sync and the drawing — is verified against CNA itself.

Box2D v3 integration (2D)

# Clone Box2D v3 (new C API)
git clone https://github.com/erincatto/box2d.git ../box2d
cmake -S ../box2d -B ../box2d/build
cmake --build ../box2d/build

# In your game's CMakeLists.txt:
add_subdirectory(../box2d ${CMAKE_BINARY_DIR}/box2d)
target_link_libraries(MyGame PRIVATE box2d)

Bullet Physics integration (3D)

git clone https://github.com/bulletphysics/bullet3.git ../bullet3
cmake -S ../bullet3 -B ../bullet3/build \
  -DBUILD_DEMOS=OFF -DBUILD_EXTRAS=OFF -DUSE_GRAPHICAL_BENCHMARK=OFF
cmake --build ../bullet3/build
find_package(Bullet REQUIRED)
target_link_libraries(MyGame PRIVATE ${BULLET_LIBRARIES})
target_include_directories(MyGame PRIVATE ${BULLET_INCLUDE_DIRS})

Physics step in Update()

Always step the physics world in Update(), not in Draw(). Use a fixed timestep for deterministic simulation. CNA’s default game loop already is one: with IsFixedTimeStep left at true, every Update receives exactly TargetElapsedTime (1/60 s), except the game’s very first Update, whose ElapsedGameTime is 0. So the simplest correct integration is to step the physics world once per Update with a constant step (see Tutorial 48). The accumulator below is for the other cases: a variable-step game (IsFixedTimeStep = false), or a physics rate that differs from the update rate.

// Box2D v3: world step
void Update(GameTime& gt) override {
    Game::Update(gt);
    float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());

    // Accumulate time and step in fixed increments (dt is 0 on the very first Update)
    accumulator_ += std::min(dt, 0.25f);   // clamp so one long stall cannot cause a burst of steps
    const float FIXED_DT = 1.0f / 60.0f;
    while (accumulator_ >= FIXED_DT) {
        b2World_Step(worldId_, FIXED_DT, 4);   // 4 substeps
        accumulator_ -= FIXED_DT;
    }
}

Syncing physics bodies to CNA transforms

Box2D v3 integration: world step + body sync to Vector2 position:

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "box2d/box2d.h"
#include <algorithm>
#include <memory>
#include <vector>
#include <cmath>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;

struct PhysicsBody {
    b2BodyId  bodyId;
    Vector2   renderPos;   // updated from physics each frame
    float     renderAngle;
    int       width, height;

    void SyncFromPhysics() {
        b2Vec2 pos = b2Body_GetPosition(bodyId);
        renderPos.X  = pos.x;
        renderPos.Y  = pos.y;
        renderAngle  = b2Body_GetAngle(bodyId);
    }
};

class PhysicsDemo final : public Game {
public:
    PhysicsDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

    // The physics world is not a GPU resource, so free it in the destructor. (UnloadContent() runs
    // only when the game is disposed with Dispose(), not when a stack-allocated game simply goes out of scope.)
    ~PhysicsDemo() {
        if (worldCreated_) b2DestroyWorld(worldId_);
    }

protected:
    void Initialize() override {
        // Create Box2D world (gravity pointing down in screen space)
        b2WorldDef worldDef = b2DefaultWorldDef();
        worldDef.gravity = { 0.0f, 200.0f };   // pixels/sec² downward
        worldId_ = b2CreateWorld(&worldDef);
        worldCreated_ = true;

        // Ground body (static)
        b2BodyDef groundDef = b2DefaultBodyDef();
        groundDef.position = { 400.0f, 580.0f };
        b2BodyId groundId = b2CreateBody(worldId_, &groundDef);
        b2Polygon groundBox = b2MakeBox(400.0f, 10.0f);
        b2ShapeDef shapeDef = b2DefaultShapeDef();
        b2CreatePolygonShape(groundId, &shapeDef, &groundBox);

        // Falling box (dynamic)
        b2BodyDef boxDef = b2DefaultBodyDef();
        boxDef.type     = b2_dynamicBody;
        boxDef.position = { 400.0f, 100.0f };
        b2BodyId boxId  = b2CreateBody(worldId_, &boxDef);
        b2Polygon box   = b2MakeBox(20.0f, 20.0f);
        b2ShapeDef bsd  = b2DefaultShapeDef();
        bsd.density  = 1.0f;
        bsd.friction = 0.5f;
        b2CreatePolygonShape(boxId, &bsd, &box);
        bodies_.push_back({ boxId, {400,100}, 0.0f, 40, 40 });

        Game::Initialize();
    }

    void LoadContent() override {
        sb_    = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        pixel_ = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
        Color w = Color::White;
        pixel_->SetData(&w, 1);
    }

    void Update(GameTime& gt) override {
        Game::Update(gt);
        float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
        accumulator_ += std::min(dt, 0.25f);
        const float FDT = 1.0f / 60.0f;
        while (accumulator_ >= FDT) {
            b2World_Step(worldId_, FDT, 4);
            accumulator_ -= FDT;
        }
        for (auto& b : bodies_) b.SyncFromPhysics();
    }

    void Draw(const GameTime& gt) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(30, 30, 60));
        sb_->Begin();
        // Ground
        sb_->Draw(*pixel_, Rectangle(0, 570, 800, 30), Color(100,80,50));
        // Physics bodies
        for (auto& b : bodies_) {
            sb_->Draw(*pixel_,
                      Rectangle((int)(b.renderPos.X - b.width/2),
                                 (int)(b.renderPos.Y - b.height/2),
                                 b.width, b.height),
                      Color::Orange);
        }
        sb_->End();
        Game::Draw(gt);   // Game presents the frame afterwards (no gd.Present())
    }

private:
    GraphicsDeviceManager        graphics_;
    std::unique_ptr<SpriteBatch> sb_;
    std::unique_ptr<Texture2D>   pixel_;
    b2WorldId                    worldId_{};
    bool                         worldCreated_ = false;
    std::vector<PhysicsBody>     bodies_;
    float                        accumulator_ = 0.0f;
};

int main() { PhysicsDemo game; game.Run(); }

Debug draw of physics shapes

Box2D v3 provides a debug draw callback interface (b2DebugDraw). Wire it up to collect line segments and draw them as PrimitiveType::LineList through CNA's GraphicsDevice to visualize physics colliders during development. Disable this in release builds with a preprocessor define. The CNA half looks like this:

#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/PrimitiveType.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "CNA/GraphicsCapability.hpp"

// Two vertices per segment, in pixel coordinates; your b2DebugDraw callbacks call this.
void AddDebugLine(std::vector<VertexPositionColor>& v, Vector2 a, Vector2 b, Color c) {
    v.push_back(VertexPositionColor(Vector3(a.X, a.Y, 0), c));
    v.push_back(VertexPositionColor(Vector3(b.X, b.Y, 0), c));
}

void DrawDebugLines(GraphicsDevice& gd, BasicEffect& fx,
                    const std::vector<VertexPositionColor>& lineVerts) {
    // User primitives need a 3D-capable renderer (see the note below)
    if (lineVerts.empty() || !gd.SupportsCapability(CNA::GraphicsCapability::ThreeD)) return;

    const int w = gd.getViewportProperty().getWidthProperty();
    const int h = gd.getViewportProperty().getHeightProperty();
    fx.VertexColorEnabled = true;
    fx.setWorldProperty(Matrix::getIdentityProperty());
    fx.setViewProperty(Matrix::getIdentityProperty());
    fx.setProjectionProperty(Matrix::CreateOrthographicOffCenter(   // pixel space, Y down
        0.0f, (float)w, (float)h, 0.0f, 0.0f, 1.0f));

    for (auto& pass : fx.getCurrentTechniqueProperty()->getPassesProperty()) {
        pass.Apply();
        gd.DrawUserPrimitives(PrimitiveType::LineList, lineVerts, 0,
                              static_cast<int>(lineVerts.size() / 2));
    }
}

The 2D-only renderer (SDL_RENDERER) cannot draw user primitives; on those, draw each segment as a thin rotated rectangle with SpriteBatch instead. The helper above therefore checks GraphicsCapability::ThreeD first, and the effect needs a BasicEffect created in LoadContent().