2D physics (Box2D v3), 3D physics (Jolt), a walking character controller,
ragdolls and cloth. Everything is in gander:: (module physics.goose, part of the
package). Bodies, joints, characters, ragdolls and cloth are handles: Body2,
Joint2, Body, Joint, Character, Ragdoll, Cloth (.id == 0 is none).
Rules that bite
- Physics only moves when you step it. Call
gander::physics2d_step(dt)(2D) and/organder::physics_step(dt)(3D) once per frame, before reading positions and before drawing. Both run fixed 1/60 s ticks inside, so the simulation is frame-rate independent and deterministic ingander test. - Physics does not draw. Each frame, read the body's position and draw your
sprite / mesh / model there (
gander::body_xform(b)is a readyMat4for 3D; it has no scale, so multiply your mesh scale on the right:gander::mul(gander::body_xform(b), gander::scale(size))). - Shapes are created centered.
body_boxtakes the full width/height in 2D but half extents in 3D. - Kinds:
gander::STATIC(never moves),gander::DYNAMIC(simulated),gander::KINEMATIC(moved by you, pushes dynamic bodies). - Contact / sensor events cover only the latest step call: read them right after stepping, every frame.
gander::physics_debug_draw()(3D) draws every body's bounds: green awake, grey asleep. Use it when something falls through the floor.
2D (pixels, +Y down, radians clockwise)
Scale: 32 px = 1 m (physics2d_scale(px)), gravity 9.8 m/s^2 down
(physics2d_gravity(x, y) in px/s^2). Defaults for new shapes:
physics2d_defaults(density, friction, bounce) (1, 0.6, 0).
| Create | |
|---|---|
body2d_box(x, y, w, h, kind) -> Body2 |
center + full size |
body2d_circle(x, y, r, kind) |
|
body2d_capsule(x, y, h, r, kind) |
standing capsule, total height h (characters) |
body2d_polygon(x, y, points: f32[], kind) |
convex, 3..8 points (x0, y0, x1, y1...) relative to (x, y) |
body2d_chain(points: f32[], looped) |
static ground polyline in world pixels (hills, caves), at least 4 points |
body2d_sensor(x, y, w, h) |
static trigger area |
body2d_add_box(b, ox, oy, w, h, angle), body2d_add_circle(b, ox, oy, r) |
compound shapes |
body2d_destroy(b), body2d_valid(b) |
State: body2d_pos(b) -> float2, body2d_angle, body2d_vel -> float2
(px/s), body2d_spin (rad/s), body2d_set_pos/_set_angle/_set_vel/_set_spin,
body2d_impulse(b, ix, iy), body2d_impulse_at(b, ix, iy, px, py),
body2d_force, body2d_torque, body2d_mass(b) -> f32.
Behavior: body2d_fixed_rotation(b, true) (characters), body2d_gravity_scale,
body2d_damping(b, linear, angular), body2d_bullet(b, true) (fast objects),
body2d_friction, body2d_bounce, body2d_density,
body2d_filter(b, category_bits, mask_bits), body2d_tag(b, i32) /
body2d_get_tag(b), body2d_on_ground(b) -> bool (a contact pushing up from
below within ~45 degrees).
Events and queries:
- physics2d_contact_count(), physics2d_contact(i) -> Contact2 { a, b, x, y, nx, ny, began }
(began false: they separated; normal points from a to b).
- physics2d_sensor_count(), physics2d_sensor_event(i) -> Contact2 (a = the sensor, b = the visitor).
- raycast2d(x1, y1, x2, y2, mask) -> Hit2 { body, x, y, nx, ny, fraction } (body.id == 0: no hit; mask 0 = all).
- physics2d_overlap_box(x, y, w, h) -> count, physics2d_overlap_circle(x, y, r) -> count, then physics2d_overlap_body(i).
Joints (anchors in world pixels): joint2d_revolute(a, b, x, y),
joint2d_distance(a, b, ax, ay, bx, by), joint2d_prismatic(a, b, x, y, axis_x, axis_y),
joint2d_weld(a, b, x, y), joint2d_mouse(ground, b, x, y) + joint2d_target(j, x, y)
(drag with the pointer), joint2d_motor(j, speed, max_force) (revolute rad/s,
prismatic px/s), joint2d_limits(j, lo, hi) (radians / pixels), joint2d_destroy(j).
3D (meters, +Y up)
Gravity -9.81 (physics_gravity(v)).
| Create | |
|---|---|
body_box(pos, half, kind) -> Body |
half extents |
body_sphere(pos, r, kind) |
|
body_capsule(pos, h, r, kind), body_cylinder(pos, h, r, kind) |
upright, total height h |
body_convex(model, xf, kind), body_convex_mesh(mesh, xf, kind) |
convex hull of a model / mesh placed at xf (props, debris) |
body_mesh(model, xf), body_mesh_mesh(mesh, xf) |
exact triangles, static only (levels, buildings, rocks) |
body_heightfield(heights: f32[], samples_x, samples_z, cell, origin) |
static terrain, heights[z * samples_x + x], origin = sample (0, 0) |
Density is water-like (1000 kg/m^3), so a 1 m box is 1000 kg; set real masses
with body_mass(b, kg). Default friction 0.6, bounce 0.
State: body_xform(b) -> Mat4, body_pos, body_rot -> float4, body_vel,
body_spin, body_set_pos/_set_rot/_set_xform/_set_vel/_set_spin,
body_impulse(b, i), body_impulse_at(b, i, point), body_force,
body_torque, body_move(b, pos, rot, dt) (kinematic: doors, platforms,
elevators), body_friction, body_bounce, body_mass,
body_damping(b, linear, angular), body_gravity_scale, body_ccd(b, true)
(fast small things), body_sensor(b, true) (trigger, reported as contacts),
body_tag(b, i32) / body_get_tag(b) (-1 for ragdoll parts), body_layer(b, l),
body_wake(b), body_awake(b), body_destroy(b), body_valid(b).
Layers 0..15: LAYER_STATIC 0 (static bodies), LAYER_DYNAMIC 1 (everything
else), LAYER_CHARACTER 2, LAYER_RAGDOLL 3, LAYER_DEBRIS 4 (hits only
static), 5..15 yours. All pairs collide except static/static, ragdoll/character
and debris/non-static; change with physics_collide(a, b, on). Query masks are
layer bits (1 << layer), 0 = all.
Queries:
- raycast(origin, dir, max_dist, mask, ignore) -> RayHit { body, dist, point, normal }
(dist < 0: no hit; ignore: a body to skip, e.g. character_body(player)).
raycast(origin, dir, max_dist) hits anything.
- spherecast(origin, radius, dir, max_dist, mask, ignore) -> RayHit.
- physics_overlap_sphere(center, r, mask) -> count then physics_overlap_body(i).
- physics_contact_count(), physics_contact(i) -> Contact { a, b, point, normal, speed, began }
(speed = closing speed: play an impact sound when it is above ~2).
Joints (world anchors; gander::NO_BODY as b attaches to the world):
joint_hinge(a, b, point, axis, min_deg, max_deg) (min >= max: free),
joint_fixed(a, b), joint_point(a, b, point),
joint_cone(a, b, point, twist_axis, swing_deg, twist_min, twist_max),
joint_distance(a, b, pa, pb, min, max), joint_motor(j, rad_per_s, max_torque)
(hinges), joint_destroy(j).
Character controller
character(feet, height, radius) -> Character: a capsule that walks. It steps
up stairs (0.4 m), sticks to slopes up to 50 degrees, rides moving platforms,
pushes dynamic bodies, collides with other characters, and has an inner body on
LAYER_CHARACTER so raycasts can hit it.
character_update(c, move, jump, dt)once per frame:move= desired horizontal velocity (m/s, world),jump= upward speed when it should jump (only used on the ground, 0 otherwise). Gravity and air control are handled.character_pos(c)(feet),character_vel,character_on_ground,character_ground_normal,character_set_pos,character_set_vel(knockback),character_height(c, h) -> bool(crouch; false when blocked above),character_body(c),character_destroy(c).character_limits(c, step, max_slope_deg): the tallest step walked up (default 0.4 m) and the steepest slope walked on (default 50 degrees; crater banks 58).
Ragdolls
ragdoll(anim) -> Ragdoll lays 12 bodies on a character's skeleton by bone
name (Hips, Spine*, Neck, Head, Left/Right Arm, ForeArm, Hand, UpLeg, Leg, Foot;
Meshy, Mixamo, Unreal and Blender names all map): pelvis, spine, chest, head,
upper and lower arms and legs, cone joints at hips / shoulders / spine / neck,
hinges that bend only the natural way at knees and elbows.
- Draw the character with
draw_ragdoll(r, xf)always (alive too): it animates normally and records the pose so a ragdoll keeps the motion it had. ragdoll_start(r, impulse, hit_point)on death: goes limp from the current pose, the impulse (N s; a rifle round ~40..80 along the bullet) goes into the body nearest hit_point.ragdoll_state(r):RAGDOLL_ALIVE,RAGDOLL_FALLING,RAGDOLL_SETTLED(baked into a static pose after it rests, or after 4.2 s; costs nothing afterwards). At most 8 fall at once (ragdoll_max_active(n), 1..32); starting one more settles the oldest.ragdoll_limits(r, part, swing, twist): joint limits in degrees for one part ("head", "l_upperarm", "r_shin" ...): swing is the cone half angle (for elbows and knees the largest bend), twist the turn about the bone; -1 keeps the default.ragdoll_damping(r, linear, angular)(0.1 / 0.6): higher falls limper and settles sooner. Both apply from the nextragdoll_start.- On a character whose model is still streaming (
model_async) the ragdoll waits: its calls do nothing until the model arrives. ragdoll_impulse(r, impulse, point)while falling (explosions),ragdoll_reset(r)back to animation (respawn).ragdoll_bone_hit(r, point, out_name) -> partfor hit-location damage: writes the bone name ("Head", "LeftLeg") and returns 0 pelvis, 1 spine, 2 chest, 3 head, 4/5 left arm, 6/7 right arm, 8/9 left leg, 10/11 right leg.body_ragdoll(b) -> Ragdolltells which ragdoll a raycast hit.
Cloth
Capes, flags, banners, curtains and tarps: a sheet simulated in
physics_step (Jolt soft bodies). It is pushed by bodies, characters and
ragdolls but never blocks them, and raycasts and shape casts pass through it,
so a curtain never stops a player or a bullet.
cloth(ClothDesc { .. }, xf) -> Clothmakes the sheet in its pin framexf: x across, hanging down -y, the front facing +Z.width,heightin metres.pin:CLOTH_PIN_TOP(top edge centred on the frame: capes, curtains),CLOTH_PIN_LEFT(left edge, running out along +x: a flag from its pole),CLOTH_PIN_CORNERS(the two top corners: banners on hooks),CLOTH_PIN_NONE(nothing: it falls and drapes over what is below).weight(kg/m^2: 0.1 silk, 0.25 a flag, 0.5 a heavy cape, 1 canvas),bend(0 drapes like silk .. 1 holds like canvas),damping,wind(how muchcloth_windmoves it).backstop: capes only (see below).detail: grid spacing, leave it 0.swing_top,swing_bottom: swing limits for pinned sheets (Unreal's painted max distance): how far in metres each row may move from where it hangs in the pin's frame, from the pinned edge to the hem, linear down the rows. A cape: 0.03 at the shoulders, 0.5 at the hem, so it moves with the stride and the wind but never flies up over the head. -1 (default) off.cloth_pin(c, xf): move the pinned edge. Every frame beforephysics_stepfor a cape; never for a flag that stays put. A jump of more than 4 m carries the whole sheet (respawn, teleport) instead of stretching it.draw_cloth(c, material)every frame. Both sides are lit with any material; a texture's u runs across from the left edge, v down from the top.draw_cloth_lined(c, outer, lining): instead ofdraw_cloth, a sheet with a lining:outeron the front (+Z as made: away from the wearer for a cape),liningon the back. Give both single-sided materials.cloth_swing_scale(c, k): scale every swing limit at run time (1 as made, 1.5 looser while sprinting or in a gale, 0.5 tighter while kneeling).cloth_wind(float3 m/s, gust 0..1): wind on every sheet (5 m/s makes a flag stand out, 12 is a gale; gust 0.5 gives ripples and gusts). Wind only pushes a sheet facing it: a flag side-on to the wind hangs.cloth_push(c, at, radius, vel): shove it around a point (a shot through a flag, a blast, an NPC brushing a curtain).cloth_point(c, u, v) -> float3: a point on the sheet as drawn (hang a lantern on a banner, put a sound on a flag's tip).cloth_ignore(c, body): something the sheet passes through: a cape's wearer (character_body(player)), so it never fights the capsule.cloth_capsule(c, bone_a, bone_b, radius) -> i32: a capsule between two bones of the wearer that the sheet stays out of (thighs, shins, arms, a sword;bone_b"" makes a sphere atbone_a). Make them once, up to 24.cloth_wearer(c, anim, xf): every frame next tocloth_pin, the wearer's animator and the matrix it is drawn with, so the capsules follow its bones.cloth_free(c).
Costs about 0.7 microseconds per vertex per frame. The default grid is 8 cm
(5 cm in Pro builds, at most 32 x 32 vertices, 48 x 48 in Pro): a cape is
about 0.1 ms, a 2 m curtain about 0.6 ms on the web. Keep it to a few
sheets near the player, and cloth_free sheets far away.
Capes. Characters face -Z, so the cape goes on their back at +Z. Pin it
with the same matrix you draw the character with, moved to the shoulders;
use only the spine bone's position, so the cape hangs straight down from the
character instead of twisting with every step of the animation. backstop
(about 0.02) keeps the cape from swinging into the body, with no colliders on
the body:
var cape: gander::Cloth;
fn cape_frame(a: gander::Anim, xf: gander::Mat4) -> gander::Mat4 {
let s = gander::position(gander::bone(a, "spine_03")); // "Spine2" on Mixamo rigs
gander::mul(xf, gander::translate(gander::v3(0.0, s.y + 0.1, s.z + 0.17)))
}
// main: after the animator exists
cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, pin: gander::CLOTH_PIN_TOP,
weight: 0.5, bend: 0.2, backstop: 0.02 }, cape_frame(anim, hero_xf));
gander::cloth_ignore(cape, gander::character_body(player));
// frame
gander::cloth_pin(cape, cape_frame(anim, hero_xf)); // before physics_step
gander::physics_step(dt);
gander::draw_animated(anim, hero_xf);
gander::draw_cloth(cape, cape_mat);
A lined cape with a collar. Swing limits keep the hem's reach believable without colliders; the lining shows where the cape turns over; a collar is a second short, stiff sheet pinned a little higher (it is drawn over the cape's top edge):
cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, weight: 0.5, bend: 0.2, backstop: 0.02,
swing_top: 0.03, swing_bottom: 0.5 }, cape_frame(anim, hero_xf));
collar = gander::cloth(gander::ClothDesc { width: 0.5, height: 0.14, weight: 0.8, bend: 0.9,
swing_top: 0.0, swing_bottom: 0.04 }, gander::mul(cape_frame(anim, hero_xf), gander::translate(gander::v3(0.0, 0.05, 0.02))));
// frame: pin both, then
gander::draw_cloth_lined(cape, wool_outer, linen_lining);
gander::draw_cloth_lined(collar, fur, fur);
A cape that wraps the body. backstop alone keeps a cape off the back,
but a stride or a swing still passes through it. Capsules on the limbs make
it drape over them instead: after every physics tick, any part of the sheet
inside one is put on its surface (a fast swing cannot tunnel through). Size
each a little wider than the limb with its armour; a sword's capsule goes from
the hand to the blade's socket.
Keep the cape's backstop small (0.02) with capsules: a loose one lets it swing
forward into the legs and bunch. Keep the pinned edge out of every capsule
(the engine warns when one holds it: narrow the cape or the capsule).
// main, after cloth(): legs, arms and the blade
gander::cloth_capsule(cape, "thigh_l", "calf_l", 0.10);
gander::cloth_capsule(cape, "thigh_r", "calf_r", 0.10);
gander::cloth_capsule(cape, "calf_l", "foot_l", 0.07);
gander::cloth_capsule(cape, "calf_r", "foot_r", 0.07);
gander::cloth_capsule(cape, "upperarm_r", "lowerarm_r", 0.07);
gander::cloth_capsule(cape, "hand_r", "blade_tip", 0.04); // a socket (gander glb socket)
// frame, before physics_step
gander::cloth_pin(cape, cape_frame(anim, hero_xf));
gander::cloth_wearer(cape, anim, hero_xf);
Flag on a pole. The frame sits at the top of the pole; the flag runs out along +x and turns with the wind:
flag = gander::cloth(gander::ClothDesc { width: 1.8, height: 1.1, pin: gander::CLOTH_PIN_LEFT,
weight: 0.2, bend: 0.15 }, gander::place(pole_top, 0.0));
gander::cloth_wind(gander::v3(6.0, 0.0, 1.0), 0.5);
Tarp over crates. CLOTH_PIN_NONE with the frame tipped flat (the sheet's
down becomes +z), a metre above what it should cover:
let xf = gander::trs(gander::v3(x - 1.1, top + 1.0, z - 1.1),
gander::quat_axis(gander::v3(1.0, 0.0, 0.0), -1.5708), gander::v3(1.0, 1.0, 1.0));
tarp = gander::cloth(gander::ClothDesc { width: 2.2, height: 2.2, pin: gander::CLOTH_PIN_NONE, weight: 0.8 }, xf);
Recipes
FPS player:
var player: gander::Character;
var yaw: f32 = 0.0;
var pitch: f32 = 0.0;
// main(): player = gander::character(gander::v3(0.0, 0.0, 0.0), 1.8, 0.3);
export "gander_frame" fn frame(dt: f32) {
let look = gander::look_axis();
yaw -= gander::deg(look.x);
pitch = clamp(pitch - gander::deg(look.y), -1.4, 1.4);
let mv = gander::move_axis();
let fwd = gander::v3(-sin(yaw), 0.0, -cos(yaw));
let right = gander::v3(cos(yaw), 0.0, -sin(yaw));
let speed: f32 = if gander::action_down("sprint") { 6.5 } else { 4.0 };
let jump: f32 = if gander::action_pressed("jump") { 5.0 } else { 0.0 };
gander::character_update(player, (fwd * mv.y + right * mv.x) * speed, jump, dt);
gander::physics_step(dt);
let eye = gander::character_pos(player) + gander::v3(0.0, 1.65, 0.0);
gander::camera_xform(gander::trs(eye, gander::quat_euler(pitch, yaw, 0.0), gander::v3(1.0, 1.0, 1.0)), 70.0);
}
Bullets (hitscan) with ragdoll deaths and headshots:
let eye = gander::camera_pos();
let dir = gander::camera_forward();
let hit = gander::raycast(eye, dir, 200.0, gander::MASK_ALL, gander::character_body(player));
// enemies are drawn with draw_ragdoll; find who was hit by distance to the hit point
// (or keep a capsule body per enemy on a game layer and use its tag)
var bone: u8[>..] = [];
let part = gander::ragdoll_bone_hit(enemy_doll, hit.point, bone);
if part == 3 { damage = 100; }
if enemy_hp <= 0 { gander::ragdoll_start(enemy_doll, dir * 60.0, hit.point); }
Platformer (2D):
// main(): hero = gander::body2d_capsule(80.0, 600.0, 56.0, 14.0, gander::DYNAMIC);
// gander::body2d_fixed_rotation(hero, true);
let v = gander::body2d_vel(hero);
var vy = v.y;
if gander::action_pressed("jump") && gander::body2d_on_ground(hero) { vy = -420.0; }
gander::body2d_set_vel(hero, gander::move_axis().x * 260.0, vy);
gander::physics2d_step(dt);
let p = gander::body2d_pos(hero); // draw the hero sprite at p
Coins / triggers (2D): coin = gander::body2d_sensor(x, y, 30.0, 30.0), then
after stepping loop physics2d_sensor_event(i) and check e.began && e.a.id == coin.id.
Cars and trucks (3D): use the vehicle API, never hinge-jointed wheels (no suspension, they hop and flip). Build a dynamic chassis, then one call makes the whole car: suspension, engine with torque curve, automatic gearbox, differentials, anti-roll bars, tyre grip, lowered centre of mass.
car_body = gander::body_box(gander::v3(0.0, 1.5, 0.0), gander::v3(0.9, 0.4, 2.0), gander::DYNAMIC);
gander::body_mass(car_body, 1300.0);
// wheelbase 2.6, track 1.6, wheels at y -0.3 (chassis-local), radius 0.38, travel 0.3 m, all-wheel drive
car = gander::vehicle_car(car_body, 2.6, 1.6, -0.3, 0.38, 0.3, 2);
gander::vehicle_tune(car, 700.0, 7500.0, 32.0, 0.8); // torque, redline, steering lock, grip (gravel)
gander::vehicle_suspension(car, 1.5, 0.5); // soft rally springs
// every frame, before physics_step:
let m = gander::move_axis();
gander::vehicle_drive(car, m.y, m.x, if gander::action_down("brake") { 1.0 } else { 0.0 },
if gander::action_down("handbrake") { 1.0 } else { 0.0 });
gander::physics_step(dt);
// draw: the body from body_xform(car_body); each wheel (axle along X) from
// vehicle_wheel_xform(car, i), e.g. a wheel model authored axle-along-X, or
// MESH_CYLINDER times gander::rotate_z(1.5708) (its axis is Y)
for i: i32 in 0..4 { gander::draw_model(wheel, gander::vehicle_wheel_xform(car, i)); }
// HUD and feel: vehicle_speed (m/s, x 3.6 = km/h), vehicle_rpm (engine
// sound pitch), vehicle_gear; vehicle_wheel_slip(car, i) x/y past ~0.2/0.15
// = dust, skid marks and tyre squeal; vehicle_wheel_contact for airtime.
Forward is the chassis's -Z, wheels 0..3 are front left, front right, rear
left, rear right. Throttle below zero reverses once stopped. Drive on a
body_heightfield (terrain) or meshes: the wheels cast cylinders, so kerbs,
rocks and ramps work.
Thrown objects / debris: body_convex(model, xf, gander::DYNAMIC), then
body_impulse; put small debris on LAYER_DEBRIS so it never blocks players.
Terrain: build the height grid you render with, then
body_heightfield(heights, nx, nz, cell, origin) so characters and ragdolls
stand on it.