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;positionandnormal– 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,heightThe capsule.
heightis the cylinder part only: a capsule 2.0 tall overall with a 0.5 radius isradius=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_maskThe layer the character’s own body is on: what others hit when they shoot at it. Default
layer_bit(2).collide_maskWhat the movement sweep collides with (walls and floors). It must not include
body_mask, or the capsule would hit itself; the default is everything exceptbody_mask.max_slides,floor_max_angle_deg,floor_snap_lengthDefault 6, 45 and 0.1, as in Godot.
snap_extra_reachExtra 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_distanceOff (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.
gravityWhat
get_gravity()returns (defaultGRAVITY). 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)andp2g(v)swap Y and Z:(x, y, z)becomes(x, z, y).godot_to_acron_vec(x, y, z)andacron_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 byhpr(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, andp2g_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)