Physics and Characters

This page describes the Python game framework that ships in the acron wheel. It has no C++ counterpart; a C++ game uses Bullet directly.

The framework’s physics layer sits on Bullet. It does not hide Bullet: PhysicsWorld is a BulletWorld, and everything Bullet offers is still there. What it adds is a fixed-step convention, flat query results, collision-layer helpers, and a character mover that stays on the floor.

The pieces are:

  • acron.world – the physics world, ray and sweep queries, layers and owners.

  • acron.character_body – CharacterBody3D, a kinematic collide-and-slide mover.

  • acron.spring_arm – a camera boom that pulls in when a wall is behind the camera.

  • acron.camera_shake – trauma-based camera shake.

  • acron.interop – conversions between Y-up and Z-up conventions.

The most used names are also importable from acron itself (from acron import PhysicsWorld, CharacterBody3D, SpringArm, ...).

For corpses and falling characters, the engine also has a ragdoll solver.

The physics world

PhysicsWorld extends BulletWorld. Bodies are attached and removed with Bullet’s own attach and remove, and the debug node and contact tests work as usual.

from acron import PhysicsWorld

physics = PhysicsWorld()            # gravity (0, 0, -9.8), 60 Hz step
physics.do_physics()                # one step of physics.fixed_dt

The constructor takes gravity (default acron.interop.GRAVITY, 9.8 m/s² downward) and fixed_dt (default 1/60).

do_physics(dt=None) takes exactly one step, without Bullet substeps: do_physics(dt) is BulletWorld.do_physics(dt, 0, dt), and with no dt it steps fixed_dt. Game logic that runs on a fixed tick expects every step to be the same length; substeps break code that derives velocity from position. Passing max_substeps (and stepsize) explicitly gives Bullet’s own behaviour, so existing code keeps working. The cadence of the steps belongs to the game loop.

is_attached(node) tells whether a body (a NodePath or a Bullet node: rigid bodies, ghosts, characters, soft bodies) is in the world right now. Bullet warns when a body is removed twice; asking first avoids the warning.

Static boxes

static_box(world, parent, name, half_extents, pos, hpr=None, mask=LAYER_ALL) makes a mass-0 box body, parents it, sets its collide mask, attaches it to the world and returns its NodePath. A tilted box (with hpr) is a ramp.

from acron.world import static_box

floor = static_box(physics, render, "floor", (20, 20, 0.5), (0, 0, -0.5))
ramp = static_box(physics, render, "ramp", (2, 4, 0.2), (5, 0, 0.5),
                  hpr=(0, 20, 0))

Queries

ray_test(from_pos, to_pos, mask=LAYER_ALL, exclude=()) returns the closest hit as a RayHit, or None. exclude lists NodePaths, Bullet nodes, or objects with an np attribute to ignore.

sweep_closest(shape, from_ts, to_ts, mask=LAYER_ALL) sweeps a Bullet shape between two TransformStates and returns the first hit as a RayHit, or None when the path is clear.

A RayHit has:

  • bullet_node – the Bullet node that was hit;

  • position and normal – world space;

  • fraction – where along the ray or sweep the contact is, from 0 to 1;

  • collider – the game object that owns the node (see below), or the Bullet node when nothing owns it.

hit = physics.ray_test((0, 0, 10), (0, 0, -10))
if hit is not None:
    print(hit.collider, hit.position, hit.normal)

Collision layers

Collision masks are BitMask32 values. Layer tables in other engines and in level editors are often numbered from 1; layer_bit(n) is BitMask32.bit(n - 1), and layer_mask(*layers) ORs several together. LAYER_ALL is every bit. Name the layers in your game:

from acron import layer_bit, layer_mask

LAYER_LEVEL = layer_bit(1)
LAYER_PLAYER = layer_bit(2)
LAYER_ENEMIES = layer_bit(3)
SHOT_MASK = layer_mask(1, 3)

Owners

