The reference engines on identical scenes, on one machine, one thread, each at its own recommended settings for a game's 60 Hz step. What decided which design this engine follows.
Machine: Intel Core i7-13700K, Windows 11, GCC 14 (msys2 ucrt64), 2026-09-19. The GPU was busy with a game at the time; these are CPU numbers.
Builds: Box3D f555ee4 (Release, SSE2, validation off); Jolt v5.3.0
(Distribution: LTO, AVX2/FMA, no profiler, no debug renderer -- its
shipping configuration). Box3D's build here has neither AVX nor LTO, so
if anything the table favours Jolt.
Settings: a 1/60 s step. Each engine at its own recommended setting first -- Box3D: 4 sub-steps (its own benchmark setting); Jolt: 1 collision step, 10 velocity + 2 position iterations (its defaults) -- and then each at the other's budget: Jolt at 4 collision steps, Box3D at 16 sub-steps. Sleeping off on both, so every body is simulated every step. Timing excludes the first step (the structures are built there). The machine was also running a game, so the times wander by about 20% between runs; the ratios do not.
| scene | bodies | Box3D 4 sub-steps | Jolt 1 step | how it held |
|---|---|---|---|---|
| pyramid, 100 rows of unit boxes | 5,050 | 9.6 ms/step | 44.5 | Box3D: worst drift 0.79 m after 300 steps, 0.72 m after 1,000 (the top wobbles, the stack stands). Jolt: 17.5 m after 300, 102 m after 1,000 -- the pyramid collapses. |
| pile, 20 x 25 x 20 unit boxes dropped 2 m | 10,000 | 10.2 | 42.5 | lowest box after 300 steps: Box3D 0.498 m (2 mm into the ground), Jolt 0.473 m (27 mm) |
| chain, a 100 x 100 grid of spheres on point joints, hung from its top row | 10,000 bodies, 19,800 joints | 11.1 | 11.3 | sag of the bottom corner below a taut 99-link chain: Box3D 0.94 m, Jolt 1.97 m |
| scene | Jolt 4 steps | Box3D 16 sub-steps | how it held |
|---|---|---|---|
| pyramid | 157 ms/step | 28.2 | Jolt now stands: drift 0.05 m. Box3D 0.68 m (its contacts are soft, 30 Hz by default; see below). |
| pile | 60.8 | 24.4 | Jolt 0.4995 m (0.5 mm in), Box3D 0.498 m (2 mm) |
| chain | 42.8 | 39.1 | Jolt 0.094 m. Box3D 0.79 m at its default 60 Hz joints; 0.047 m with the joints set to 240 Hz (BAKE_JOINT_HERTZ=240), same 39 ms. |
scripts/bake.sh 300 reproduces both tables; bench/bake_box3d.c and
bench/bake_jolt.cpp are the scenes.
- At its recommended setting Box3D's Soft Step solver is four times faster than Jolt's sequential impulse solver on the contact-heavy scenes, and it is the one that holds a tall stack at that speed: sub-stepping with soft constraints converges where iterating a single step does not.
- Given four times the collision steps Jolt holds everything too, at four to sixteen times Box3D's cost. Box3D at sixteen sub-steps -- about a third to two-thirds of Jolt's 4-step time -- matches or beats it on every scene.
- The residual drift and sag in Box3D are its soft constraints, not a
convergence limit: the joint stiffness is a per-joint hertz (default
60 Hz, capped at a quarter of the sub-step rate), and raising it with the
sub-step count took the chain's sag from 0.79 m to 0.047 m at no extra
cost. The same knob exists for contacts (
contactHertz, 30 Hz). This is the design working as intended: stiffness is a choice made per scene, bounded by the sub-step rate, rather than an iteration count. - Both scale across threads (Box3D's own records show 7x at 8 threads on a 7950X; Jolt is known to scale); the port is single-threaded first, so the single-thread numbers are the ones that matter for it.
Not measured: PhysX 5 (too heavy to build for a bake-off tick; GPU-first) and Bullet (an older sequential-impulse design without sub-stepping, the collision library under GTA IV/V's RAGE -- Euphoria sat on it, but the solver is not where the field is today). Rapier is Rust and its idioms would port worst.
The engine follows Box3D's design: Soft Step (sub-stepped, soft constraints, relax iterations, restitution pass), graph-coloured constraint islands, speculative contacts with continuous collision, island-based sleep, convex hulls / capsules / spheres / triangle meshes / height fields, a dynamic-tree broad phase, and cross-platform determinism. Jolt stays as the second baseline in this table.
Each layer of aephysics against the same code in the reference, on this
machine, one thread. scripts/bench.sh builds and runs every pair.
bench/tree.ae and bench/tree_box3d.c: 10,000 boxes (1 m, in a 200 m
cube) inserted one by one, 100 full rebuilds, 100,000 box queries (2 m),
10,000 ray casts (up to 200 m), then ten rounds of enlarging every proxy
by a random metre and a partial rebuild. The same random sequence on both.
| phase | aephysics | Box3D |
|---|---|---|
| insert 10,000 | 5.0 ms | 9.4 |
| 100 full rebuilds | 46 | 42 |
| 100,000 box queries | 12.5 | 11.9 |
| 10,000 ray casts | 6.9 (13.4 before the slab test) | 7.2 |
| 10 x enlarge all | 2.5 | 2.9 |
| 10 x partial rebuild | 5.9 | 5.0 |
Both report 3,965 query hits, 1,950 ray hits, height 17 and area ratio
63.45: the trees are the same tree. The ray cast was 1.9x here while it
tested each node as the reference does -- the segment's box, then the
three edge axes -- in scalar doubles against the reference's SSE2. It now
takes the slab test (math.slab_overlap) against the ray's inverse
direction, worked out once per ray: one pass, exact as the pair it
replaces, with the near and far faces picked by the ray's signs so a
tree's empty sibling box is still missed. 13.6 ms to 6.9 on the same
run (aephysics#11), at the reference's figure; the box cast takes the
same test on the node boxes grown by the cast box.
bench/hull.ae and bench/hull_box3d.c: 200 hulls of 64 points on a
sphere capped at 32 vertices, 20 hulls of 4,096 points inside a cube with
the corners stamped last (every interior point through the conflict lists,
most cone faces merged out), and 2,000 box hulls. The same random sequence
on both.
| phase | aephysics | Box3D |
|---|---|---|
| 200 sphere hulls, 64 points to 32 | 4.7 ms | 2.9 |
| 20 cube hulls, 4,096 points to 8 | 5.2 | 2.5 |
| 2,000 box hulls | 0.62 (2.0 before #108) | 0.17 |
Both produce 6,554 vertices and 12,106 faces in total: the same hulls. The builder runs at 1.6-2x the reference's time (doubles, indices in place of pointers, structs passed by value). Hull construction is a load-time cost, not a per-step one.
The box hulls were not the allocation (#108, 2026-10-05). The box's
topology came from helpers holding their table as a local array, so each
call copied the whole table onto the stack, 96 times a box. And the block
hash ran every word through one chain of multiplies. Now the topology is
written out and the hash runs four lanes: 2.9 ms to 0.62 ms for the 2,000
boxes. The faster hash also took about 10% off the mesh and height field
builds (median mesh 234-260 to 207-224 ms, height field 172-181 to
152-161). The hash now reads its words through memcpy
(native.load_word). Read through an integer pointer, as before, GCC
moved the reads ahead of the writes once the hash was inlined, and the
compound benchmark's material table found 253 materials instead of 2.
bench/distance.ae and bench/distance_box3d.c: two boxes (1 x 0.5 x
0.75 and 0.6 x 0.8 x 0.4) over 100,000 poses along a sweep that passes
through overlap, each pose a GJK query from a cold cache and then from a
cache warmed by the pose before; 10,000 shape casts of the second box at
the first; 10,000 times of impact of the second box falling and turning
onto a slab.
| phase | aephysics | Box3D |
|---|---|---|
| 100,000 GJK queries, cold cache | 22.9 ms | 17.3 |
| 100,000 GJK queries, warm cache | 18.2 | 11.9 |
| 10,000 shape casts | 5.0 | 3.5 |
| 10,000 times of impact | 13.8 | 9.4 |
The sums of the results agree to the precision of the reference's floats (distances 26,291.3 on both, cast fractions 2,780.06, impact fractions 5,523.1) and the GJK iteration counts within 0.01% (335,952 against 335,990): the same algorithm taking the same paths. The port runs at 1.3-1.5x the reference's time; the simplex is passed by value here where the reference works on it in place.
bench/lanes.ae and bench/lanes_native.c: the contact solve's
per-point body, four contacts to a pass -- rotate both anchors by the
bodies' delta rotations, the separation and its bias, the relative
normal velocity, the clamped impulse and the two bodies' velocity
updates -- over the same flat block of floats, once in Aether's
std.lanes and once in C with GCC's vector extensions, the way
aephysics/native/aephysics_native.c writes its own. Same layout, same
operations, same order, same compiler flags. 4,096 bundles x 4 points x
200 passes.
| time | |
|---|---|
| C, GCC vector extensions | 54-55 ms |
Aether, std.lanes |
55-56 ms |
Within two percent, and the same sum of impulses to the last digit (37,201,532). A lane operation in Aether costs what the same lane operation costs in C, which is what the native file's vector code was waiting on: the wide contact solver -- prepare, warm start, solve, restitution, and the pack and unpack that exist only because the module's constraints are doubles and the solve's are floats -- can come back into the module in Aether without paying for it (#46).
bench/manifold.ae and bench/manifold_box3d.c: 20,000 hull-hull
collisions of two boxes (1 x 0.5 x 0.75 and 0.6 x 0.8 x 0.4) along a sweep
through overlap with a cold SAT cache each time; the same 20,000 with the
cache carried from pose to pose (a stack's steady state); 20,000
hull-capsule and 20,000 hull-sphere collisions along a sweep.
| phase | aephysics | Box3D |
|---|---|---|
| 20,000 hull-hull, cold cache | 11.2 ms | 9.2 |
| 20,000 hull-hull, warm cache | 3.2 | 3.6 |
| 20,000 hull-capsule | 4.8 | 4.9 |
| 20,000 hull-sphere | 2.4 | 2.5 |
The same manifolds come out: 52,386 / 52,074 / 23,482 / 11,500 contact points, 19,706 cache hits, and equal separation sums on every phase. With the cache warm -- the state a resting stack is in -- and on capsules and spheres, the port is at or past the reference; the cold separating axis test is 1.4x.
The cold test was 13.2 ms until the edge-pair loop stopped reading A's edges through the half-edge structure. Every edge of A is tested against every edge of B -- a hundred and forty-four pairs for two boxes -- and each test read a face index into the planes and an origin index into the points, a pointer chase per pair; they are gathered once into a flat array of directions, normals and tolerances before the loop, in single precision, since a separating axis is a direction and the reference finds it in floats (#42). That took it to 12.7.
It is 11.2 since the gather became thirteen flat arrays and the loop
reads four of A's edges at a time (std.lanes, Aether 0.706): the whole
first test -- four dot products, a maximum and four comparisons -- is one
pass of lane arithmetic, and only a lane that survives it takes the
double path. The reference does the same thing with intrinsics; this is
the first place the port answers its SIMD in Aether rather than in the
native file. Every manifold is unchanged (52,386 cold points, the same
separation sums): each lane computes what the edge computed on its own,
in the same order, and the reduction keeps the lowest index among equal
values, so the axis a pair chooses is the axis it chose before.
The same treatment of the face tests does not pay and is not in: the support search over the other hull's vertices, gathered into lanes the same way, took the cold test from 11.2 to 11.8 ms. A box has eight vertices, so a search is two lane steps and the reduction across four lanes costs more than the eight dot products it replaces. It is worth measuring again on hulls with vertices in the dozens.
bench/triangle.ae and bench/triangle_box3d.c: 20,000 triangle-hull
collisions of a box at a hundred attitudes sinking onto a big triangle
with a cold SAT cache each time; 20,000 of a box resting on a creeping
triangle with the cache carried; 20,000 triangle-capsule collisions of a
capsule swung across a triangle and its edge; 20,000 triangle-sphere
collisions of a sphere swept over the triangle and off it.
| phase | aephysics | Box3D |
|---|---|---|
| 20,000 triangle-hull, cold cache | 2.8 ms | 2.6 |
| 20,000 triangle-hull, warm cache | 2.6 | 2.4 |
| 20,000 triangle-capsule | 5.1 | 3.8 |
| 20,000 triangle-sphere | 0.32 | 0.61 |
The same manifolds come out: 39,536 / 80,000 / 29,778 / 6,422 contact points, 19,999 cache hits, equal separation sums. Triangle against hull is within 10% of the reference both cold and warm; the capsule is 1.35x (its GJK); the sphere, which is the closest point on a triangle and nothing else, is faster here.
bench/mesh.ae and bench/mesh_box3d.c: a 200 x 200 wave mesh of
80,000 triangles built ten times with the median split and ten with the
binned SAH, edges identified each time; 100,000 rays cast down onto it;
100,000 box queries over it; 10,000 sphere shape casts onto it.
| phase | aephysics | Box3D |
|---|---|---|
| 10 builds, median split (80,000 triangles) | 221 ms | 129 |
| 10 builds, binned SAH | 335 | 256 |
| 100,000 ray casts | 20.8 | 10.2 |
| 100,000 box queries | 70.4 | 39.5 |
| 10,000 shape casts | 24.7 | 14.3 |
The same trees come out: 43,135 nodes of height 16 from the median split and 48,063 of height 16 from the SAH on both; every ray and cast hits on both with equal sums; the box query reports 2,218,859 triangles here against 2,218,676 there (0.008% more, boundary cases of the reference's SIMD box-triangle test against the scalar one -- false positives the query permits). The build is 1.2-1.5x, with the welding map and the edge map through core's LongMap; the traversals are 1.7-2x, the reference's SIMD box tests against scalar ones on 48-byte double boxes, the same gap the dynamic tree's ray cast showed. With the slab test in the traversal (aephysics#11) the 100,000 rays went from 11.7 ms to 8.9 in one run on the current toolchain (the table's 20.8 is from an older one; the reference was not run again), the same hits and sum. The sphere shape casts take a closed form for a triangle against a point (Real-Time Collision Detection 5.1.5) in place of GJK since then: 22.4 ms to 11.6, under the reference's 14.3, the same hits and sum.
The builds since (2026-10-05): the tree's recursion carried each child's height back in a 4-byte heap block, two a node, and the binned SAH allocated its bins on every call; the heights now come back with the node index and the bins are made once a build. The SAH bins all three axes in one pass over the primitives rather than one pass an axis. And the edge identification pairs edges by bucketing them under their lower vertex (a vertex has a handful) where it hashed 240,000 keys into a LongMap twice each: 8 ms of a 27 ms build to 4.5. The ten median builds went from 212-232 ms to 156-186 (the reference: 129-134), the ten SAH builds from 323-344 to 239-256 (the reference: 256-258), and every mesh's block, hash included, is the same byte for byte.
bench/height_field.ae and bench/height_field_box3d.c: a 512 x 512
wave field of 522,242 triangles with a hole every sixteenth cell, built
ten times; 100,000 rays cast down onto it at slight angles; 100,000 box
queries over it; 10,000 sphere shape casts onto it; 10,000 sphere
overlaps at its surface.
| phase | aephysics | Box3D |
|---|---|---|
| 10 builds (522,242 triangles) | 200 ms | 131 |
| 100,000 ray casts | 16.0 | 11.3 |
| 100,000 box queries | 8.3 | 9.0 |
| 10,000 shape casts | 49.5 | 23.5 |
| 10,000 overlaps | 3.7 | 2.0 |
The same fields come out: every ray hits on both (94,058, the sum of hit heights within 0.1%, the quantised heights held as doubles here and floats there), every shape cast hits on both with equal fraction sums, the overlaps agree (3,160), the box query reports 2,171,942 triangles here against 2,171,914 there (0.001% more, boundary cases of the cell bounds test). The build is 1.5x, the quantisation and the edge flags of half a million triangles; the ray walk 1.4x; the box query at parity (no SIMD in the reference's); the shape cast 2.1x, the GJK cast per straddled cell on top of distance's 1.3-1.5x; the overlap 1.85x.
The field now packs as the reference's does (2026-10-05): a 16-bit
quantum per grid point, read through native.load_u16, and a byte per
cell for its material and per triangle for its edge flags (Aether's
byte[]). 4,182,176 bytes became 1,307,808 (the reference: 1,307,760),
with the same hits and sums. The 10 builds went from 148-158 ms to
140-146. Rays and queries are unchanged once the quantum is a single
16-bit load: read as two bytes and a shift, the queries were 6% slower.
HEIGHT_FIELD_VERSION moved with the layout. Meshes keep 32-bit
material indices, which go past the reference's 255.
The shape casts and overlaps meet each straddled cell's triangles with a sphere's centre, and that pair takes a closed form (Real-Time Collision Detection 5.1.5) in place of GJK since: on the current toolchain the 10,000 casts went from 42.3 ms to 20.1 (the reference's 23.5) and the overlaps from 3.5 to 2.0 (its 2.0), the same hits and sums.
bench/shape.ae and bench/shape_box3d.c: 1,000,000 rays at a unit
sphere and 1,000,000 at a capsule from a fan of origins, a third of them
missing; 100,000 casts of a sphere at a capsule under a transform;
100,000 overlaps of a sphere with it; 100,000 mover planes against it;
100,000 capsule masses.
| phase | aephysics | Box3D |
|---|---|---|
| 1,000,000 sphere ray casts | 33.2 ms | 31.5 |
| 1,000,000 capsule ray casts | 41.0 | 49.7 |
| 100,000 shape casts | 44.6 | 18.0 |
| 100,000 overlaps | 12.2 | 5.9 |
| 100,000 mover planes | 6.9 | 4.8 |
| 100,000 capsule masses | 4.5 | 4.3 |
The same answers: the ray hits agree to one boundary case in a million
(349,452 sphere hits here against 349,451, the same 595,654 on the
capsule) with equal fraction sums, the casts hit the same 89,582 times
with equal sums, the overlaps (51,518), the mover's planes (45,071) and
the masses agree. The sphere ray and the masses are at parity, the
capsule ray is 0.8x (the reference's float division by the axis length
against our double), the mover 1.45x. The sphere-against-capsule cast
is 2.5x and the overlap 2x: both are one GJK call on proxies with
radii, where the box-against-box work of the distance layer sat at
1.3-1.5x; the radius handling in distance.shape_cast and
shape_distance is the place to profile (aephysics#9).
Since aephysics#9's fix, proxies of one or two points (spheres' centres,
capsules' segments) take a closed form (math.segment_distance) in place
of GJK, the cache written as GJK leaves it for the time of impact. On the
current toolchain, same run, same hits and sums: the 100,000 shape casts
went from 32.5 ms to 18.6 (the reference's 18.0) and the overlaps from
13.7 to 8.6 (1.46x the reference's 5.9). A test holds the closed form to
GJK on 2,000 pairs to 1e-6.
bench/compound.ae and bench/compound_box3d.c: a compound of 2,000
children (1,000 spheres, 500 capsules and 500 instances of one box hull
on a 20 x 10 x 10 grid, two materials) built ten times; 100,000 rays
through it; 100,000 box queries over it; 10,000 sphere shape casts down
through it; 10,000 overlaps and 10,000 mover planes under a transform.
| phase | aephysics | Box3D |
|---|---|---|
| 10 builds (2,000 children) | 9.9 ms | 14.3 |
| 100,000 ray casts | 75.4 | 25.7 |
| 100,000 box queries | 8.7 | 6.5 |
| 10,000 shape casts | 10.7 | 5.0 |
| 10,000 overlaps | 1.5 | 0.9 |
| 10,000 mover planes | 1.2 | 0.9 |
The same answers: 50,450 ray hits with equal sums of fraction and child
index, 777,892 query visits, 5,098 cast hits, 3,278 overlaps, 5,090
mover planes with equal offset sums. The build is faster here (0.7x;
the content maps are core's LongMap on the hull's and the mesh's own
hash, the reference rehashes every block through a generic table); the
queries, overlaps and movers 1.3-1.6x; the shape cast 2.1x (issue #9's
radius GJK). The ray cast is 2.9x: a probe with a visitor that does
nothing shows the time in dynamic_tree.tree_ray_cast itself (77 ms
for 520,000 leaf visits), not in the children -- the tree layer showed
the same cast at 1.9x on a sparser scene, the reference's SIMD slab
test against scalar doubles. The traversal is the place to profile
(aephysics#11). On the current toolchain the rays were down to 39.5 ms,
and the slab test in the traversal takes them to 27.3 (the shape casts
7.6 to 7.0) with the same hits and sums; the reference was not run
again. The compound is 379 KB here against 231 KB there: the
tree's nodes and proxies are our wider doubles and longs, and the
block carries the traversal stack.
bench/mover.ae and bench/mover_box3d.c: 1,000,000 solves of a
target against six planes tilted around it (a floor, four walls leaning
in, a soft ceiling), each followed by a velocity clip.
| phase | aephysics | Box3D |
|---|---|---|
| 1,000,000 plane solves and clips | 64.6 ms | 73.2 |
The same answers: equal sums of the deltas and of the clipped velocities, 1,662,623 iterations here against 1,662,624 there (one convergence test on the float's side of the slop). The solver runs at 0.9x: the reference reads its planes through a pointer per pass where the loop here indexes the array.
bench/broad_phase.ae: 10,000 dynamic unit boxes on a jittered 25 x 16
x 25 grid at 1.4 spacing over a static ground, the first update finding
every pair, then 20 steps in which every box drifts a little and only
the new pairs come out, each update's keys adopted into the pair set.
The reference finds its pairs inside its world (broad_phase.c filters
through the shapes and creates the contacts itself), so it has no
free-standing counterpart; its pair update is measured against ours
through the world benchmarks once the world steps.
| phase | aephysics |
|---|---|
| 10,000 proxies created | 3.5 ms |
| first update (2,900 pairs) | 2.2 |
| 20 steps of 10,000 moves and an update (757 new pairs) | 91 |
A step is 4.6 ms, most of it the 10,000 proxy moves (a leaf removed and re-inserted each); the update itself walks only the sibling pairs a moved node touched. The test checks every update against a brute force over the boxes.
The hash under the pair set and every map (core's key_hash) was
rewritten during this layer: the previous 64-bit mixer multiplied
signed longs, which is undefined in the C underneath, and gcc at -O2
made two inlined copies of it disagree, so a key stored by one copy was
not found by the other. The new mixer stays in 32-bit products; the
mesh builds above moved from 199 to 221 ms and 316 to 335 ms with it.
bench/mesh_contact.ae: a box, a sphere and a capsule each dragged
100,000 steps across a 100 x 100 wave mesh, riding a hundredth inside
the surface with a small move each step, the manifolds computed every
step. The reference's mesh contact lives inside its world (it needs a
contact, a worker context and the world's material callbacks), so it
has no free-standing counterpart; it is measured against ours through
the world benchmarks once the world steps.
| shape, 100,000 steps | aephysics | per step |
|---|---|---|
| box (0.8 wide): 876,526 clusters, 3.17 M points, 910,432 cache hits | 322 ms | 3.2 us |
| sphere: 127,842 clusters, 129,088 points | 69 ms | 0.7 us |
| capsule: 797,124 clusters, 1.35 M points | 371 ms | 3.7 us |
The wave's cells are half the box's width, so a box straddles several triangles of different normals and the clusters stay many (the cluster threshold is cos 5 degrees); the far clip points of a hull face are kept as speculative ones, as the reference keeps them. The test checks the seams (a sphere on an interior edge or vertex gives one point), the cache's persistence and the cull.
bench/dynamics.ae and bench/dynamics_box3d.c: ten rounds of a world
with 5,000 dynamic bodies (a box hull and an offset sphere each, the
mass computed from both) and 500 static ones, the mass summed, every
dynamic body's transform set once (its shapes' bounds and proxies
updated), then the world destroyed; then 5,000 cubes resting on a slab
through eleven collide passes with no solve (the reference's step with
a zero time step does exactly that: the pairs, the narrow phase, the
state changes): the first begins every contact, the rest recycle them;
then those cubes chained by 4,999 revolute joints (each created into the
constraint graph and linked into the islands, which merge into one),
then every joint destroyed; then 2,500 sensor spheres over 2,500 static
boxes through eleven sensor passes (each with the pairs and the narrow
phase before it, as the reference's zero-time step runs them).
| phase | aephysics | Box3D |
|---|---|---|
| 5,500 bodies and 10,500 shapes created, 10 rounds | 112 ms | 120 |
| 5,000 transforms set, 10 rounds | 39 | 55 |
| the world destroyed, 10 rounds | 5.6 | 4.7 |
| first collide: 5,000 contacts begun, islands linked | 7.2 | 4.6 |
| 10 recycling collides | 4.0 | 2.5 |
| 4,999 revolute joints created, 5,000 islands merged into one | 2.9 | 5.7 |
| the joints destroyed | 0.23 | 0.11 |
| first sensor pass: 2,500 begin events | 1.4 | 1.0 |
| 10 more sensor passes | 8.7 | 7.0 |
The same mass sum (53,272.5), the same 5,000 contacts and 5,000 islands, 4,999 joints and one island, 2,500 begin events. Creation is at parity: the reference's hull database hashes every hull shape's bytes and refcounts them, which this layer did not do then (a hull shape kept the pointer it was given; the database came with #147, below). The transforms are 0.7x: the reference's b3Body_SetTransform also walks the body's joints, which arrive with the joint layer. The collide passes are 1.7-1.8x: each contact is reached through its id into a 300-byte struct here where the reference's narrow phase prefetches the next contact and packs its state; the first pass also computes 5,000 hull-hull manifolds (the distance layer's 1.3-1.5x) and links 5,000 islands. The wide contact solver will read these arrays, so this is the place to come back to with the solver's profile. Joint creation is 0.5x: the reference's b3CreateJoint records the call for its replay and validates the joint definition's cookie; the destruction is 2x on a tenth of a millisecond. The sensor passes are 1.2-1.4x: each pass queries the three trees per sensor and runs GJK on every candidate, the distance layer's gap.
With the hull database (#147, 2026-10-09, Aether 0.795.0, three runs each, the same session): every hull shape now clones its hull into the world on its first use and finds it by hash and bytes after, as the reference does. Creation went from 96.2-97.4 ms to 97.0-99.2, against the reference's 120.5-123.9: still 0.8x of its time, now for the same work. The other phases did not move.
Re-measured on 2026-10-05 (main b47a270, Aether 0.779.0), three runs each, the same session:
| phase | aephysics | Box3D |
|---|---|---|
| 5,500 bodies and 10,500 shapes created, 10 rounds | 106-109 ms | 120-122 |
| 5,000 transforms set, 10 rounds | 26.5-27.3 | 54.3-54.9 |
| the world destroyed, 10 rounds | 7.9-9.0 | 5.2-5.4 |
| first collide | 4.57-5.21 | 4.61-4.65 |
| 10 recycling collides | 2.46-2.88 | 2.15-2.35 |
| 4,999 revolute joints created | 3.19-3.46 | 5.57-5.82 |
| the joints destroyed | 0.21-0.26 | 0.10-0.16 |
| first sensor pass | 0.99-1.01 | 1.03-1.27 |
| 10 more sensor passes | 5.25-5.42 | 6.99-7.49 |
The collide passes are at parity on the first pass and within 10-20% on the recycling (#17 closed). The prefetching (#79, #83), the SAT changes (#87, #94, #101) and #111's union of the contact's caches got them there. The sensor passes are now ahead. The world's destruction frees 16 MB in 5,088 blocks (an island's body list each, then the large arrays); timed section by section, it is the freeing of the double-precision records' memory, with no one culprit.
bench/contact_solver.ae (ours alone: the reference has no solve
without its step, which integrates too; the step's pair comes with the
world layer): 5,000 cubes resting on a slab and 1,000 stacked in pairs,
collided once, then ten steps of the passes as the solver runs them:
the colours' constraints prepared, warm started, four sub-steps of a
biased solve and four relaxes (the relax carries the friction, twist
and rolling), the restitution pass and the impulses stored.
| pass, 10 steps of 6,000 contacts | aephysics |
|---|---|
| prepare | 7.0 ms |
| warm start | 1.9 |
| 4 biased solves | 20.1 |
| 4 relaxes (with friction) | 27.6 |
| restitution | 0.13 |
| store | 2.5 |
About 85 ns per contact per biased solve and 115 ns with friction, on one core, scalar. The reference solves its convex contacts eight at a time in SIMD lanes (b3ContactConstraintWide); that path is the layer to add and measure once the step exists to compare against.
bench/joint_solver.ae (ours alone, as the contact solver's): 5,000
cubes in a row chained by 4,999 joints of one kind, the row given a
sideways velocity gradient, ten steps of the passes: prepared, warm
started, four biased sub-step solves and four relaxes.
| pass, 10 steps of 4,999 joints | revolute (limited) | spherical (cone and twist) | weld | prismatic (limited, sprung) |
|---|---|---|---|---|
| prepare | 4.1 ms | 3.9 | 2.8 | 2.9 |
| warm start | 1.0 | 1.0 | 0.9 | 1.5 |
| 4 biased solves | 24.7 | 21.5 | 22.0 | 19.2 |
| 4 relaxes | 25.2 | 21.7 | 16.0 | 13.6 |
About 0.5 to 0.6 microseconds per joint per solve, on one core. The reference solves joints the same way (scalar, per graph colour); its numbers come with the step's pair.
bench/solver.ae and bench/solver_box3d.c: the step end to end (the
pairs, the narrow phase, the Soft Step) against the reference's
b3World_Step, one thread, 120 steps of 1/60 with four sub-steps: 5,000
cubes dropped from a metre onto a slab in a 100 by 50 grid (they land,
slide and settle), and 100 stacks of 10 cubes (dynamic pairs in every
colour).
| scene, 120 steps | scalar | lanes in Aether | native lanes | Box3D |
|---|---|---|---|---|
| 5,000 cubes falling onto a slab | 249 ms | 162 | 162 | 161 |
| 100 stacks of 10 cubes | 50 | 35 | 36 | 28 |
The same height sums (2,499.65 and 4,983.87), every body asleep at the
end, the same contact counts (5,000 and 1,000), in every column. The
convex contacts three ways (issue #22's measure): solved one at a time
through contact_solver (scalar; 308 and 61 ms before the build
flags); four at a time through
contact_solver_wide's lanes in Aether; and through the same lanes in
aephysics_native.c, GCC vector code.
The two lane columns were 230 and 187 until the module's lanes became
the language's f32x4 (std.lanes, Aether 0.706, #46): four doubles in a
record left to the C compiler to vectorise became four floats in a
register, the constraint became one single-precision record instead of
the module's doubles and a float copy for the solve, and with it went
the pack every sub-step and the unpack before every store. The Aether
lanes are now the native ones' equal on the falling grid and their
better on the stacks, and they compute the same record bit for bit
(tests/test_contact_solver_wide: "native vs lanes 0"), so the wide
path is the Aether one and the native vector code goes.
On the falling grid the port is now the reference's equal. The stacks are the solver's remaining 1.25x.
bench/physics_world.ae and bench/physics_world_box3d.c: the
reference's own benchmark scenes (its shared/benchmarks.c) stepped by
our world and by b3World_Step, one thread, 1/60 with four sub-steps,
sleeping off as the reference runs them.
| scene | aephysics per step | Box3D per step |
|---|---|---|
| large pyramid: 5,050 cubes on a base of 100, 200 steps | 11.5 ms | 9.5 |
| many pyramids: 196 pyramids of base 10, 10,780 cubes, 100 steps | 27.2 | 20.7 |
| joint grid: 10,000 spheres on 19,800 spherical joints, 100 steps | 13.1 | 10.8 |
The pyramids were 12.5 and 28.4 against 8.9 and 19.3 before the wide constraint became one single-precision record in the module's own lanes (#46): 1.40x and 1.47x to 1.22x and 1.31x.
The reference pin moved from f555ee4 to e77352c (#86). Upstream's
optimization commits since the old pin (#161, #168, #170) and its AVX2
dispatch (#174, picked at run time when the processor has it) make main
much faster. aephysics has ported every behaviour change and the
optimizations that measured faster here (#83-#94).
All three engines ran back to back, one thread, all three scenes in one process each, two rounds. That mode inflates the later scenes for every engine compared with running each scene alone, so read across the rows, not against the table above:
| scene | aephysics | Box3D f555ee4 |
Box3D e77352c |
|---|---|---|---|
| large pyramid | 9.84-10.23 ms | 9.09-9.34 | 5.38-5.69 |
| many pyramids | 32.2-37.4 | 27.2-29.6 | 12.5-13.3 |
| joint grid | 27.1-30.6 | 21.0-21.6 | 14.4-15.5 |
Box3D's large pyramid stage by stage, old pin against main. The gain is the eight-wide (AVX2) contact stages:
| stage | f555ee4 |
e77352c |
|---|---|---|
| collide | 1.06 ms | 0.80 |
| prepare | 1.26 | 0.60 |
| warm start | 1.17 | 0.55 |
| solve | 1.76 | 0.92 |
| relax | 2.24 | 1.02 |
| store | 0.48 | 0.38 |
Matching that needs eight float lanes: Aether 0.788 has them (aether#2428), and the solver uses them (below, "Eight lanes").
Aether 0.788 has f32x8 (aether#2428) and the wide contact solver is
written in it: eight contacts to a slot, one 256-bit register a lane
group when built with AVX2 (AETHER_AEPHYSICS_CFLAGS=-mavx2), two
four-lane halves otherwise. Every lane computes what it computed at four,
so the checksums are the same bits at either width and either build.
The reference's three scenes, interleaved, one thread, the reference at
e77352c (its AVX2 path picked at run time):
| scene | Box3D e77352c |
aephysics, four lanes | aephysics, eight lanes, AVX2 |
|---|---|---|---|
| large pyramid | 5.86-6.45 ms | 10.76-11.57 | 9.73-9.93 |
| many pyramids | 15.1-17.7 | 26.5-28.1 | 23.8-25.3 |
| joint grid | 11.1-11.3 | 12.5-13.3 | 12.2-12.7 |
Without AVX2 the eight-lane build steps as the four-lane one did, as the lanes promise. With it the large pyramid's contact stages, against the reference's:
| stage | four lanes | eight lanes, AVX2 | Box3D e77352c |
|---|---|---|---|
| prepare | 1.28 ms | 1.17 | 0.64 |
| warm start | 1.04 | 1.06 | 0.60 |
| solve | 1.97 | 1.52 | 0.98 |
| relax | 2.39 | 1.63 | 1.06 |
| store | 0.66 | 0.70 | 0.43 |
The solve and relax gain a quarter and a third; the warm start, nearly all gather and scatter, nothing. What is left is the bodies' states: ours are doubles, converted lane by lane into the floats the lanes hold and back, where the reference's are floats it loads and transposes (#95).
On Windows, before Aether 0.793, the AVX2 build needed GCC 16: MinGW GCC 15.2 spilled 256-bit values with aligned moves to a stack it could not align to 32 bytes, and the program faulted (aether#2476). Aether 0.793 fixes that; MinGW GCC 15.2 now builds and runs it with the same checksums.
The solver's body states (velocities, the step's change of position and
rotation) are single precision now, laid out as the reference's
b3BodyState: 64 bytes a body, one cache line, where the doubles took
136 over three. The lanes gather them without a conversion and scatter
them back the same way; the scalar solvers and the integration widen to
double for their arithmetic and narrow on the store, as the reference
computes in float throughout. Positions stay double.
Interleaved, one thread, the same machine minutes apart:
| scene, ms per step | doubles, AVX2 | floats, AVX2 | doubles | floats |
|---|---|---|---|---|
| large pyramid | 8.18-8.69 | 7.71-8.00 | 8.78-8.99 | 8.24-8.76 |
| many pyramids | 19.9-20.9 | 18.8-19.7 | 22.0-24.3 | 20.0-22.3 |
| joint grid (pinned, best of 8) | 16.59 | 16.57 | 17.48 | 17.13 |
The large pyramid's stages in AVX2: warm start 0.91-1.00 to 0.76-0.78 ms, solve 1.43-1.46 to 1.20-1.22, relax 1.57-1.62 to 1.30-1.34. The joint grid is level: its joints widen what they read and narrow what they write.
The results change, as rounding a velocity to float each sub-step must:
the falling ragdolls sleep at step 298 (307 in doubles), the soak's
fastest body and overlaps are unchanged to the printed digits, and every
check holds. tests/golden/determinism_trace.txt is the new record (#125).
The stored manifold and the contact's cached values are single precision
now, the reference's b3ManifoldPoint and b3Manifold byte for byte
(56 and 268 bytes, from 104 and 504) and its cached rotations, relative
pose, friction, restitution, rolling resistance and tangent velocity
(the contact 344 bytes to 256; the reference's is 216). The narrow phase
still computes in double and narrows once as it stores; the recycling,
the prepare and the store, which read and write these records for every
contact every step, move half the bytes, and the lanes load them with no
conversion.
Interleaved, AVX2 builds, one thread, ms per step (a noisy machine; read across a row):
| scene | main (float states) | + float manifolds | + float contacts |
|---|---|---|---|
| large pyramid | 9.83-10.43 | 8.60-11.40 | 8.76-10.36 |
| many pyramids | 25.1-26.3 | 22.1-23.7 | 21.1-22.7 |
| collide, many pyramids | 5.71-6.38 | 4.59-4.94 | 4.04-4.47 |
| prepare, many pyramids | 3.64-3.81 | 2.75-2.87 | 2.45-2.84 |
| store, many pyramids | 1.68-1.86 | 1.25-1.38 | 1.23-1.38 |
| joint grid (pinned, best of 8) | 12.25 | 12.19 | -- |
The collide phase on the many pyramids is a quarter shorter, the prepare a quarter, the store a quarter; the joint grid, which has no contacts, is level. The soak test against the reference stays as close as it was (seed 1's farthest body 6.89911 m against 6.89907), and the determinism golden is regenerated for the new rounding.
BodySim keeps its world positions in double (the origin's transform,
the centre of mass and the time of impact's start centre) and the rest
in single precision as the reference's b3BodySim does: the inverse
inertias, the local centre, the extents, the forces, the dampings and the
start rotation. It shrinks from 424 bytes to 272 (the reference's is 216,
its positions in float), and what the recycling and the prepares read
comes first.
A quiet machine, AVX2 builds, interleaved, ms per step:
| scene | main | float body sims | Box3D e77352c |
|---|---|---|---|
| large pyramid | 7.29-7.37 | 7.04-7.13 | 5.54-5.74 |
| many pyramids | 16.5-16.9 | 16.1-16.3 (15.6 best) | 13.0-13.8 |
| joint grid | 11.66-11.77 | 11.68-11.98 | 10.63-10.76 |
Collide is 7% shorter and the prepare 10%; the velocity integration a few percent longer (it widens the inertias for the gyroscopic solve, the reference's algorithm in double). After this morning's #124, #126, #127 and this, the reference's three scenes stand at 1.25x, 1.2x and 1.1x its AVX2 build, from 1.8x, 1.8x and 1.2x.
Two pieces of e77352c's collide pass were not ported until now, and
both are about what a contact reaches without going through anything
else:
- A contact keeps its bodies' sims as indices (the reference's
encodedBodySimA/B): the awake set's index for an awake body, -(index + 2) for a static one,NULL_INDEXfor any other. They are kept current wherever a sim moves: a contact made or put into the graph, a body woken or put to sleep, a sim swapped into the place of a destroyed or transferred body's. The collide pass and the contact prepares read them where they used to go from the shape to the body, its solver set and its sim, and rewrite the indices every step. A sleeping body's sim, which a non-touching contact may still need, is found through the body, as the reference'sb3ResolveContactBodySimdoes.validate_solver_setschecks every contact's indices against its bodies (the reference'sb3ValidateSolverSets), andtest_encoded_simsintests/test_edge_cases.aeruns every path that moves a sim with that check after each step: leaving out the refresh on a destroy, or on a wake, fails it. - The shapes' fat boxes live in the world's
fat_aabbs, by shape id (the reference'sfatAABBs), not in the shapes: the first test of every contact, whether its proxies still overlap, reads two entries of a dense array instead of two shapes, and a recycled contact never touches its shapes at all.
Nothing is computed differently: the golden trace and every checksum are the same. A quiet machine, pinned to one core, 5 runs each rotated, ms per step (range and median):
| scene | main | this change | Box3D e77352c, AVX2 |
|---|---|---|---|
| large pyramid | 6.50-7.29 (6.81) | 6.27-8.85 (6.47) | 5.28-5.80 (5.39) |
| collide | 1.00-1.08 | 0.81-0.85 | 0.81-0.94 |
| many pyramids | 14.66-17.10 (14.86) | 13.43-15.02 (14.34) | 11.73-13.83 (12.40) |
| collide | 2.49-2.97 | 1.81-1.97 | 1.97-2.36 |
| joint grid | 10.62-10.91 | 10.76-11.00 | 10.25-10.39 |
The collide pass is level with the reference's on the large pyramid and
ahead of it on the many pyramids. The mixed pile (bench/soak.ae, awake,
6 runs): 2.58-2.64 ms per step to 2.52-2.55 against the reference's
1.99-2.02, its collide 0.59-0.60 to 0.52-0.53 against 0.42-0.43; more of
that pile's contacts compute their manifolds afresh every step, and the
narrow phase of its mixed pairings is in double. The scenes now stand at
1.2x, 1.15x and 1.05x the reference, the mixed pile at 1.26x. What is left
is in the lanes (warm start, solve, relax: the transpose of aether#2550),
the prepare, and the pairs (the tree in double, kept for large worlds).
The joint grid's solve and relax were already ahead of the reference's; its gap was the warm start and the prepare. Two changes, neither of which moves a bit of any result:
JointSimkeeps the bodies' world inertias in float, as the body sims hold them, and the joint solvers widen them where they read them. They were floats widened at the prepare, so nothing rounds differently, and the record shrinks from 912 bytes to 840.- The spherical joint's warm start adds its angular impulses (the spring's, the motor's, the cone's and the twist limits') only when one of those parts is on. Each is zeroed when its part is off, so the sum it skips is exactly zero; a plain point joint, the grid's, no longer reads the 120 bytes of impulses and axes behind it.
Pinned to one core, 8 runs each interleaved, ms per step:
| min | median | |
|---|---|---|
| main | 11.23 | 11.91 |
| this change | 10.78 | 10.90 |
Box3D e77352c, AVX2 |
10.21 | 10.34 |
Stage by stage, three rounds:
| stage | main | this change | Box3D |
|---|---|---|---|
| prepare joints | 1.10-1.23 | 0.93-0.95 | 0.51-0.58 |
| integrate velocities | 1.21-1.25 | 1.20-1.22 | 1.09 |
| warm start | 1.97-2.23 | 1.37-1.40 | 1.17-1.26 |
| solve | 3.23-3.48 | 3.14-3.19 | 3.32-3.34 |
| integrate positions | 0.30-0.31 | 0.30-0.31 | 0.20 |
| relax | 3.12-3.36 | 3.06-3.11 | 3.22-3.23 |
The grid stands at 1.05x the reference, from 1.1x. Two more things were tried and dropped. Keeping the spherical joint's frames, impulses, axes and masses in float, as the reference does (a 664-byte record), took the warm start only to 1.5-1.6 ms and cost the solve and the relax 0.3 ms each: the solve is bound by its chain from the impulse to the velocities, and that chain gains a narrowing and a widening every iteration. Computing the limits' axes only for a joint with a limit gained nothing, since GCC already sinks them. What remains (the prepare, the two integrations, a fifth of the warm start) is the arithmetic in double over float records, each read widened and each store narrowed, where the reference computes in float throughout; a line-level sample of the warm start puts a fifth of it in those conversions.
The joint grid has no contacts, so the eight-wide lanes don't explain its
gap. It is the scalar spherical joint, the same code as the reference's.
Each scene run alone (AEPHYSICS_BENCH_SCENE=grid), one thread:
| stage | aephysics before | aephysics after | Box3D e77352c |
|---|---|---|---|
| prepare joints | 1.21-1.42 ms | 1.27-1.49 | 0.61 |
| integrate velocities | 1.25 | 1.25 | 1.15 |
| warm start | 2.09-2.95 | 2.04-2.39 | 1.31 |
| solve | 4.36-4.98 | 3.90-4.14 | 3.33 |
| relax | 4.21-4.88 | 3.88-4.14 | 3.22 |
| step | 14.4-17.0 | 13.6-14.7 | 10.4-10.6 |
"After" is #107's layout. The solve and relax were reading about eleven
cache lines per joint, because what they need was spread through the
records. JointSim kept the local frames (read only by the prepare)
between its header and the masses, and SphericalJoint kept the enable
flags, tested first, 536 bytes in. JointSim now puts the solve's fields
first: kind, masses, inertias, softness and the joint event thresholds.
The kind's data comes next, and the frames and settings go last.
SphericalJoint puts its flags, body indices, impulse, frames and centre
delta first, then what the warm start reads. The arithmetic is unchanged
and the height sum is the same.
BodySim now starts with what every constraint prepare reads: the
transform, centres, inverse mass and world inertia, in three lines
instead of five. That took the joint prepare from 1.43-1.49 to 1.26-1.37 ms
and the many pyramids' wide prepare from 3.53-3.60 to 3.38-3.49. The
large pyramid was about even.
Then #108: the profile put a sixth of the joint grid in
point_mass_matrix, a real call (GCC didn't inline it) forming
skew(r) i skew(r) through two full 3x3 products per body. It now leaves
out the products with skew's zeros. Every surviving term is the one
mul_mm forms, added in the same order, and adding an exact zero product
changes nothing, so the matrix is the same to the bit
(tests/test_joint_solver.ae checks 2,000 random cases). Joint grid,
interleaved, 4 rounds:
| stage | before | after | Box3D e77352c |
|---|---|---|---|
| solve | 3.87-4.23 ms | 3.09-3.38 | 3.33 |
| relax | 3.87-4.22 | 3.01-3.22 | 3.22 |
| step | 13.6-14.5 | 11.5-12.7 | 10.4-10.6 |
The joint solve and relax are now level with the reference's. The warm start (1.9-2.2 ms against 1.31) and the prepare (1.2-1.4 against 0.61) are what's left.
What's left of the gap is the records' size in double precision. Our
JointSim is about 900 bytes, against the reference's float union. A
BodyState is 112 bytes against 64.
Before #107, AEPHYSICS_STAGES never reset its clocks between scenes. A
benchmark's second and third scenes reported averages over every step
since the start, so the joint grid showed a wide prepare and a store
although it has no contacts. Stage figures for the many pyramids and the
joint grid from before then (in this file and on the issues) carry the
earlier scenes' stages. The large pyramid, which runs first, was right.
AEPHYSICS_STAGES=1 times every stage a worker runs and prints the
per-step averages; the world's own profile carries the phases around them,
and bench/physics_world_box3d.c prints the reference's b3Profile, which
names the same stages. So the two columns are the same run, stage for
stage, rather than two totals. The large pyramid, one thread:
| stage | aephysics | Box3D | difference |
|---|---|---|---|
| pairs (broad phase) | 0.32 ms | 0.22 | +0.10 |
| collide (narrow phase and recycling) | 2.17 | 1.04 | +1.13 |
| prepare constraints | 2.07 | 1.26 | +0.82 |
| integrate velocities | 0.57 | 0.58 | 0.00 |
| warm start | 1.13 | 1.15 | -0.02 |
| solve (biased) | 1.92 | 1.75 | +0.17 |
| integrate positions | 0.11 | 0.11 | 0.00 |
| relax (no bias) | 2.43 | 2.24 | +0.20 |
| restitution | 0.01 | 0.01 | 0.00 |
| store impulses | 0.76 | 0.44 | +0.31 |
| step | 11.9 | 9.1 | +2.8 |
The line profile of the collide pass (scripts/profile.sh samples mapped to
source lines with addr2line) put 72% of the recycling's samples on two
lines: the first read of a contact's manifold and the first read of its
points. The arithmetic is the reference's, operation for operation; the
difference was where the manifolds were. The port allocated each contact's
manifolds from the heap one by one, so the manifolds of neighbouring
contacts were anywhere, and every pass that walks the contacts -- the
collide, the prepare, the store -- missed the cache on each. The reference
hands them out of a block allocator per manifold count
(b3AllocateManifolds), and so does the port now
(basics.BlockAllocator, a port of block_allocator.c, held by the world
behind a spinlock since the narrow phase runs on every worker).
The large pyramid, the two builds run in turn, four rounds:
| ms per step | |
|---|---|
| manifolds from the heap | 11.57, 11.34, 11.36, 11.65 |
| manifolds from blocks | 11.17, 11.19, 11.36, 11.23 |
and stage by stage, collide 1.94-2.02 to 1.55-1.72 ms, prepare 1.87-1.95 to 1.75-1.81, store 0.64-0.68 to 0.60-0.64. The same height sum; the suite passes.
A probe that never reused a freed manifold -- every allocation the next
slot of one long run, in the order the contacts were made -- measured 10.7
to 11.3 in the same rounds, so the layout has more in it than the
allocator gives: a freed slot is reused last-in first-out, which is the
reference's behaviour and scatters the order a little. The reference's own
note on the contact (todo embed single manifold) points at the rest: a
contact with one manifold carrying it inline, where the pass is already
reading.
Two stages are seven tenths of the whole difference: the collide pass (#17) and the prepare. Everything the lanes touch -- warm start, solve, relax, restitution, integrate -- is within two tenths of a millisecond of the reference, and the warm start is ahead of it. That is what #48 bought and where the port stands: the solve is the reference's equal, and what is left is the narrow phase and the work of filling the constraints.
The same height sums (167,556, 37,695 and -496,893), contact counts
(14,950 and 28,420) and joint count. One run of the pair, on a machine
whose other work moves both columns by 10-20% between runs (the joint
grid has measured 11.1 to 14.1 ms, the reference 9.1 to 11.8): the
ratios hold, 1.1x on the joint grid (no contacts: single against
double precision and what the emitted C still loses) and 1.4-1.5x on
the pyramids (14.1 and 31.6 before the native prepare, 1.6x; 24.5 and
53.0 before the wide path and the flags, 2.2-2.4x; 17.1 and 41.5
before the parallel layer's stack allocator stopped zeroing).
AEPHYSICS_BENCH_SCENE=pyramid scripts/profile.sh bench/physics_world.ae
on the large pyramid alone, and the reference's benchmark under the
same sampler (tools/sampler.c linked into its C), ms per step:
| stage | aephysics | Box3D |
|---|---|---|
| contact solve (with the gather and scatter) | 4.8 | 4.5 |
| contact prepare | 2.1 | 1.3 |
| narrow phase and recycling | 2.0 | 1.2 |
| warm start | 1.0 | 0.7 |
| unpack | 0.3 | -- |
| the solver's own stages (integrate, finalize, blocks) | 1.7 | 1.0 |
| store | 0.3 | 0.5 |
| step | 12.5 | 9.7 |
The solve is at parity: the same lanes, the same precision. The prepare is the native file's now (issue #37): the module used to write a lane at a time into its double block and the file packed the block to floats, a millisecond a step of packing on this scene; the prepare writes the floats itself, reading the module's structures through the field offsets the module measures at start, and computes in doubles as the module did, operation for operation, so the lanes are the same to the bit (test_contact_solver_wide's native-against-lanes figure is unchanged, 3.87e-5, and the determinism traces are). What is left is the prepare's own writes (a lane at a time, as the reference's), the narrow phase in doubles (issue #17) and the solver's stages.
bench/parallel.ae and bench/parallel_box3d.c: the three scenes
above by worker count, ours through WorldDef.worker_count and the
reference's through b3WorldDef.workerCount on its built-in
scheduler, sleeping off, ms per step; 1, 2, 4 and 8 workers and the
machine's 24 processors. The height sums are the same at every count
on both sides.
| scene | workers | aephysics | Box3D |
|---|---|---|---|
| large pyramid | 1 | 13.3 | 9.1 |
| 2 | 8.0 | 4.8 | |
| 4 | 4.6 | 2.9 | |
| 8 | 3.3 | 2.0 | |
| 24 | 2.9 | 1.8 | |
| many pyramids | 1 | 32.0 | 20.6 |
| 2 | 17.6 | 10.4 | |
| 4 | 10.8 | 5.3 | |
| 8 | 6.8 | 3.5 | |
| 24 | 5.6 | 2.8 | |
| joint grid | 1 | 12.2 | 11.4 |
| 2 | 6.2 | 5.6 | |
| 4 | 3.8 | 3.3 | |
| 8 | 2.4 | 2.1 | |
| 24 | 2.2 | 1.6 |
The scaling is the reference's (4.0x, 4.7x and 5.1x at eight workers
against its 4.4x, 5.8x and 5.4x) and the ratio between the columns at
eight workers is the single-thread one, so what is left is the
per-thread work, not the layer. The profile the world keeps
(physics_world.get_profile, the reference's b3Profile) says where
the step goes at eight workers on the large pyramid: constraints 2.2
ms (the stages), collide 0.5, pairs 0.3 (the broad phase, serial here
and a task in the reference), transforms 0.14, setup 0.01.
Two things the layer found on the way. A task on a fresh thread (std.worker's run_detached spawns one per call) costs 180 us a round of seven; the scheduler now makes its threads once and they wait on a semaphore, and a round is 8 us. And the stack allocator zeroed every allocation -- the constraint blocks are tens of megabytes a step -- where the reference's does not: a millisecond a step on the pyramids, gone, with the memory poisoned under the determinism scenes to show nothing reads it before writing.
bench/human.ae and bench/human_box3d.c: the reference's "rain"
benchmark (shared/benchmarks.c, its shared library linked for the
figure and the scene): a ten by ten grid of cells, each a grid mesh with
a torus on it; every 48 steps a column of cells gets three ragdolls
(twelve capsule bones, eleven joints with limits, springs and motors)
dropped from twenty metres, the columns recycled once full. 400 steps
of 1/60 with four sub-steps, one thread.
| aephysics | Box3D | |
|---|---|---|
| 400 steps, 3,700 bodies and 4,200 joints at the end | 4.61 ms per step | 4.40 |
1.05x, the closest pair yet: the step is joints and capsule contacts, which the flags of PR #24 and the lanes of PR #25 brought to parity, and the meshes' contacts, the bookkeeping of 300 figures created and destroyed in turn. Both build the same 3,700 bodies and 4,200 joints on the same steps; the resting state differs (7,208 contacts and 2,892 awake against 6,831 and 3,144, the moved bodies' height sum 24,019 against 24,187): eight figures tumbling for six seconds is chaotic, and single against double precision parts them, where the resting pyramids of physics_world agree to six digits.
Checked again on 2026-10-05, when the rain ended at 6,062 contacts against 6,831. Traced every 10 steps, the counts agree within 1% through the fall and part at the first landing (step 120: 1,700 against 1,805), when the figures hit the top of the tori. One figure dropped on one cell follows Box3D to four digits until it lands. From there, both engines split between staying on the torus and sliding off it with the drop point: over eleven offsets, Box3D stays on top three times and aephysics four. Groups of three, dropped at sixteen offsets, end with 34 to 93 contacts in Box3D and 47 to 138 here, and ours has the larger sum (1,373 against 1,074). No figure falls through a mesh in either engine. The rain's total is chaos, not a contact difference.
bench/soak.ae against bench/soak_box3d.c (2026-10-07, i7-13700K, one
thread, Box3D e77352c with its AVX2 dispatch): 2,048 bodies of every
convex kind -- boxes, spheres, capsules, hulls of twelve random points,
restitution up to 0.6, a little rolling resistance -- thrown spinning
from eight layers into a walled pen, 600 steps, the same dice drawn in
the same order in both. The reference's own benchmarks are cubes; this is
the pile a game throws.
Box3D e77352c |
aephysics (eight lanes, AVX2) | |
|---|---|---|
| sleeping, ms per step | 1.25-1.48 | 2.33-2.39 |
| awake throughout, ms per step | 2.26-2.43 | 3.49-3.93 |
| pairs | 0.08 | 0.12 |
| collide | 0.53-0.58 | 0.89-0.93 |
| solve (constraints) | 1.64-1.76 (1.42-1.52) | 2.49-2.88 (2.22-2.61) |
| transforms | 0.21-0.23 | 0.25 |
| height sum, contacts | 618.6, 9,061 | 621.9, 8,927 |
Awake, the like-for-like cost, we are 1.5-1.7x the reference, the gap in every phase: the solve's share is the body states' gather and scatter in doubles (#95), the pairs, the narrow phase of the mixed pairings and the transforms are their own (#121).
Both piles are asleep by step 500 now. Before #134, a random hull resting on an edge could rock between two faces for good, in the reference as in ours (replayed from the same state, both cycle every 400 steps to five digits): when the floor's manifold flipped to the other incident face, the corner coming down was not among the old manifold's points and was found up to 2.5 cm deep, the soft contact pushed it out, lifting the body, and it fell back the other way, the energy the impact took given back as height. Which body in a pile ended up so was chaos, so the reference's pile slept at step 500 and ours at 1,150, then 800. The face contact now takes the hull's vertices already within the speculative distance over the reference face, the corner lands speculatively, and our pile sleeps at step 500 as the reference's does; the benchmark checksums are unchanged.
tests/test_soak.ae holds the small version to account on four seeds
(64 bodies, 900 steps), and the reference's port on the same dice agrees
with it nearly to the digit: the same starting energies to the joule,
none ever gained, the fastest body 11.3546 m/s on seed 1 in both, the
deepest overlap in the fall 0.160931 m against our 0.160922 (dynamic
bodies have no continuous collision in either, so a body meeting another
at 9 m/s is a step's 15 cm in before its contact exists), the farthest
body 6.8991 m against 6.8992, at rest 0.8-4.7 mm against our 0.6-5.0,
every body asleep at the end in both.
The rest of the reference's benchmark/main.c (#141): its nine other
scenes, built by its own functions (shared/benchmarks.c, e77352c, the
release sizes) in bench/suite_box3d.c and by their port in
bench/suite.ae, each run as the reference's runner runs it: the scene's
own step before every step, the first step untimed. One thread, pinned to
a core, the better of two runs, ms per step:
| scene | aephysics | Box3D e77352c, AVX2 |
height sum, contacts (ours / reference) | |
|---|---|---|---|---|
| convex_pile: 5,120 of PEEL's 32-point convexes | 11.89 | 9.84 | 1.21x | 37,935 / 37,950; 51,767 / 51,706 |
| junkyard: 10,584 rocks swept by a kinematic cylinder | 22.69 | 21.80 | 1.04x | 85,925 / 90,716; 195,896 / 193,126 |
| large_world: a million static boxes, 100 spheres dropped | 0.0109 | 0.009 | 1.21x | 76.189 / 76.189; 380 / 380 |
| sleep: ten pyramids, one woken each step | 2.18 | 1.80 | 1.21x | 14,322.8 / 14,322.8; 5,890 / 5,890 |
| spinner: 1,500 spheres churned in a cylinder mesh | 3.46 | 3.17 | 1.09x | 19,206 / 19,059; 7,496 / 7,564 |
| trees25 | 1.45 | 1.25 | 1.16x | 43.99 / 44.48; 1,112 / 1,103 |
| trees50 | 0.543 | 0.513 | 1.06x | 45.59 / 45.60; 1,106 / 1,100 |
| trees100 | 0.310 | 0.299 | 1.04x | 45.20 / 45.33; 1,100 / 1,100 |
| washer: 8,000 cubes in a kinematic drum | 16.87 | 15.14 | 1.11x | 91,422 / 85,532; 41,649 / 43,736 |
Where nothing is chaotic the two agree: the large world and the sleep scene to the printed digit, the spinner's motor angle after 800 steps to five (-0.157365 against -0.157361). The piles that tumble (the junkyard, the washer, the spinner's spheres) part at the rounding of the first collisions and end in different heaps of the same size. The reference's trees25 warns that a mesh query filled its 256-triangle buffer; ours has no such cap, hence its nine more contacts.
The SAT runs (shared/sat_benchmark.c): 1,000 pairs of each hull pairing
placed half the speculative distance apart, the best of four runs of
twenty repeats, microseconds a query, ours / the reference's:
| pair | GJK cold | GJK warm | SAT | SAT, no inscribed-sphere bound |
|---|---|---|---|---|
| complex/complex | 0.654 / 0.618 | 0.212 / 0.196 | 1.78 / 0.954 | 16.3 / 6.08 |
| complex/rock | 0.433 / 0.411 | 0.139 / 0.129 | 1.02 / 0.645 | 5.63 / 2.76 |
| complex/cylinder | 1.13 / 1.09 | 0.380 / 0.361 | 2.78 / 1.07 | 14.9 / 6.00 |
| rock/rock | 0.240 / 0.225 | 0.080 / 0.073 | 0.560 / 0.379 | 1.99 / 1.18 |
| rock/cylinder | 0.944 / 0.842 | 0.296 / 0.273 | 1.58 / 0.738 | 5.18 / 3.02 |
| cylinder/cylinder | 1.45 / 1.41 | 0.485 / 0.474 | 4.77 / 1.41 | 13.3 / 7.48 |
GJK is within 3-12% of the reference's. The separating axis test is
1.5-3.4x: the reference's is the wide one of convex_manifold_wide.inl
(the hull's vertices as eight-lane rows, the edge candidates filtered and
sorted before any is tested), ours the four-lane edge pass of an earlier
reference. That is the convex pile's gap, and #150. Across the scenes the
pairs phase is 1.4-1.6x the reference's (2.59 against 1.63 ms on the
convex pile): the broad phase's boxes are double, where the reference
keeps float boxes rounded outward even in its large-world build (#151).
Both are taken up below.
The tree's boxes are single precision now, rounded outward from double
as the reference's large-world build rounds them: 32-byte nodes, the
world's fat boxes in float, the insertion costs and the rays measured on
the widened boxes. Measured alone, that did not move the pairs phase
(main 1.64-1.82 ms on the convex pile, the float boxes 1.68-1.77). The
profile said why: two thirds of the walk's samples were the pair set's
probe, a cache miss on every candidate in a table larger than the
caches. The reference batches its candidates and prefetches every slot
before it probes any (b3FlushCandidatePairs); so do we now, 32 at a
time, and the walk reads a leaf's shape index from the node instead of
the proxy array. The pairs phase, ms per step, the better of two pinned
runs, Box3D e77352c AVX2 and ours AVX2 alternating, one session:
| scene | Box3D | before | after |
|---|---|---|---|
| convex_pile | 1.24 | 1.82 | 1.44 |
| junkyard | 2.00 | 2.88 | 2.21 |
| washer | 1.83 | 2.65 | 2.21 |
| spinner | 0.284 | 0.403 | 0.353 |
The separating axis test reads the hulls' new single-precision rows: support searches eight vertices a pass (a hull of 16 vertices or fewer is searched one vertex at a time in double, where the lanes' reduction cost more than it saved), edge candidates culled with the stored cosines and again by the best faces' probe points, edge pairs eight a pass with the axis and separation in lanes. Every winner is measured again in double, so the golden trace did not change. The SAT runs, microseconds a query, before / after / the reference:
| pair | SAT | SAT, no inscribed-sphere bound |
|---|---|---|
| complex/complex | 1.76 / 1.60 / 0.95 | 16.2 / 12.9 / 6.01 |
| complex/rock | 1.00 / 1.04 / 0.63 | 5.57 / 5.11 / 2.73 |
| complex/cylinder | 2.77 / 2.02 / 1.06 | 14.8 / 12.8 / 5.98 |
| rock/rock | 0.545 / 0.600 / 0.375 | 1.91 / 1.93 / 1.17 |
| rock/cylinder | 1.51 / 1.34 / 0.74 | 4.88 / 4.80 / 3.01 |
| cylinder/cylinder | 4.73 / 2.58 / 1.41 | 13.0 / 10.2 / 7.42 |
(The rock rows were measured before the small-hull search went back to double; the rocks have ten vertices and take that path now.) The rest of the gap is the work around the lanes that the reference does in lanes too: the face dots, B taken into A's frame, A's edges gathered, and the reduction of a support search's lanes, which std.lanes cannot do in one step yet (aether#2603, aether#2550). #150 stays open for it.
The whole step with all of it, ms per step, the better of two runs, ours before and after (that session was noisier for both engines, so the ratio to the reference is from the table above): convex_pile 11.84 to 11.26, junkyard 28.3 to 25.0, washer 17.6 to 16.7, spinner 4.31 to 4.08, sleep 2.40 to 2.33, large_world 0.0114 to 0.0111, trees25 1.589 to 1.587, trees50 0.573 to 0.590, trees100 0.334 to 0.334. The trees scenes pay for the outward rounding: their fast shapes leave their fat boxes every step, and each new box is rounded (the inline, branch-free bit step of nextafterf); a tree query no longer rounds at all, the double box compared against the widened nodes.
The generated C of every Aether function is a plain static function,
never static inline, and gcc's -O2 keeps the small maths (a 3x3
product, a rotation, a quaternion product) out of line: a sampling
profile of the joint grid at -O2 had math_mul_mm at 15%,
math_rotate_vector at 8%, math_solve3, math_mul_quat and
math_inv_mul_quat at 4-6% each, all as calls, where the reference's
static inline headers vanish into the joint solve. Marking the math
module's functions static inline by hand in the emitted C took the
joint grid from 18.9 to 11.9 ms per step; the same effect from the
outside is gcc's inlining budget, so aether.toml builds this repo
with
-O3 --param max-inline-insns-auto=400 --param inline-unit-growth=400
(ae build reads it for the tests and the benchmarks; a program that
uses aephysics as a library sets its own). Measured on the joint grid,
one run each, ms per step: -O2 18.9; -O3 16.9; -O3 -march=native 16.2;
-O2 with the inline parameters at 200: 12.5; -O3 with them at 400:
11.2; the hand-inlined C at -O2: 11.9. The pyramids gain 10% from the
same flags (their time is the contact solver's loops, already inlined).
The compile is a third slower and the executable 1.7x larger. Turning
every double into a float in the emitted C (an experiment, not a
change) made the grid slower, 26 ms, so precision is not where the time
goes at present. An emitter-side static inline for small leaf
functions is asked of Aether in aether-lang-dev/aether#2123.