Panda3D - Bullet

Introduction

Panda3D offers two main ways to handle physics:

  1. Its Built-in Physics Engine (very basic, uses ActorNode) and
  2. The Bullet Physics Engine (more powerful, industry-standard).

For a beginner, Bullet, it’s much more robust for games and generally easier to scale as your project grows.

We will walk you through setting up a “Hello World” of physics: a falling box hitting a floor.

Hello World

hello.py

To use Bullet physics, we need to have a BulletWorld.

The world is Panda3D’s term for a “space” or “scene”. The world holds physical objects like rigid bodies, soft bodies, or character controllers. It controls global parameters, such as gravity, and it advances the simulation state.

from direct.showbase.ShowBase import ShowBase
from panda3d.bullet import BulletWorld
from panda3d.core import Vec3
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.world = BulletWorld()
self.world.setGravity(Vec3(0, 0, -9.81))
app = MyApp()
app.run()

The above code creates a new world, and it sets the worlds gravity to a downward vector with length 9.81. While Bullet is in theory independent of any particular units, it is recommended to stick with SI units (kilogram, meter, second). In SI units 9.81 m/s² is the gravity on Earth’s surface.

Next, we need to advance the simulation state. This is best done by a task which gets called each frame. We find out about the elapsed time (dt), and pass this value to the do_physics() method.

class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.world = BulletWorld()
self.world.setGravity(Vec3(0, 0, -9.81))
self.taskMgr.add(self.update, 'update')
def update(self, task):
dt = globalClock.getDt()
self.world.doPhysics(dt)
return task.cont

The doPhysics method allows finer control on the way the simulation state is advanced. Internally, Bullet splits a timestep into several substeps. We can pass a maximum number of substeps and the size of each substep, like shown in the following code.

world.doPhysics(dt, 10, 1.0/180.0)

Here we have a maximum of 10 substeps, each with 1/180 seconds. Choosing smaller substeps will make the simulation more realistic, but performance will decrease too. Smaller substeps also reduce jitter.

Static bodies

So far we just have an empty world. We next need to add some objects. The most simple objects are static bodies. Static object doesn’t change their position or orientation with time. Typical static objects are the ground or terrain, and houses or other non-moveable obstacles. Here we create a simple plane which will serve as a ground.

class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# ...
# Floor
floor = BulletRigidBodyNode('Ground')
floor.addShape(BulletPlaneShape(Vec3(0, 0, 1), 1))
floor_np = self.render.attachNewNode(floor)
floor_np.setPos(0, 0, -2)
self.world.attachRigidBody(floor)

First we create a collision shape, in the case a BulletPlaneShape. We pass the plane’s constant and normal vector within the shape’s constructor. There is a separate page about setting up the various collision shapes offered by Bullet, so we won’t go into more detail here.

Next we create a rigid body and add the previously created shape. BulletRigidBodyNode is derived from PandaNode, and thus the rigid body can be placed within the Panda3D scene graph. You can also use methods like setPos or setH to place the rigid body node where you want it to be.

Finally, we need to attach the newly created rigid body node to the world. Only rigid bodies attached to the world will be considered when advancing the simulation state.

Dynamic bodies

Dynamic bodies are similar to static bodies. Except that dynamic bodies can be moved around the world by applying force or torque. To setup a dynamic body is almost the same as for static bodies. We will have to set one additional property, though, the body’s mass. Setting a positive finite mass will create a dynamic body, while setting the mass to zero will create a static body. Zero mass is a convention for setting an infinite mass, which is the same as making the body unmovable (static).

Bullet will automatically update a rigid body node’s position and orientation if it has changed after advancing the simulation state. So, if you have a GeomNode - e.g. a textured box - and reparent this geom node below the rigid body node, then the geom node will move around together with the rigid body. You don’t have to synchronize the visual world with the physics world.

The Physics “World”

In Bullet, physics doesn’t happen in the regular scene graph (render).

It happens in a BulletWorld. You must create this world and “step” it (update it) every frame.

Key Concepts

  • BulletWorld: The container for all physical objects.
  • RigidBody: An object that moves according to physics (gravity, collisions).
  • Collision Shape: The “invisible” box or sphere used to calculate hits.

Save this as main.py. You don’t need any external assets; we’ll use Panda3D’s built-in models.

