A rigid-body physics engine written in Aether, for ae3d and any Aether program.
The brief: the most accurate and most performant rigid-body physics there
is, in the language, not behind a binding. Rather than trust anyone's
README, the candidates were built on one machine and run on identical
scenes -- bench/RESULTS.md. By those numbers the
engine follows the design of Box3D (Erin Catto, 2026, C17, MIT): its
Soft Step solver held a 100-row pyramid at 10 ms a step where Jolt's
collapsed at 44, dropped ten thousand boxes at 10 ms a step against 43,
and given the same budget as Jolt at its stiffest it was tighter on every
scene.
This is our implementation. Box3D and Jolt are fetched, unmodified, into an
ignored reference/ (scripts/fetch_references.sh) for two things only:
their tests, which every layer here is written against, and the same-machine
benchmarks every layer is measured against. Nothing of theirs is
redistributed here; both are MIT and credited. The naming follows Box3D's
where the function is the same thing, without its prefix, in snake case,
so a test written against the reference reads the same here.
| module | holds | state |
|---|---|---|
aephysics.math |
vectors, quaternions, transforms, 3x3 matrices, bounding boxes, segment distances, inertia helpers, the deterministic atan2/cos/sin, and the same vectors in single precision (Vec3f, Quatf, Matrix3f) for the geometry the reference keeps in float (#42) |
done, test_math.ae (6M checks) |
aephysics.basics |
bit set, id pool, hash set, a long-to-int map, arrays, the stack and arena allocators, the block hash (the reference's core.c, named so a host's own core module and this one can share a program) |
done, test_basics.ae (100k checks) |
aephysics.native |
the Aether face of the one C file built with every program: the threads' helpers Aether has not (a thread-local worker index, yield, pause, the processor count, atomics on an int in place, a semaphore) and the per-worker scratch every module keeps a block of | done |
aephysics.parallel |
the reference's scheduler and parallel_for: worker threads made once and waiting on a semaphore, tasks in slots claimed by compare-and-swap, the main thread helping while it waits; a range in blocks the tasks claim, the caller working as worker 0 |
done, test_parallel.ae (18 checks); the step by worker count against the reference's |
aephysics.dynamic_tree |
the bounding volume hierarchy under the broad phase: SAH insertion, rotations, enlarge, sweep refit, partial rebuild in depth-first order, box / closest / ray / swept-box queries | done, test_dynamic_tree.ae (12k checks); same tree as the reference, ray cast 1.9x its time |
aephysics.hull |
quickhull with face merging, the half-edge hull with its mass properties, box / cylinder / cone / rock hulls, clone-and-transform with mirroring, support functions, ray cast, the 2D hull | done, test_hull.ae (438 checks); same hulls as the reference, 1.6-2x its time |
aephysics.distance |
GJK with the warm-started simplex cache, the shape cast by conservative advancement, the time of impact by separating-axis root finding | done, test_distance.ae (1.1k checks); same results as the reference, 1.3-1.5x its time |
aephysics.manifold |
contact manifolds for sphere, capsule and hull in every pairing: the separating axis test with its cache, reference-face clipping, the feature pairs, reduction to four points | done, test_manifold.ae (43k checks, 7,000 pairs against a brute-force oracle); same manifolds as the reference, warm cache at parity |
aephysics.triangle_manifold |
one mesh triangle against a sphere, capsule or hull: back-side cull with hysteresis, GJK shallow, the separating axis test deep with the triangle's edges as zero-area faces, the feature recorded for the mesh contact's ghost-collision reduction | done, test_triangle_manifold.ae (1.5k checks); same manifolds as the reference, within 10% on hulls |
aephysics.mesh |
the triangle mesh: a BVH by binned SAH or median split with the triangles in depth-first order, vertex welding, edge flags, any scale including mirrored; overlap, ray cast, shape cast, the mover's planes, a box query | done, test_mesh.ae (1.6k checks); same trees as the reference, traversals 1.7-2x |
aephysics.height_field |
the height field: quantised heights on a fixed diagonal, materials and holes per cell, edge flags per triangle, either winding; overlap, ray and shape casts by a walk along the grid, the mover's planes, a box query | done, test_height_field.ae (113 checks, casts against a brute force over a wave); same results as the reference, query at parity, casts 1.4-2x |
aephysics.material |
a surface's material (friction, restitution, rolling resistance, tangent velocity, user id) and its default | done |
aephysics.sphere |
the sphere: mass, bounds, the ray cast in the closest-point form that keeps its precision far from the origin, the shape cast, overlap, the mover's plane | done, in test_shape.ae |
aephysics.capsule |
the capsule: mass by cylinder and caps, bounds, the ray cast by the closest points of the lines with the near-parallel fallback, the shape cast, overlap, the mover's plane | done, in test_shape.ae |
aephysics.compound |
the baked compound: capsules, hulls, meshes and spheres in one block under a static tree, hulls and meshes shared by content, materials by value; bounds, overlap, ray and shape casts, the box query, the mover's planes | done, test_compound.ae (159 checks); same results as the reference, build 0.7x, queries 1.3-1.6x |
aephysics.shape |
the shape of any kind (sphere, capsule, hull, mesh, height field, compound) with the dispatch over every kind under a transform: bounds, swept and fat bounds, centroid, areas, mass, extent, ray and shape casts, overlap, the mover's planes, the proxy; the collision filters | done, test_shape.ae (440 checks); same results as the reference, rays and masses at parity, radius casts 2.5x |
aephysics.mover |
the character mover's plane solver: pushes accumulated and clamped over twenty sweeps, the velocity clip | done, test_mover.ae (56 checks); same results as the reference, 0.9x its time |
aephysics.broad_phase |
the broad phase: a tree per body type, proxies keyed by type, the pair update through the moved siblings and cross-tree seeds with the filter and compound lookups as visitors, the pair set, the keys sorted | done, test_broad_phase.ae (36 checks against a brute force); 10,000 moving boxes at 4.6 ms a step |
aephysics.mesh_contact |
a convex shape against a mesh or height field: the triangle cache with per-triangle warm starts, the manifolds per triangle, the seam rules against ghost collisions, clusters by normal, the four-point cull | done, test_mesh_contact.ae (64 checks); a box on a wave at 3.2 us a step |
aephysics.dynamics |
the world's state and bookkeeping: worlds, bodies (creation, mass from shapes or by hand, transforms, velocities, extents, locks, sleep and enable flags, the type changed, disabled and enabled) in solver sets, shapes of every kind on bodies with their broad-phase proxies, islands linked by contacts and joints and split by union-find, sleeping sets, waking and merging, the constraint graph's colouring, contacts from the broad phase's pairs with the narrow phase (convex, mesh, height field, compound children) and the state changes with their events, contact recycling, joints of every kind (definitions, creation with the edges, sets and islands, collide-connected in the pair filter, destruction, separations, reactions), sensors with their begin and end events | test_dynamics.ae (541 checks: test_body.c, the step-free test_world.c and test_joint.c, contacts, islands, sleep, joints, sensors); creation at parity, collide 1.7x, joints 0.5x, sensors 1.2x |
aephysics.contact_solver |
the contact constraints of a step, scalar: prepared from the manifolds (anchors, base separations, normal masses, the friction centre and its tangent mass, twist and rolling masses), warm started, solved per sub-step (soft normal constraints with speculative bias, then central friction, twist friction and rolling resistance in the relax pass), the restitution pass, the impulses stored with the hit events; the step context | test_contact_solver.ae (49 checks: the passes by hand on a cube on a slab) |
aephysics.contact_solver_wide |
a colour's convex contacts four at a time: the constraint of four contacts as one single-precision record in lanes (f32x4, std.lanes -- the reference's SIMD path), the same prepare, warm start, solve, restitution and store, the bodies gathered into lanes and scattered back |
test_contact_solver_wide.ae (one scene two ways -- the stack, the bounce with hit events, rolling resistance, a conveyor, a slide -- the lanes within a millimetre of the scalar solve, and the same lanes twice bit for bit) |
aephysics.joint_solver |
the seven joints solved: each kind's prepare (frames relative to the centres of mass, effective masses, spring softness), warm start and solve (rigid or soft constraints, speculative limits, springs, motors), the kinds' accessors (limits, springs, motors, current angles and translations, forces and torques), joint.c's dispatch with the constraint hertz clamp | test_joint_solver.ae (149 checks: test_joint.c's accessors on every kind, each kind solved by hand) |
aephysics.solver |
the Soft Step over the workers, the reference's stages: the work in blocks (the bodies', each colour's joints, wide and scalar constraints) that workers claim by compare-and-swap, the stages published as sync bits by worker 0 -- the constraints prepared, per sub-step the velocities integrated (gravity, damping, the gyroscopic torque), warm start, solve, positions, relax, colour by colour with the overflow first on worker 0; restitution, the impulses stored; the bodies finalised in parallel (sleep velocities, move events, fast bodies swept for the time of impact, bounds and proxies), the joint and hit events, the trees refit, bullets, the sensors' hits, islands put to sleep | test_solver.ae (45 checks: free fall against the closed form, resting and sleeping, a stack, a bounce and its hit event, a joint event, a pendulum, a fast sphere stopped by a thin wall, a bullet, a sensor swept, two worlds bit for bit alike) |
aephysics.physics_world |
the world's face: the step (pairs, narrow phase, solve, sensors, events), the events read back, the settings and counters, queries over every shape (overlap of a box or a proxy, the mover's planes, ray, shape and mover casts, the closest ray hit) and against one body at a transform of the caller's, explosions | test_physics_world.ae (94 checks: test_world.c's HelloWorld, contact, hit, move and sensor events, the explosion near and far; test_body_query.c's casts, overlaps, mover planes and time of impact; the world queries; a wave pile stepping alike twice) |
aephysics.human |
the ragdoll: twelve capsule bones on spherical joints with cone and twist limits and revolute joints with angle limits, a spring on every joint toward the reference pose, a motor whose torque limit is joint friction, filter joints for the limbs that clash; the align spring, kinematic anchors through motor or parallel joints and their targets driven over a step (the pose drive of an active ragdoll), velocity, kicks, bullets | test_human.ae (55 checks: the figure's shape, a fall to rest in one piece, the setters, a kick, standing under the align spring, the pose held on anchors, the anchors driven two metres and the figure walked behind them, the same drop twice bit for bit) |
aephysics |
the public API |
Deliberate choices:
- Double precision. Aether's float is a C double, so everything is
double; the reference's float/double world-position split collapses into
one
Vec3and oneTransform. - Determinism kept. The approximate atan2, cosine and sine exist for cross-platform replay and are kept, folded with the engine's own pi.
- No SIMD intrinsics. The wide contact solver is written scalar first and measured; where the benchmark says the wide path matters, that loop goes native behind the same interface.
- Threads. The reference's task scheduler is ported as it is, on threads Aether's std.worker makes, with the little C the threads need (aephysics.native); the single-threaded path came first and the benchmarks record both.
scripts/test.sh # builds and runs every tests/test_*.ae with ae; what CI runs
scripts/fetch_references.sh # Box3D and Jolt into reference/, built (needs cmake, ninja, gcc)
scripts/bake.sh [steps] # the bake-off scenes on the references
scripts/bench.sh [layer] # a layer of aephysics against the same code in the reference
scripts/profile.sh file.ae [top] # a sampling profile on Windows (tools/sampler.c + tools/rank.ae)
Aether keeps struct names in one namespace across modules, so a host
engine and this library must agree on every name they both use. The
math types are shared on purpose: Vec2 and Vec3 as {x, y, z},
Quat as {x, y, z, w} (the maths reads it through math.qv and
math.quat_vs, the reference's {v, s}), the same definitions ae3d's
core has, so a transform crosses between the two without conversion.
The rest (World, Plane, Buffer, HumanBone, ...) is this
library's; a host names its own types for what they are (ae3d's voxel
world, frustum plane and glTF buffer are VoxelWorld, FrustumPlane
and GltfBuffer).
aether.toml gives ae build the flags the benchmarks are measured
with (-O3 and a wider inlining budget, so the small maths inline as
the reference's static inline headers do; why).
One C file goes with the library, aephysics/native/aephysics_native.c:
the threads' helpers Aether has not (a thread-local worker index, the
atomics in place, a semaphore). aephysics.native names it (@source,
Aether 0.704), so every program that imports the library builds it in
with no flag of its own. The contact lanes lived there as GCC vector
code until Aether had lanes of its own; they are the module's f32x4
now. solver.set_wide_contacts(false) runs the scalar solve instead
(what the lanes buy).
A world steps over as many threads as its definition asks
(WorldDef.worker_count, one by default; native.processor_count()
for the machine's): the narrow phase, the solver's stages, the bodies'
finalize and the bullets go in blocks over the workers, the reference's
way, with every worker writing its own context and the step merging
them after. The result does not depend on the count: within a colour
no two constraints move the same body, so a step is the same to the
bit at one worker or twenty-four (test_determinism.ae runs each
scene over four workers too). The worker threads are made when the
world is and wait on a semaphore between steps; a world of one worker
never makes a thread. A host with a job system of its own lends it
instead (WorldDef.scheduler, an aephysics.parallel scheduler the
host made and keeps; its worker count then rules), as the reference
takes a game's task callbacks, so an engine runs its crowd, its
weather and its physics on one pool. What the workers buy, against
the reference's own scaling.
Beyond the modules' own tests, the reference's scene tests run on the
world as a whole: test_mover_world.ae (the mover through a world:
which material a plane came from, for meshes, compounds and convex
shapes; 38 checks), test_determinism.ae (the reference's wave pile,
query spawn and mesh drop with its own random numbers, and the
falling ragdolls, each run to sleep twice and over four workers and
compared bit for bit; the query spawn sleeps a step after the
reference's with its 59 query hits, the mesh drop two; 16 checks) and test_large_world.ae (a stack, a bullet and the
origin-relative queries at x = 0 and at x = 1e7 agree, the whole
engine being in doubles; 43 checks).
The step is deterministic across platforms and worker counts, not
only across runs: AEPHYSICS_TRACE=1 target/test_determinism prints
every body's checksum after every step with its bits, CI records the
Linux trace in its log, and the Windows trace (MinGW, a different gcc)
matches it bit for bit on every step of the four scenes. The engine's own maths
(the reference's cos, sin and atan2 approximations, sqrt, floor
and remainder as the only libm calls, no fused multiply-adds in the
lanes) is what makes that hold; the one divergence seen so far was an
older aetherc folding 1.0 / 60.0 to ten digits (issue #27).
design.md: the order of the layers, what each is measured
by, and the research this is the ground for -- active ragdolls and
procedural animation in the NaturalMotion/Euphoria line, for the engine
that consumes this.
The design and the tests followed are Box3D's, by Erin Catto (MIT), and the second baseline is Jolt, by Jorrit Rouwe (MIT). aephysics is by Nicolas Maman, MIT.