A Bullet query reports the body node it hit, not the game object behind it. tag_owner(np, owner) stores the owner on the node (as a Python tag named OWNER_TAG), and owner_of(node_or_np) reads it back; RayHit.collider uses it. A CharacterBody3D tags itself.

from acron import tag_owner

crate_np = static_box(physics, render, "crate", (0.5, 0.5, 0.5), (3, 3, 0.5))
tag_owner(crate_np, crate)          # crate: any Python object of the game

Character bodies

BulletCharacterControllerNode can lose contact with the floor when it is pushed against a wall, and sink. CharacterBody3D is a kinematic mover in the style of Godot’s move_and_slide: each step it sweeps a capsule along the desired motion, and on a hit it moves up to the contact and slides the rest of the motion along the surface, a few times over. Bullet never integrates the body, so nothing can push it into the floor.

from acron import CharacterBody3D

player = CharacterBody3D(physics, radius=0.5, height=1.0,
                         collide_mask=LAYER_LEVEL, body_mask=LAYER_PLAYER)
player.np.reparent_to(render)
player.np.set_pos(0, 0, 1.0)

The body is player.np, a NodePath with a kinematic, mass-0 rigid body carrying the capsule, so other queries – shots, line-of-sight rays – hit the character. Its position is synced into Bullet by the world step; the fixed loop runs the step after the movers, which keeps this right.

Constructor arguments:

radius, height

The capsule. height is the cylinder part only: a capsule 2.0 tall overall with a 0.5 radius is radius=0.5, height=1.0. The capsule is centred on the node; lift the spawn point by half the capsule’s height, and lower the visible model by the same amount.

body_mask

The layer the character’s own body is on: what others hit when they shoot at it. Default layer_bit(2).

collide_mask

What the movement sweep collides with (walls and floors). It must not include body_mask, or the capsule would hit itself; the default is everything except body_mask.

max_slides, floor_max_angle_deg, floor_snap_length

Default 6, 45 and 0.1, as in Godot.

snap_extra_reach

Extra reach of the floor snap beyond floor_snap_length (default 0.35), which keeps the body on slopes and down small steps. It does not climb steps.

seam_snap_distance

Off (0.0) by default. For levels whose floor is made of separate boxes with hairline seams between them: a body that was on the floor last step and is not jumping is put back onto a floor within this distance.

gravity

What get_gravity() returns (default GRAVITY). The mover does not apply it; your code does.

Moving

Set velocity and call move_and_slide(dt) once per fixed step. Afterwards is_on_floor(), is_on_wall() and is_on_ceiling() describe the contacts, and get_floor_normal() and get_wall_normal() the last surfaces touched. On floor contact the downward part of the velocity is removed. Zero the vertical velocity yourself when on the floor before adding gravity, or gravity keeps accumulating:

JUMP_SPEED = 5.0

class Player:
    def __init__(self, physics, inputs):
        self.body = CharacterBody3D(physics, collide_mask=LAYER_LEVEL,
                                    body_mask=LAYER_PLAYER)
        self.input = inputs

    def _physics_process(self, dt):
        body = self.body
        if body.is_on_floor() and body.velocity.z < 0.0:
            body.velocity.z = 0.0
        body.velocity += body.get_gravity() * dt
        if self.input.is_action_just_pressed("jump") and body.is_on_floor():
            body.velocity.z = JUMP_SPEED
        body.move_and_slide(dt)

Registered with a GameLoop, _physics_process runs at the fixed rate.

The floor snap only runs when the body was on the floor in the previous step, is not on it now, and is not moving up. This keeps the body on slopes and lets it jump and fall freely. After a teleport (a level change, a respawn), call reset_collision_state(), or the snap may pull the body down onto whatever lies under its new position.

Other methods: set_velocity(v), set_up_direction(up), set_transform(ts), set_collision_disabled(disabled=True) and is_collision_disabled() (take the body out of the world, for a dead character; calling it twice is harmless), and queue_free() (take it out of the world and the scene graph).