from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletPlaneShape, BulletRigidBodyNode, BulletBoxShape
class PhysicsDemo(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 1. Setup the World
self.world = BulletWorld()
self.world.setGravity(Vec3(0, 0, -9.81)) # Standard Earth gravity
# 2. Add a Floor (Static Object)
# A plane facing Up (0,0,1) at a height of 0
floor_shape = BulletPlaneShape(Vec3(0, 0, 1), 0)
floor_node = BulletRigidBodyNode('Ground')
floor_node.addShape(floor_shape)
# Static objects have a mass of 0
floor_np = self.render.attachNewNode(floor_node)
floor_np.setPos(0, 0, -2)
self.world.attachRigidBody(floor_node)
# 3. Add a Falling Box (Dynamic Object)
# Box size is half-extents (0.5 means 1x1x1 unit)
box_shape = BulletBoxShape(Vec3(0.5, 0.5, 0.5))
box_node = BulletRigidBodyNode('Box')
box_node.setMass(1.0) # Giving it mass makes it fall!
box_node.addShape(box_shape)
self.box_np = self.render.attachNewNode(box_node)
self.box_np.setPos(0, 10, 5) # Lift it up
self.world.attachRigidBody(box_node)
# 4. Visual Representation
# Physics nodes are invisible. We attach a model to see them.
visual_model = self.loader.loadModel("models/box")
visual_model.reparentTo(self.box_np)
# 5. Update Task
# We must tell the world to simulate every frame
self.taskMgr.add(self.update, 'update')
def update(self, task):
dt = globalClock.getDt()
self.world.doPhysics(dt)
return task.cont
app = PhysicsDemo()
app.run()

Movement

We’ll use Panda3D’s Event Handler to listen for key presses and apply Central Forces or Impulses to the physics node.

When using a physics engine, you shouldn’t use setPos to move a character. If you “teleport” an object inside another object, the physics engine will glitch.

Instead, we use:

  • applyCentralForce: Best for continuous movement (like an engine pushing a car).
  • applyCentralImpulse: Best for instant bursts of speed (like jumping or being hit by a bullet).

We will add a “Jump” feature (Space) and a “Push” feature (ArrowKeys).

from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletPlaneShape, BulletRigidBodyNode, BulletBoxShape
class PhysicsDemo(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# --- Standard Setup ---
self.world = BulletWorld()
self.world.setGravity(Vec3(0, 0, -9.81))
# Floor
floor_node = BulletRigidBodyNode('Ground')
floor_node.addShape(BulletPlaneShape(Vec3(0, 0, 1), 0))
floor_np = self.render.attachNewNode(floor_node)
floor_np.setPos(0, 0, -2)
self.world.attachRigidBody(floor_node)
# The Player Box
self.box_node = BulletRigidBodyNode('Box')
self.box_node.setMass(1.0)
self.box_node.addShape(BulletBoxShape(Vec3(0.5, 0.5, 0.5)))
self.box_np = self.render.attachNewNode(self.box_node)
self.box_np.setPos(0, 10, 0)
self.world.attachRigidBody(self.box_node)
# Visuals
visual_model = self.loader.loadModel("models/box")
visual_model.reparentTo(self.box_np)
# --- Input Handling ---
# Accept "space" to jump
self.accept("space", self.do_jump)
# Track arrow keys for constant movement
self.keys = {"left": False, "right": False}
self.accept("arrow_left", self.set_key, ["left", True])
self.accept("arrow_left-up", self.set_key, ["left", False])
self.accept("arrow_right", self.set_key, ["right", True])
self.accept("arrow_right-up", self.set_key, ["right", False])
self.taskMgr.add(self.update, 'update')
def set_key(self, key, value):
self.keys[key] = value
def do_jump(self):
# Apply an upward impulse (Instant kick)
# We use a vector (X, Y, Z)
self.box_node.applyCentralImpulse(Vec3(0, 0, 5))
def update(self, task):
dt = globalClock.getDt()
# Apply continuous force if keys are held
if self.keys["left"]:
self.box_node.applyCentralForce(Vec3(-10, 0, 0))
if self.keys["right"]:
self.box_node.applyCentralForce(Vec3(10, 0, 0))
self.world.doPhysics(dt)
return task.cont
app = PhysicsDemo()
app.run()

Key Takeaways

  • Event Listeners: self.accept("key-name", function) tells Panda3D to run a function when a key is pressed.

  • Damping: You might notice the box slides like it’s on ice. To fix this, you can set “Linear Damping” on the node:

    • self.box_node.setLinearDamping(0.5) — This acts like air resistance or friction to slow the object down naturally.
  • Active State: Bullet sometimes “puts objects to sleep” if they haven’t moved in a while to save CPU. If your jump doesn’t work after the box sits still, use self.box_node.setActive(True).

Collison detection

By default, Bullet handles the physics (the “bounce”), but it doesn’t tell your Python code about it unless you ask. We do this using Contact Manifolds.

  • set_notify(True): You must call this on a physics node to tell Bullet, “Hey, tell me when this object hits something.”
  • contact_test: A method to check if two things are touching right now.
  • contact_added: An event triggered the exact moment two shapes touch.

Collision Events

We will modify the floor and the box so that a message prints to the console every time the box lands.

from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletPlaneShape, BulletRigidBodyNode, BulletBoxShape
class PhysicsDemo(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.world = BulletWorld()
self.world.setGravity(Vec3(0, 0, -9.81))
# 1. Setup the Floor
floor_node = BulletRigidBodyNode('Ground')
floor_node.addShape(BulletPlaneShape(Vec3(0, 0, 1), 0))
floor_np = self.render.attachNewNode(floor_node)
floor_np.setPos(0, 0, -2)
self.world.attachRigidBody(floor_node)
# 2. Setup the Box
self.box_node = BulletRigidBodyNode('Box')
self.box_node.setMass(1.0)
self.box_node.addShape(BulletBoxShape(Vec3(0.5, 0.5, 0.5)))
# CRITICAL: Enable collision notifications for this node
self.box_node.notify_collisions(True)
self.box_np = self.render.attachNewNode(self.box_node)
self.box_np.setPos(0, 10, 5)
self.world.attachRigidBody(self.box_node)
# Visuals
visual_model = self.loader.loadModel("models/box")
visual_model.reparentTo(self.box_np)
# 3. Listen for the Collision Event
# The event name is always 'bullet-contact-added'
self.accept('bullet-contact-added', self.on_contact)
self.taskMgr.add(self.update, 'update')
def on_contact(self, node1, node2):
# This function runs whenever two 'notifying' nodes touch
print(f"Collision detected between: {node1.getName()} and {node2.getName()}!")
def update(self, task):
dt = globalClock.getDt()
self.world.doPhysics(dt)
return task.cont
app = PhysicsDemo()
app.run()

Advanced Collision Filtering

If you have 100 objects, you probably don’t want a notification for every tiny bump. You can use Collision Masks to tell Bullet which groups of objects should care about each other.

For example:

  • Group 1: Players
  • Group 2: Power-ups
  • Logic: Players can hit Power-ups, but Power-ups shouldn’t “hit” each other.
# Set a bitmask (Binary)
self.box_node.setIntoCollideMask(0x1)
self.box_node.setFromCollideMask(0x1)

Debugging

Physics is “invisible,” which makes it hard to debug if a collision shape is the wrong size. You can turn on the Debug Renderer to see the physics shapes as wireframes:

from panda3d.bullet import BulletDebugNode
debugNode = BulletDebugNode('Debug')
debugNP = self.render.attachNewNode(debugNode)
debugNP.show()
self.world.setDebugNode(debugNode)

Characte Controller

Moving from a simple falling box to a Character Controller is a big step. In physics terms, a standard “RigidBody” (like our box) will tumble, roll, and tip over when it hits a curb. A character, however, needs to stay upright, climb stairs, and stop instantly when you let go of the keys.

Panda3D provides the BulletCharacterControllerNode specifically for this “FPS-style” or “Third-Person” movement.

Unlike the box, the Character Controller is kinematic. This means it isn’t moved by forces (like gravity or pushing), but rather by your direct velocity commands. However, it still interacts with the world (it won’t walk through walls).

We use a Capsule shape because it’s the industry standard for humans (it slides off corners easily).

Key Concepts

  • setLinearMovement: Unlike applyForce, this sets the exact speed. If you set it to 5, the character moves at 5 units per second immediately.

  • isOnGround(): This is a built-in helper that checks if the bottom of the capsule is touching something. No more infinite jumping in mid-air!

  • Bitmasks: Character controllers are very sensitive to collision masks. BitMask32.allOn() ensures it sees the floor, otherwise it will just fall through the world forever.

Camera Follow

To make this feel like a real game, you can parent the camera to the player:

self.camera.reparentTo(self.player_np)
self.camera.setPos(0, -15, 5) # Look from behind and slightly above

Trigger Zones

In game development, Trigger Zones (often called “Ghost Objects” in Bullet) are invisible areas that don’t block movement but “know” when something enters them. These are perfect for opening doors, starting cutscenes, or dealing damage in a lava pit.

A BulletGhostNode is a special type of node that:

  • Does not physically collide (it’s a “phantom”).
  • Does keep track of all other physics objects overlapping its shape.

We’ll add a “Winning Zone” (a red transparent box). If the player walks into it, the console will print a message.

Ghost vs. Contact

You might wonder: “Why not just use the collision contact event from the last lesson?”

  • Contact Events are best for “one-off” hits (like a ball hitting a wall).
  • Ghost Objects are best for “Area of Effect” checks (knowing if a player is still standing inside a zone).

Constraints

In physics engines, Constraints (often called Joints) are the glue that holds objects together. They restrict how one object moves relative to another—like a door on a hinge, a wheel on an axle, or a chandelier on a chain.

In Panda3D Bullet, the most common constraint is the HingeConstraint.