Skip to main content

humpty

humpty is the rigid-body physics under Eggy's Physics, in 2D or 3D and f32 or f64, after Rapier and Avian. It doesn't import Eggy, so any Zig 0.17 project can use it: on its own as a physics world, or inside a clutch ECS world as a plugin. Its only dependencies are clutch and the eggenvector maths library.

Add it to a project​

zig fetch --save git+https://github.com/eggyengine/humpty
// build.zig
const humpty = b.dependency("humpty", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("humpty", humpty.module("humpty"));

zig build docs in humpty's checkout writes the full API reference to zig-out/docs.

A world​

humpty.Physics(dim, T) is a world type: dim is .two or .three, and T the scalar it computes in. Physics2 and Physics3 are the f32 ones. Every vector, pose and shape below is that world's: World.Vec, World.Pose, World.Shape.

const humpty = @import("humpty");
const World = humpty.Physics3;

var world = try World.init(gpa, .{ .gravity = .init(0, -9.81, 0), .fixed_step_s = 1.0 / 60.0 });
defer world.deinit();

// Ground: a collider with no body is static scenery.
_ = try world.addCollider(.{ .shape = .{ .half_space = .{ .normal = .init(0, 1, 0) } } }, null, 0);

// A ball: a body, and the collider it touches the world with.
const ball = try world.addBody(.{ .kind = .dynamic, .pose = .fromTranslation(.init(0, 10, 0)), .mass_kg = 1 }, 1);
_ = try world.addCollider(.{
.shape = .{ .ball = .{ .radius = 0.5 } },
.material = .{ .restitution = 0.3, .friction = 0.5 },
}, ball, 1);

// Each frame, with the frame's length in seconds.
try world.update(frame_s);
const where = world.pose(ball).translation;

update runs as many fixed steps as fit in the frame and carries the rest over, so the simulation is the same at any frame rate.

The last argument of addBody and addCollider is a u64 of your own, read back with bodyData and colliderData: an entity id, an index into your own array. Handles are generational, so one kept after its body is removed goes stale (contains says false) instead of naming whatever reuses the slot.

CallDoes
addBody(body, data), removeBody(handle)Adds a body, or removes it with its colliders.
addCollider(collider, body, data), removeCollider(handle)Attaches a collider to a body, or with null adds static scenery.
pose(body), setPose(body, pose)Where a body is, or teleports it.
velocity(body), setVelocity(body, .{ .linear, .angular })How fast it moves (m/s) and turns (rad/s).
applyImpulse(body, impulse_ns, .center_of_mass)Pushes it, in N·s; .{ .at_world_point = p } pushes off-centre and spins it.
setColliderPose(collider, pose)Moves a collider on its body, or in the world when it has none.
isSleeping(body)Whether it has settled; touching or pushing it wakes it.
contacts(), started(), ended()The last step's contact points, and the collider pairs that began and stopped touching.
raycast(ray, filter)The nearest collider along a ray.
debugDraw(options, sink)The world as lines, for any renderer.

Bodies and colliders​

A Body has a kind: .dynamic bodies are pushed by gravity, contacts and impulses; .kinematic ones move only at the velocity you give them and push dynamic ones aside; .static ones never move. Only dynamic bodies have mass_kg, spread over their colliders by volume for how they turn. gravity_scale, linear_damping and angular_damping tune how it moves, and rotation = .locked keeps it from turning at all, for characters.

A Collider has a shape, a local_pose on its body (in the world when it has none), a material (restitution 0 to 1, friction) and sensor: a sensor reports what it overlaps in started and ended without pushing anything.

ShapeFields
ballradius
cuboidhalf_extents
capsulehalf_height of the straight part, radius; along local y
cylinder, conehalf_height, radius; along local y; 3D only
convex_hullpoints, copied
meshvertices and indices (triangles in 3D, segments in 2D), copied; static only
half_spacenormal: everything behind the plane through the origin

Rays​

const hit = world.raycast(.{
.origin = eye,
.direction = forward, // unit length
.max_distance_m = 100,
}, .{ .exclude_body = player, .sensors = .skip }) orelse return;
// hit.collider, hit.point, hit.normal, hit.distance_m

A ray that starts inside a collider meets it at distance 0, so leave out the body casting it with exclude_body (or one collider with exclude_collider).

Debug drawing​

humpty doesn't draw; it hands line segments to a sink you give it, any value with a line(from, to, color) method:

const Lines = struct {
renderer: *MyRenderer,
pub fn line(self: *@This(), from: World.Vec, to: World.Vec, color: [4]f32) !void {
try self.renderer.addLine(from.toArray(), to.toArray(), color);
}
};
var lines: Lines = .{ .renderer = &renderer };
try world.debugDraw(.{}, &lines);

Each collider is drawn in its humpty.debug_draw.palette colour (static grey, dynamic green, kinematic purple, sleeping blue, sensor yellow), and each contact as a red cross with its normal. DebugDrawOptions turns either off and sizes the crosses. To draw one shape yourself, humpty.debug_draw.DebugDraw(World.S).shape(shape, pose, color, &lines).

In a clutch world​

humpty.ecs runs a world inside a clutch ECS world. Entities get components; the plugin builds bodies for them, steps the world in its own stage after Update, and writes where bodies went back to your transform component.

const Physics = humpty.ecs.Plugin(.three, f32, MyHost);
try Physics.install(&world, gpa, .{ .gravity_mps2 = .init(0, -9.81, 0) });
defer world.runStage(.Deinit) catch {};

_ = try world.spawn(.{ MyTransform{ .scale = .init(10, 1, 10) }, humpty.ecs.Collider{} });
_ = try world.spawn(.{ MyTransform{ .position = .init(0, 3, 0) }, humpty.ecs.Collider{}, humpty.ecs.RigidBody{} });

// Each frame, after your Update systems:
try world.insertResource(MyTime{ .delta_s = frame_s });
try world.runStage(humpty.ecs.PhysicsStage);

The plugin knows your engine only through the host type you give it:

const MyHost = struct {
pub const Transform = MyTransform;
pub const Time = MyTime;
pub fn deltaSeconds(time: *const MyTime) f32 {
return time.delta_s;
}
/// Where `transform` puts `entity` in the world, after any parents.
pub fn place(world: *clutch.World, entity: clutch.EntityId, transform: MyTransform) humpty.ecs.Place {
_ = .{ world, entity };
return .{ .position = transform.position, .rotation = transform.rotation, .scale = transform.scale };
}
/// Puts `entity` at a world position and rotation, by changing its own `transform`.
pub fn setPlace(world: *clutch.World, entity: clutch.EntityId, transform: *MyTransform, position: Vec3, rotation: UnitQuat) void {
_ = .{ world, entity };
transform.position = position;
transform.rotation = rotation;
}
// Optional: the triangles of `entity`'s mesh, for mesh and convex-hull colliders.
// pub fn mesh(world: *clutch.World, entity: clutch.EntityId, gpa: Allocator) !?humpty.ecs.MeshSource
};

Components are plain f32 data whatever the world's dimension and precision, so an editor can list and save them; in 2D only x and y are read, and bodies turn about z.

ComponentWhat it is
Collidershape (sizes in metres before the entity's scale), offset_m, restitution, friction, sensor, mesh_from (another entity whose mesh the mesh shapes take, where it sits; the collider's own when unset)
RigidBodykind, mass_kg, damping, gravity_scale, rotation
LinearVelocity, AngularVelocityRead to see how a body moves; set to change it
CollidingEntitiesKept listing the entities its collider touches
PhysicsSettingsgravity_mps2 and steps_per_second, read from the first entity that has it

Editing a component rebuilds that entity's body; moving its transform (or a parent's) moves it. CollisionStarted and CollisionEnded events are sent for each pair that begins and stops touching. Plugin.Resource is the running world: raycast(origin, direction, max_m, filter) by entity (humpty.ecs.RayFilter{ .exclude = entity }), and started() and ended() as entity pairs. Plugin.drawColliders(world, entities, gpa, sink) draws colliders from their components, with no world running, and Plugin.drawContacts(world, sink) the last step's contacts.