A wall does not change the stored velocity: the motion slides along the wall, but a body held against it keeps the speed it was given.

The slide core

The collide-and-slide algorithm lives in acron.slide (collide_and_slide and snap_to_floor), and knows nothing of Bullet: the caller passes a sweep function. CharacterBody3D and the 2D mover, CharacterBody2D, both run on it.

Spring arm

SpringArm is a third-person camera boom. It owns a pivot node and, on each process(camera, render), casts a ray from the pivot along its local +Y axis for spring_length; on a hit it shortens the boom to the hit distance minus margin, and puts the camera at the end of it, keeping the camera’s own orientation. Turn the pivot so that its +Y points behind the camera.

from acron import SpringArm

arm = SpringArm(player.np, physics, collide_mask=LAYER_LEVEL,
                length=2.4, margin=0.2)
arm.node.set_z(1.6)

def follow(task):
    arm.node.set_h(render, player_heading + 180)   # +Y behind the camera
    arm.process(base.camera, render)
    base.camera.look_at(arm.node)
    return task.cont

collide_mask should name solid walls only. If the boom also hits barriers that only stop the player (railings, props that shots pass through), it collapses onto the player whenever one is behind the camera. current_length is the boom’s length after the last process. The length jumps when a wall comes or goes; smooth spring_length yourself if that matters.

Camera shake

CameraShake shakes a camera by an amount of trauma that decays over time. The offset is trauma ** 2 * max_angle * noise for heading, pitch and roll, with noise sampled along an advancing time coordinate.

from acron import CameraShake

shake = CameraShake(base.camera, decay_rate=1.5, max_roll=0.1)

def on_hit():
    shake.add_trauma(0.75)

def camera_task(task):
    aim_camera()                    # the game's own chase or look-at logic
    shake.update(base.clock.get_dt())  # last, on top of this frame's aim
    return task.cont

The constructor takes speed, decay_rate, max_yaw, max_pitch, max_roll (radians), max_trauma, frequency and seed. The shake is an offset on whatever orientation the camera has when update runs, so aim the camera first and call update last. If something else re-aimed the camera since the last update, that pose becomes the new base. With no trauma, update writes nothing.

Y-up and Z-up

The engine is Z-up. Code and data ported from a Y-up engine carry vectors that must be converted; acron.interop holds the conversions in one place. Models loaded through the glTF loader are converted on load already – never rotate them again.

UP is (0, 0, 1) and GRAVITY is (0, 0, -9.8); gravity_vec(magnitude) makes a gravity vector of another strength.

There are two conventions:

The axis swap

g2p(v) and p2g(v) swap Y and Z: (x, y, z) becomes (x, z, y). godot_to_acron_vec(x, y, z) and acron_to_godot_vec(v) are the same with separate arguments and a tuple result. The swap is a reflection, not a rotation: use it for vectors (gravity, up, velocities, offsets), never for an orientation.

A Y-up frame

attach_logic_frame(parent, name="logic_frame") attaches a child node turned by hpr(0, 90, 0), inside which +Y is up, so ported math can run unchanged. g2p_rot(v) maps (x, y, z) to (x, -z, y), the rotation that frame performs, and p2g_rot(v) is its inverse. World data shared with such a frame (collision geometry, markers) must use these, not the swap, or the world is mirrored relative to the frame.

To transform vectors through a node’s frame, use the engine’s NodePath.get_relative_vector and get_relative_point.

apply_vertical_fov(lens, vfov_degrees, eps=0.01) sets a lens from a vertical field of view. Lens.set_fov with one number sets the horizontal angle; most other engines state the vertical one. The function converts through the lens’s aspect ratio and returns whether it changed the lens, so it is cheap to call every frame and restores the right angle after a window resize.

from acron.interop import apply_vertical_fov

apply_vertical_fov(base.camLens, 70.0)