From c7be407d82300c3c2ae9dbc96c4a233a8fa1d91d Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:14:29 -0300 Subject: [PATCH 1/3] Aether 0.839.0: the two functions that take over a struct's buffers declare it @keep 0.838.0 checks a struct parameter taken by value that holds a pointer as it checks a ptr one (E0902): a function that stores its pointers past the call declares it @keep. broad_phase.copy_tree moves a tree's storage into another and snapshot.replace_pool hands a loaded id pool over; both keep their src, and now say so. No caller changed. CI pins 0.839.0, the latest release (aeb v0.330). Every module checks clean on it. --- .github/workflows/test.yaml | 18 ++++++++++-------- aephysics/broad_phase/module.ae | 2 +- aephysics/snapshot/module.ae | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 514ad53..8d337c1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,15 +7,17 @@ on: - main pull_request: -# Pin: 0.837.0, the latest release. The accessors that hand back a -# pointer into their argument declare it `@into` (aether#2990), which -# 0.836.0 checks; the lane moves (aether#2550), bit casts and reductions -# (#2603) and `@target("avx2") import` (#2607) are 0.835.0's. A -# reproducible bootstrap version: the floor this code needs, verified by -# every job below. +# Pin: 0.839.0, the latest release. The two functions that take over a +# struct's buffers declare it `@keep`, which 0.838.0 checks; the +# accessors that hand back a pointer into their argument declare it +# `@into` (aether#2990), which 0.836.0 checks; the lane moves +# (aether#2550), bit casts and reductions (#2603) and +# `@target("avx2") import` (#2607) are 0.835.0's. A reproducible +# bootstrap version: the floor this code needs, verified by every job +# below. env: - AE_PIN: 0.837.0 - AEB_REF: v0.328 + AE_PIN: 0.839.0 + AEB_REF: v0.330 jobs: test: diff --git a/aephysics/broad_phase/module.ae b/aephysics/broad_phase/module.ae index 7995683..6f73a8a 100644 --- a/aephysics/broad_phase/module.ae +++ b/aephysics/broad_phase/module.ae @@ -123,7 +123,7 @@ create_broad_phase(bp: *BroadPhase, capacity: Capacity) { bp.candidate_hashes = basics.alloc(CANDIDATE_BATCH * 4) } -copy_tree(dst: *DynamicTree, src: DynamicTree) { +copy_tree(dst: *DynamicTree, src: @keep DynamicTree) { dst.nodes = src.nodes dst.parents = src.parents dst.proxies = src.proxies diff --git a/aephysics/snapshot/module.ae b/aephysics/snapshot/module.ae index fc6e118..ea3bc2a 100644 --- a/aephysics/snapshot/module.ae +++ b/aephysics/snapshot/module.ae @@ -1011,7 +1011,7 @@ fill_world(w: *World, rd: *SnapReader) -> bool { } // The fresh world's pool given way to the loaded one. -replace_pool(dst: *IdPool, src: IdPool) { +replace_pool(dst: *IdPool, src: @keep IdPool) { basics.idpool_destroy(dst) dst.free_ids = src.free_ids dst.next_index = src.next_index From 99a463a927f3d3074ac3dcc8906099db5395b252 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:14:30 -0300 Subject: [PATCH 2/3] The documentation rewritten: a README, a guide, the architecture, testing The README was a 266-line wall: a module table with paragraph-sized cells, check counts and issue numbers in its prose, no example of using the engine, stale links, a top-level module that does not exist, and a ragdoll benchmark (0.9x) the record does not hold (it is 1.05x). - README.md: what the engine is, its features, a quick start (install, `ae add`, the C flags, a hello world), the performance against Box3D with each number linked to its run, building and testing. - docs/guide.md: using the engine: worlds, bodies, shapes, materials and filters, joints, events, queries, threads, determinism, ragdolls, snapshots and replay, debug drawing, fitting into a host, and where it goes past Box3D. Every call it names is an export; the behaviours it states were read in the code (sensor rules, filter groups, event flags, who owns which geometry). - docs/architecture.md, from design.md (a development log): the modules against the Box3D files they port, the data, a step, precision, SIMD, threads, determinism, memory, the C file, the departures from Box3D. - docs/testing.md: what scripts/test.sh runs, what each test holds the engine to, the golden trace, CI, the benchmarks and the profiler. - bench/RESULTS.md: a title, how to read the record, where it stands, and linked contents on top of the dated sections. - examples/: hello_world, events_and_queries (a chain on spherical joints, hit events, a ray and an overlap), ragdoll_and_snapshot (a snapshot loaded into a second world steps on to the same bits). scripts/test.sh now builds and runs every one, so CI keeps them true. - tests/test_contact_solver_wide.ae's header said three paths and native lanes; the native lanes are long gone and the test compares two. --- README.md | 428 ++++++++++------------ bench/RESULTS.md | 47 +++ design.md | 542 ---------------------------- docs/architecture.md | 249 +++++++++++++ docs/guide.md | 573 ++++++++++++++++++++++++++++++ docs/testing.md | 158 ++++++++ examples/README.md | 18 + examples/events_and_queries.ae | 110 ++++++ examples/hello_world.ae | 54 +++ examples/ragdoll_and_snapshot.ae | 74 ++++ scripts/test.sh | 24 +- tests/test_contact_solver_wide.ae | 20 +- 12 files changed, 1492 insertions(+), 805 deletions(-) delete mode 100644 design.md create mode 100644 docs/architecture.md create mode 100644 docs/guide.md create mode 100644 docs/testing.md create mode 100644 examples/README.md create mode 100644 examples/events_and_queries.ae create mode 100644 examples/hello_world.ae create mode 100644 examples/ragdoll_and_snapshot.ae diff --git a/README.md b/README.md index 1dff4c6..e2e196d 100644 --- a/README.md +++ b/README.md @@ -1,265 +1,193 @@ # aephysics -A rigid-body physics engine written in [Aether](https://github.com/aether-lang-org/aether), -for [ae3d](https://github.com/nicolas-maman/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`](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. - -## Modules - -| 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](https://github.com/aether-lang-dev/aephysics/issues/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 host's hooks (its allocator takes every block the engine makes, its log the engine's messages, its failure function the one hard stop: b3SetAllocator, b3SetLogFcn, b3SetAssertFcn), the content hash and the reference's djb2 `b3Hash`, the name cache (each body and shape name kept once per world under its 32-bit hash, the reference's name_cache.c, a collision taking the next free id instead of sharing the first name) (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, test_container.c and test_allocator.c among them, test_name_cache.c's CacheUnit with the reference's own hash values); `test_hash.ae` (36 checks: test_hash.c's properties on our hash, every bit and the length reaching it, families of real geometry never colliding); `test_host_api.ae` (31 checks: a host's allocator, handing out dirty memory, takes every block of a world on four workers with a ragdoll, a snapshot and a recording and gets each back; its log takes the memory part by part, adding up to the counters' byte count; the counters, capacity, contact by id, world counts, version, and `b3Hash` against the reference's own values) | -| `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](bench/RESULTS.md#parallel) | -| `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, the tree saved and loaded as bytes (b3DynamicTree_Save and _Load; every corrupt input refused); its boxes in single precision rounded outward from double, as the reference's large-world build keeps them (32-byte nodes; the costs and rays measured on the widened boxes) | done, `test_dynamic_tree.ae` (15k checks: TreeSaveLoadRoundtrip among them, the loaded tree's queries against a scan, a scale, ten corruptions refused, and centimetre boxes at 1e7 each held by its leaf and found by every query that touches it); [same tree as the reference; inserts 0.4x its time, queries 1.1x, rays 0.9x](bench/RESULTS.md#dynamic_tree) | -| `aephysics.hull` | quickhull with face merging, the half-edge hull with its mass properties, box / cylinder / cone / rock hulls and PEEL's convex (the reference's complex hull, the same 32 points), clone-and-transform with mirroring, support functions, ray cast, the 2D hull; every hull carries single-precision rows of its vertices and normals and its edges' cosines, padded to eight lanes, for the wide separating axis test | done, `test_hull.ae` (445 checks); [same hulls as the reference; sphere hulls at parity, merge-heavy cubes 2x, box hulls 3.6x (a heap block against its stack value)](bench/RESULTS.md#hull) | -| `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.1-1.2x its time, the time of impact faster](bench/RESULTS.md#distance) | -| `aephysics.manifold` | contact manifolds for sphere, capsule and hull in every pairing: the separating axis test (aephysics.sat) 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](bench/RESULTS.md#manifold) | -| `aephysics.sat` | the separating axis test between two hulls as the reference's wide one (convex_manifold_wide.inl): single precision from the hulls' rows, a support search whose vertex index rides in the low mantissa bits so one horizontal minimum finds it, edge candidates culled by the inscribed spheres and a probe point, B taken into A's frame and the edge pairs eight a pass, the lanes reduced in order; a second copy compiled for AVX2, chosen by the SIMD width | done, in `test_manifold.ae` (the 7,000-pair oracle) and `test_determinism.ae` (both widths, bit for bit); [1.11-1.47x the reference a query, from up to 2x](bench/RESULTS.md#the-references-wide-sat-the-lanes-transposed-the-width-at-run-time-2026-10-20-150-121-156) | -| `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](bench/RESULTS.md#triangle_manifold) | -| `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; the SAH build at parity, the median build 1.2-1.4x, rays and casts faster, the box query 1.5x](bench/RESULTS.md#mesh) | -| `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` (158 checks, casts against a brute force over a wave, the file roundtrip through `aephysics.files`: the definition back byte for byte, the reference's own text with CRLF lines, and every bad file refused); [same results and the same 1.3 MB as the reference; rays at parity, queries and casts faster, builds 1.1x](bench/RESULTS.md#height_field) | -| `aephysics.files` | what the reference keeps on disk: a dynamic tree saved and loaded (b3DynamicTree_Save and _Load) and a height field's definition as text (b3DumpHeightData and b3LoadHeightField) in the reference's own layout, so its files load here and these load there; apart so a program that keeps nothing on disk does not build std.fs | done, through `test_dynamic_tree.ae` and `test_height_field.ae` | -| `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; the compound as bytes and back (b3ConvertCompoundToBytes, every section, shared hull and mesh, material and child checked against the bytes) | done, `test_compound.ae` (200 checks: the three serialize tests, the same bytes for the same compound, and ten corruptions refused); [same results as the reference; builds 0.9x, rays 1.1x, box queries 1.3x](bench/RESULTS.md#compound) | -| `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` (443 checks); [same results as the reference; rays, shape casts and overlaps at or under its time](bench/RESULTS.md#shape) | -| `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](bench/RESULTS.md#mover) | -| `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, candidates checked against the pair set 32 at a time with every slot prefetched first (b3FlushCandidatePairs), the keys sorted | done, `test_broad_phase.ae` (36 checks against a brute force); [10,000 moving boxes at 4.6 ms a step](bench/RESULTS.md#broad_phase) | -| `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](bench/RESULTS.md#mesh_contact) | -| `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, the hull database (a hull shape holds the world's copy, equal hulls share one, transformed hulls baked into their own), 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` (641 checks: test_body.c, the step-free test_world.c and test_joint.c, test_shape.c's names and flags, contacts, islands, sleep, joints, sensors); [creation 0.9x, the first collide at parity, recycling 1.1-1.2x, joints 0.6x, sensors 0.75-1.0x](bench/RESULTS.md#dynamics-bookkeeping-contacts-joints-sensors) | -| `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` (50 checks: the passes by hand on a cube on a slab) | [ours alone](bench/RESULTS.md#contact_solver) | -| `aephysics.contact_solver_wide` | a colour's convex contacts eight at a time: the constraint of eight contacts as one single-precision record in lanes (`f32x8`, std.lanes -- the reference's SIMD path; one AVX2 register in its AVX2 copy or with `AETHER_AEPHYSICS_CFLAGS=-mavx2`, two four-lane halves otherwise), the same prepare, warm start, solve, restitution and store, the bodies gathered into lanes and scattered back through 8x8 register transposes; a second copy compiled for AVX2 that the solver runs where the CPU has it (basics.set_simd_width) | `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) | [the falling grid: scalar 241 ms, lanes 142, the reference 155](bench/RESULTS.md#solver) | -| `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; beyond the reference, a spherical joint's cone may be centred off frame A's z and may be an ellipse of two half-angles (the circle stays the default, bit for bit) | `test_joint_solver.ae` (181 checks: test_joint.c's accessors on every kind, each kind solved by hand) | [ours alone](bench/RESULTS.md#joint_solver) | -| `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) | [1.2-1.3x the reference's step, stage by stage](bench/RESULTS.md#stage-by-stage) | -| `aephysics.physics_world` | the world's face: the step (pairs, narrow phase, solve, sensors, events), the events read back, the settings and counters (the reference's whole `b3Counters`: byte count, tasks, manifold counts, time of impact iterations), the maximum capacity kept at each step's start, kinematic speculation (off by default, #167: a fast kinematic body's contacts reach out over its travel, so what it strikes is not found inside it), and travel speculation (off by default, #173: every contact does, and a moving dynamic body's bounds cover its next step, so a fast dynamic body such as a car does not find what it strikes inside it either), the memory part by part to the host's log, the world counts, version and CPU queries, 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` (259 checks: test_world.c whole, among it the hull database (equal hulls one world-owned copy, a caller's hull freed at once, transformed hulls baked once, every holder's release), empty and recycled worlds, every setting, the worker count set between steps, contact, hit, move and sensor events, a compound child's hit events and materials, the overflow colour pile, the explosion near and far, the bullet and sleep flags, the compound count, an enlarged proxy destroyed; test_body_query.c whole: casts, overlaps of hull proxies and turned compounds, mover planes, and the mover's time of impact against 2,000 random sweeps; test_broad_phase.c's static rebuild and its stress test, a swarm through every proxy change with the pair set checked by brute force every step; test_id.c; the world queries; a wave pile stepping alike twice) | [the reference's own benchmark scenes with the same checksums: 1.2x its AVX2 build on the large pyramid, 1.15x on the many pyramids, 1.05x on the joint grid, ours at eight lanes in AVX2 with float records (#121)](bench/RESULTS.md#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121); [the rest of its suite, nine scenes and the SAT runs, at 1.04-1.21x](bench/RESULTS.md#suite-the-references-benchmark-suite) | -| `aephysics.debug_draw` | what a host draws a world with (the reference's b3DebugDraw): callbacks for a user shape, segments, transforms, points, spheres, capsules, bounds, boxes and strings, the options, the reference's 145 named colours, material presets and graph colours; `physics_world.world_draw` walks the shapes in the drawing bounds (each coloured by its body's state as the reference picks it, or by its material's own colour) and draws what the options ask of their bodies (names, mass, sleep, each joint kind once, contacts by state or graph colour with normals, forces or features, islands); the world def's debug shape callbacks make the host's drawable for a shape when it is first drawn and give it back when the shape goes, its geometry changes (the reference keeps the stale one) or the world goes (the reference leaves those to the host), and an in-place restore keeps the drawables of the shapes still there; `recording.player_draw_frame_queries` draws a replayed frame's queries, the selected one labelled with its caller | done, `test_debug_draw.ae` (41 checks: every shape once with its colour, a drawable each made once and kept, every option counted against the world (a point per manifold point, a weld's two boxes, a label per joint), the drawing bounds and the mask culling, the drawables' lives through destroy, geometry change, restore and the world's end, a player's world and its queries drawn, the labels' number formats) | -| `aephysics.snapshot` | a world as bytes and back (the reference's world_snapshot.c): `save_world` and `load_world` keep everything a step reads (the settings, the id pools, the solver sets, bodies, shapes and their geometry once each, contacts with their manifolds and mesh caches, joints, sensors, islands, the trees and pair set, the graph's colours), so a loaded world steps on as the one it was taken from, bit for bit; `restore_world` loads one into an existing world in place, its id, workers and callbacks kept (what a replay rewinds with); a snapshot of another layout or any damage to its bytes loads no world | done, `test_snapshot.ae` (63 checks: a world of every kind of thing loaded at four moments and stepped 150 steps beside its original, the same hash and events every step; a loaded world saves to the same bytes; one worker's world loaded with four steps alike; a restore in place stepping alike and saving to the same bytes, a refused restore leaving the world empty and whole; every truncation and corruption refused, nothing leaked) | -| `aephysics.recording` | recording and replay (the reference's recording.c and recording_replay.c): a world records every call that changes it, 160 kinds, field by field, a create's record with the id it made, a state hash after every step and the geometry the calls name once each, and every world query with each visit and the caller's answer (a caller named by the query filter's id and name gets a key a viewer follows it by); a player replays it into a world of its own, restored in place so its id stays, checking every step's hash and every created id, asking every recorded query again with the recorded answers and comparing each visit, with keyframes under a memory budget for seeking, bodies in the order they were made, the queries of the frame last played with their hits, a pause before a step that makes bodies, and the recording's info; records it does not know are skipped, as a newer minor version may add some | `test_recording.ae` (152 checks: the snapshot test's scene recorded from an empty world and from mid stream with the host changing it between steps, played on one worker and several, every frame's hash as recorded; every one of the 160 calls recorded and replayed (the reference's AllOps); geometry kept once from two copies; seeking back and forth with and without keyframes and under a tight budget; the reference's QueryReplay and TaggedQuery, every query kind with answers that stop and clip them, a changed hit caught at its frame; the reference's NameRoundTrip, RollbackNames, GeometryHashCollision and ReservedHeaderBytes; a file round trip; every truncation, a skipped call, a changed id, a short record and a changed hash each caught, nothing leaked); `test_threads.ae` (four threads querying a recording world, every record whole and replayed alike) | -| `aephysics.human` | the ragdoll: twelve capsule bones on spherical joints with cone and twist limits and revolute joints with angle limits -- a person's ranges by default (shoulders that reach overhead on a cone centred off the rest, hips on an elliptical cone through 125 degrees ahead, 25 back, 45 out and 30 across, knees and elbows that never pass straight), the reference's tight ones on request -- soles that grip, and on request (`Human.ankles`, past the reference) the feet as bones of their own on ankles, an elliptical cone through 50 degrees down, 35 up and 15 aside with 15 of roll, so a crouched figure's soles lie flat, and the shins stopping at the ankle so the feet bear the figure (`human_bone_far_end` still gives the ankle), 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` (134 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, a fitted shape, each arm reaching flexion 170, abduction 160 and extension 45 degrees standing and prone, the hips' cone through a person's four ranges and short of a split, the hinges stopping at straight and folding to their range, the soles' grip, the ankles' four ranges measured through the joint and the soles flat under shins tipped 25 degrees over them, a standing figure with ankles carried by its feet and none of it by its shins, where the shins as specified took 42%) | [the reference's rain benchmark, 300 ragdolls over 400 steps, at 0.9x](bench/RESULTS.md#human) | -| `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 `Vec3` and one `Transform`. -- **Determinism kept.** The approximate atan2, cosine and sine exist for - cross-platform replay and are kept, folded with the engine's own pi. -- **SIMD in the language.** The wide contact solver and the separating axis - test are the reference's eight-lane paths in Aether's own lanes (`f32x8`, - std.lanes). Each is imported twice, plain and at `@target("avx2")`, and a - world runs the AVX2 copy where the CPU has it (`basics.set_simd_width`, - the reference's b3SetSIMDWidth): one binary runs eight lanes to a register - on AVX2 and two four-lane halves on any other x86-64 or arm64, the same - bits either way. -- **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. - -## Tests and benchmarks - -Install the released toolchain with the shared, version-pinned installer -(the same bootstrap used by selaenium): +[![Test](https://github.com/aether-lang-dev/aephysics/actions/workflows/test.yaml/badge.svg)](https://github.com/aether-lang-dev/aephysics/actions/workflows/test.yaml) + +A 3D rigid-body physics engine written in [Aether](https://github.com/aether-lang-dev/aether). + +aephysics follows the design of [Box3D](https://github.com/erincatto/box3d), +Erin Catto's engine: the Soft Step solver with sub-stepping, a +graph-coloured contact solver eight lanes wide, sleeping islands and +continuous collision. It was written module by module against Box3D's own +tests, and is measured against Box3D built on the same machine: on Box3D's +benchmark suite it runs within 4 to 21 percent of Box3D's time per step on +one thread. + +It is written in Aether rather than wrapped around a C library (one small C +file holds the thread helpers Aether lacks), so an Aether program can read, +test and change its physics in its own language. It is the physics engine +of [ae3d](https://github.com/aether-lang-dev/ae3d). + +## Features + +- **Bodies:** static, kinematic and dynamic, with damping, gravity scale, + motion locks, sleeping, bullets and continuous collision for fast bodies. +- **Shapes:** spheres, capsules, convex hulls (quickhull), triangle meshes, + height fields and baked compounds. Materials carry friction, restitution, + rolling resistance and conveyor speeds. +- **Joints:** distance, motor, filter, parallel, prismatic, revolute, + spherical, weld and wheel, with springs, motors and limits. A spherical + joint's cone can sit off its axis and be elliptical. +- **Solver:** Soft Step with sub-steps; convex contacts solved eight at a + time in Aether's SIMD lanes; an AVX2 build picked at run time when the + CPU has it. +- **Threads:** a step runs over any number of worker threads, or on a job + system the host lends it. The result is the same at any worker count. +- **Queries:** ray casts, shape casts, overlaps and the character mover, + callable from any thread between steps. +- **Events:** contacts beginning and ending, hits, sensors, bodies moving and + joints under load; pre-solve and custom filter callbacks. +- **Ragdolls:** a twelve-bone figure with a person's joint ranges, optional + ankles, and pose drive through joint springs or kinematic anchors. +- **Tooling:** snapshots that load and step on bit for bit, recording and + replay of every call with seeking, debug drawing, and host hooks for the + allocator, the log and assertions. +- **Large worlds:** positions in double precision, so a scene ten thousand + kilometres from the origin behaves as one at it. +- **Determinism:** the same scene gives the same bits on Linux, macOS and + Windows, at any worker count and SIMD width, optimized or not. CI checks + every step of five scenes against a golden trace. + +## Quick start + +Install Aether with the version-pinned installer (it puts `ae` and `aeb` in +`~/.local/bin`): ```sh curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.837.0 AEB_REF=v0.328 sh + | AE_PIN=0.839.0 AEB_REF=v0.330 sh export PATH="$HOME/.local/bin:$PATH" ``` -This installs `ae` 0.837.0, the release CI pins, and `aeb` v0.328 under -`~/.local` (`PREFIX` overrides it), using release binaries where available. -CI verifies only the pinned release; older ones are not tested (the -`@into` parameters this code declares arrived in 0.836.0, the lane moves -and `@target("avx2")` imports in 0.835.0). +Add aephysics to your project: +```sh +ae add github.com/aether-lang-dev/aephysics ``` -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/bench.sh suite # the rest of the reference's benchmark suite, its own scenes against ours -scripts/profile.sh file.ae [top] # a sampling profile on Windows (tools/sampler.c + tools/rank.ae) + +`ae` reads C flags only from your own `aether.toml`, so give your build the +one flag the engine's determinism needs, and the inlining budget its +benchmarks are measured with: + +```toml +[build] +cflags = "-O3 --param max-inline-insns-auto=400 --param inline-unit-growth=400 -ffp-contract=off" ``` -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](bench/RESULTS.md#build-flags-inlining)). -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 `f32x8` -now. `solver.set_wide_contacts(false)` runs the scalar solve instead -([what the lanes buy](bench/RESULTS.md#solver)). - -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. `physics_world.set_worker_count` -changes a world's own count between steps, clamped to the cap as the -reference's `b3World_SetWorkerCount` clamps it, and the steps after give -the same bits. [What the workers buy, against -the reference's own scaling](bench/RESULTS.md#parallel). - -A world's queries may be called from any thread at once, as the -reference's may (but not while that world steps). Every module keeps -its scratch per worker slot, where the reference keeps it on each -thread's stack, and every running thread holds a slot of its own: a -world's workers hold the world's, and any other thread claims one the -first time it asks and gives it back when it exits (64 in all, the -reference's own cap on workers). `test_threads.ae` casts rays and -spheres and overlaps boxes from four threads at once against a mesh, a -height field, a compound and hulls, and checks every answer against the -serial one. A query's visitor may also run more queries on its own -thread, an overlap checking each find's line of sight: a traversal nested -inside another of the same stacks takes stacks of its own for its length, -as each of the reference's queries has its stack on the C stack. - -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; 40 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; and its rolling -mix of spheres, capsules and boxes with rolling resistance, whose trace -folds the manifolds' impulses in, the scene of its SIMD width test, which here steps every scene at width -4 and at the AVX2 copy's 8 in one process, bit for bit; 29 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), `test_collision.ae` (the reference's -test_collision.c: boxes valid and not, a ray against a box in its twelve -cases, a hull manifold and a shape's fat bounds at 1e7 against the origin; -56 checks), `test_restitution.ae` (the reference's test_restitution.c -whole: the coefficient head on at three mass ratios and at sixteen impact -phases, a ball on a column of dead balls with propagation off and on, flat -and spinning landings, elastic drops from 40, 20 and 10 m, a cube dropped -flat four times, the threshold, a resting stack that stays put, the -published approach speed, the narrow-floor overshoot, the energy of -thirteen elastic scenes, every step's impulse balanced on drops either side -of continuous collision, and one, two and four workers hashing alike; its -numbers are Box3D's own run's to every printed digit, the few that differ -in the last digit differing between the reference's single and double -builds too; 162 checks) and `test_edge_cases.ae` (every -number a world takes refused when it is NaN, infinite or negative where -it must not be, as the reference asserts it, the world left as it was; -a world or a ragdoll of such numbers never made, a ragdoll made whole -or not at all and never made over, its calls doing nothing unspawned; -a perfect bounce holding its height over twelve bounces, a free spin -keeping its energy, a box a hundred times heavier resting on a light one, -centimetre boxes, a kilometre of ground, a capsule of no length, meshes, -hulls, height fields and compounds of indices past their vertices or -NaN numbers never made, steps of no, negative and infinite time, -geometry that cannot collide, a heavy chain, an unlimited motor, a -hinge whose limits meet, a hinge's motor pressing past its limit settling -where the reference's does, an elliptical cone stopping a swing at each -half-angle, a kinematic body's contacts made stiff on request, and a -hull rocking about a vertex on the floor coming to rest, and the -contacts' sim indices kept current through every move of a sim (and -test_world.c's five locator scenes, at one worker and four alike), and -a weld and a hinge between static and kinematic bodies surviving a -disable and an enable; 158 checks). - -A joint's limits are soft constraints, as the reference's are: driven -past one by a motor or held under a steady load, it settles past itself -by the torque over its stiffness (a 100 N m motor on a 2.6 kg capsule -leaves 1.14 degrees, as in the reference). The stiffness is the joint's -`constraint_hertz` (60 by default), which the step caps at a quarter of -the sub-step rate: at eight sub-steps, 120 hertz takes three quarters of -that excess away. A body the game drives hard into others (a kinematic -car) sinks into them while the soft contact pushes them out; -`WorldDef.enable_kinematic_static_softness` (off by default, the -reference's choice) gives its contacts the static softness, twice as -stiff, and a struck ball is out to within a centimetre four frames on -instead of three centimetres. - -One place goes past the reference on purpose. A hull resting on an edge -or vertex can rock between two of its faces, and the reference (and this -engine before) rocked it for good, in a cycle of 400 steps: the corner -about to land was on the face the contact had not picked, so it was found -a step deep when the incident face flipped, and the soft contact lifted -it back out. The face contact now also takes the hull's vertices that -already lie within the speculative distance over the reference face, so -the corner lands as a speculative point and the hull sleeps (#134). A -box resting flat has no such vertex, so the reference's stacks give the -same checksums. - -`test_soak.ae` throws seeded piles of every convex kind into a walled -pen and holds them to what a sane world does: no energy made, no body -faster than its fall, none through the walls, overlaps no deeper than a -step of speed while falling and the slop at rest, every pile asleep in -the end -- [the reference on the same dice agrees on each](bench/RESULTS.md#soak-a-mixed-pile). - -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, and -`scripts/golden_trace.sh check` (run by `scripts/test.sh`) diffs that -against `tests/golden/determinism_trace.txt` on every step of the five -scenes. CI checks it on Linux, on Linux with `-mavx2 -mfma`, on macOS -arm64 and on Windows x86-64 (MinGW), there also unoptimized (#184). The -engine's own maths (the reference's cos, sin and atan2 approximations; -`sqrtf`, `floor` and `remainder` as the only libm calls; std.math's -`sqrt`, exact at every `-O` level, where MinGW's library `sqrt` rounds -twice at `-O0`) and `-ffp-contract=off` are what make that hold: without -the flag GCC and clang fuse multiply-adds wherever the -target has FMA, and arm64 parted from Linux on the first step (#125). A -program that builds aephysics into itself must pass `-ffp-contract=off` -in its own `[build] cflags`, as `ae` reads only the root manifest's. - -## Where it is going - -[`design.md`](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. +Then drop a box on the ground (a shortened [examples/hello_world.ae](examples/hello_world.ae)): + +```aether +import aephysics.math +import aephysics.hull +import aephysics.dynamics +import aephysics.physics_world + +main() { + world_def = dynamics.default_world_def() + world_def.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + world = dynamics.create_world(&world_def) + + ground_def = dynamics.default_body_def() + ground_def.position = math.vec3(0.0, 0.0 - 10.0, 0.0) + ground = dynamics.create_body(world, &ground_def) + ground_box = hull.make_box_hull(50.0, 10.0, 50.0) + ground_shape_def = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &ground_shape_def, ground_box) + + body_def = dynamics.default_body_def() + body_def.kind = dynamics.BODY_DYNAMIC + body_def.position = math.vec3(0.0, 4.0, 0.0) + body = dynamics.create_body(world, &body_def) + cube = hull.make_box_hull(1.0, 1.0, 1.0) + shape_def = dynamics.default_shape_def() + shape_def.density = 1.0 + dynamics.create_hull_shape(body, &shape_def, cube) + + step = 0 + while step < 90 { + physics_world.step(world, 1.0 / 60.0, 4) + step = step + 1 + } + p = dynamics.body_get_position(body) + println("the cube rests at y = ${p.y}") + + dynamics.destroy_world(world) + hull.destroy_hull(ground_box) + hull.destroy_hull(cube) +} +``` + +```sh +ae build hello_world.ae -o hello_world && ./hello_world +``` + +If you know Box3D, you already know the API. Each function is Box3D's +without the `b3` prefix, in snake case. Creation and the body, shape and +joint calls are in `dynamics` (`b3Body_GetPosition` is +`dynamics.body_get_position`). Stepping, settings, events and queries are +in `physics_world` (`b3World_Step` is `physics_world.step`). + +## Documentation + +- [The guide](docs/guide.md): worlds, bodies, shapes, joints, events, + queries, threads, ragdolls, snapshots and replay. +- [Examples](examples/): the hello world above; a chain of joints with + events and queries; a ragdoll saved and replayed bit for bit. CI builds + and runs every one. +- [Architecture](docs/architecture.md): the modules, how a step runs, and + where aephysics departs from Box3D. +- [Testing](docs/testing.md): the test suite, the determinism trace, CI and + the benchmarks. +- [Benchmark record](bench/RESULTS.md): every measurement against Box3D and + Jolt, with its date and machine. + +## Performance + +Box3D's own benchmark scenes, one thread, both engines built with AVX2 on +the same machine (Box3D at commit `e77352c`), aephysics' time per step over +Box3D's: + +| scene | aephysics / Box3D | +|---|---| +| large pyramid: 5,050 boxes | 1.20x | +| many pyramids: 10,780 boxes | 1.16x | +| joint grid: 10,000 spheres on 19,800 joints | 1.05x | +| junkyard: 10,584 rocks swept by a kinematic cylinder | 1.04x | +| washer: 8,000 cubes in a kinematic drum | 1.11x | +| convex pile: 5,120 hulls of 32 points | 1.21x | +| rain: 300 ragdolls dropped on meshes | 1.05x | + +The milliseconds, the other scenes and how each was run are in the record: +[the pyramids and the grid](bench/RESULTS.md#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121), +[the rest of the suite](bench/RESULTS.md#suite-the-references-benchmark-suite) +and [the rain](bench/RESULTS.md#human). The rain was timed when the ragdoll +layer landed and the rest on 2026-10-09; the wide separating axis test and +the wide solver's transposes have narrowed the gaps since. Where nothing is +chaotic, the two engines agree to the printed digit. + +Box3D's design was chosen by measurement. On identical scenes its solver +held a 100-row pyramid at 9.6 ms a step where Jolt's collapsed at 44.5, and +given the same budget as Jolt at its stiffest it was tighter on every scene +([the bake-off](bench/RESULTS.md#the-bake-off)). + +## Building and testing + +```sh +scripts/test.sh # every module checked, every test and example built and run +scripts/golden_trace.sh check # the determinism trace against tests/golden (test.sh runs it) +scripts/fetch_references.sh # Box3D and Jolt into reference/, built (needs cmake, ninja, gcc) +scripts/bench.sh [layer|suite] # a layer, or Box3D's suite, against the reference +scripts/profile.sh file.ae # a sampling profile on Windows +``` + +CI runs the suite on Linux, on Linux with AVX2 and FMA, on macOS arm64, and +on Windows at `-O3` and `-O0`. It pins one Aether release, 0.839.0; older +ones are not tested. ## Credits -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. +The design, and the tests aephysics is held to, are Box3D's, by Erin Catto +(MIT). Jolt, by Jorrit Rouwe (MIT), is the second baseline. Neither is +redistributed here: `scripts/fetch_references.sh` fetches both, unmodified, +into an ignored `reference/`. + +aephysics is by Nicolas Maman, under the [MIT license](LICENSE). diff --git a/bench/RESULTS.md b/bench/RESULTS.md index eed74b0..6a7cba6 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -1,3 +1,50 @@ +# Benchmark record + +Every measurement aephysics has been held to, in the order it was taken, +each with its date, machine and builds. A section is a record of its day, +and is not rewritten when later work changes the numbers; a later section +says what moved. Read a ratio within one table: a table comes from one +session, and the machine's speed wanders between sessions while the ratios +do not. [docs/testing.md](../docs/testing.md#benchmarks) says how to run +each pair. + +## Where it stands + +Box3D's own benchmark scenes, one thread, both engines built with AVX2 on +the same machine, aephysics' time per step over Box3D's (`e77352c`): + +| scene | aephysics / Box3D | from | +|---|---|---| +| large pyramid: 5,050 boxes | 1.20x | [2026-10-09](#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121) | +| many pyramids: 10,780 boxes | 1.16x | [2026-10-09](#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121) | +| joint grid: 10,000 spheres on 19,800 joints | 1.05x | [2026-10-09](#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121) | +| junkyard, washer, spinner, sleep, trees, large world | 1.04x to 1.21x | [the suite](#suite-the-references-benchmark-suite) | +| convex pile: 5,120 hulls | 1.21x, then 1.19x with the wide separating axis test | [the suite](#suite-the-references-benchmark-suite), [2026-10-20](#the-references-wide-sat-the-lanes-transposed-the-width-at-run-time-2026-10-20-150-121-156) | +| rain: 300 ragdolls on meshes | 1.05x | [human](#human) | + +## Contents + +- [The bake-off](#the-bake-off): Box3D and Jolt on identical scenes, and + why aephysics follows Box3D. +- [Layer by layer](#layer-by-layer): each module against the same code in + Box3D: [dynamic_tree](#dynamic_tree), [hull](#hull), + [distance](#distance), [lanes](#lanes), [manifold](#manifold), + [triangle_manifold](#triangle_manifold), [mesh](#mesh), + [height_field](#height_field), [shape](#shape), [compound](#compound), + [mover](#mover), [broad_phase](#broad_phase), + [mesh_contact](#mesh_contact), + [dynamics](#dynamics-bookkeeping-contacts-joints-sensors), + [contact_solver](#contact_solver), [joint_solver](#joint_solver), + [solver](#solver). +- [physics_world](#physics_world): the whole step against Box3D's + benchmark scenes, and every round of work on it, dated. +- [parallel](#parallel): the step by worker count against Box3D's scaling. +- [human](#human): the ragdoll rain. +- [soak](#soak-a-mixed-pile): a mixed pile, both engines on the same dice. +- [suite](#suite-the-references-benchmark-suite): the rest of Box3D's + benchmark suite and its separating axis runs, with the rounds after. +- [Build flags](#build-flags-inlining): why `-O3` and the inlining budget. + # The bake-off The reference engines on identical scenes, on one machine, one thread, each diff --git a/design.md b/design.md deleted file mode 100644 index 442a81a..0000000 --- a/design.md +++ /dev/null @@ -1,542 +0,0 @@ -# The engine: order, measures, and where it leads - -## Why an implementation and not a binding - -An Aether program could call a C library through externs, the way ae3d -calls its own native layer. The brief is the other thing: the engine -written in Aether, so the language's programs and the agents that write -them read, test and change the physics in the language, and so the library -stands on its own (`ae build --emit=lib`). The reference engines are -fetched, unmodified, beside it as the baseline: the engine is right when it -agrees with them and fast when it keeps up with them. Which design to -follow was decided by measurement, not by reputation: `bench/RESULTS.md`. - -## Order - -Each layer lands as its own pull request on `main`, with the reference's -tests for that layer written here beside it, and nothing above it is -started until its tests pass. - -1. **math** (`math_functions`): done. 6 million checks including a million - direction pairs, the deterministic atan2/cos/sin within the original's - stated accuracy against libm. -2. **core** (as `aephysics.basics`, since a host engine has a `core` of its own and Aether binds modules by their last name): done. `bitset` over 64-bit longs, `id_pool`, `table` (the - hash set; the murmur finaliser with logical shifts written out), - `container` as `IntArray` and a stride-typed `Buffer` (Aether has no - generics: an array of structs is a block read through a typed cast), - the stack and arena allocators with the original's counting. 100,000 - checks: the Fibonacci bit set, 50,000 pair keys filled, thinned, - searched and emptied, every allocation freed. -3. **dynamic_tree** (done): the tree as its own module, Box3D's - dynamic_tree.c with the traversal stacks in the tree and validation - returning false where the reference asserts. 12,772 checks against - test_dynamic_tree.c; the same tree as the reference on the benchmark - scene (same hits, height, area ratio), insert faster, ray cast 1.9x. - Save and load came with #148 (`tree_save_bytes`, `tree_load_bytes`, - and `aephysics.files` to disk); the moved-marking is atomic, as the - reference's, since the parallel layer (14). -4. **hull** (done): quickhull as `aephysics.hull`, the builder's - pointers as indices with the intrusive lists chained through the - pools and their sentinels in extra slots, int half-edge indices, no - SOA mirrors, the box hull a heap block. 438 checks from test_hull.c - at the reference's tolerances; the same hulls on the benchmark - scenes at 1.6-2x its time. -5. **distance** (done): GJK, the shape cast and the time of impact as - `aephysics.distance`, the simplex's vertices as named fields and the - cache's index pairs as named ints. 1,143 checks: the reference's four - plus spheres and capsules at analytic distances, warm against cold - caches over a sweep of poses, witness points inside their shapes and - no axis separating more than the distance, rotating sweeps in the time - of impact, the hull's overlap and cast through it. The same results as - the reference at 1.3-1.5x its time. -6. **manifold** (done): manifold.c and convex_manifold.c as - `aephysics.manifold`, the reference's scalar SAT path, module scratch - for the clip buffers (per-worker in the parallel layer). 43,090 checks: - test_sat.c's oracle over 7,000 random pairs and test_manifold.c's hull, - sphere and capsule parts. The same manifolds as the reference; warm - cache at parity, cold SAT 1.6x. Found aether#2119 (array literals of - float expressions typed as int) on the way. -7. **triangle_manifold** (done): triangle_manifold.c as - `aephysics.triangle_manifold`, with closest_point_on_triangle and the - triangle features. 1,540 checks: the triangle parts of test_manifold.c - (the tipped cube's edge on a tilted triangle edge, the parallel pair - through the threshold, the edge sweep against the cross product, the - capsule across an edge and straddling the face) plus the sphere on - every feature, the resting cube, the back-side hysteresis and the - cache. The same manifolds as the reference within 10% on hulls. - aetherc does not resolve a module constant inside a struct literal in - a return statement; the constants are restated locally. -8. **mesh** (done): mesh.c as `aephysics.mesh`: the BVH (binned SAH or - median split, triangles sorted depth-first), welding through a spatial - hash on core's new LongMap, the edge flags, and the overlap, ray, - shape cast, mover and box query traversals at any scale. 1,594 - checks: test_mesh.c's valley (dense, strided, welded, clockwise, - composed) and creators, plus the tree's consistency, rays against a - grid and a wave at analytic hits, the box query against a scan, the - overlap, the shape cast, the flags of a box and a hollow box, a - mirrored scale, the mover. The same trees as the reference; traversals - 1.7-2x (its SIMD box tests). The mesh contact's cluster reduction - (mesh_contact.c) is dynamics-side and comes with the contacts. -9. **height_field** (done): height_field.c as `aephysics.height_field`: - the heights quantised to a global range, materials and holes per - cell, the edge flags against the four neighbours, either winding; - overlap, the ray and shape casts by a DDA walk of the swept box's - leading corner through the cells, the mover's planes, the box query. - 113 checks: test_height_field.c's create, index mapping, winding, - flat ray, overlap, straddle, brute-force shape and ray casts over a - wave, back-side and clockwise culling (the file roundtrip is not - ported), plus the flags of a ridge and of holes, a scaled field, the - query and the mover. The same results as the reference; the query at - parity, the casts 1.4-2x. The heights, materials and flags are ints - for want of 16- and 8-bit arrays, 3x the reference's bytes. -10. **shape** (done): sphere.c, capsule.c and the geometric half of - shape.c as `aephysics.shape`: the sphere's and capsule's mass, bounds, - ray casts (the closest-point forms that hold their precision far from - the origin), casts, overlap and mover planes, the hull's mover, and - the Shape of any kind with its dispatch under a transform (bounds, - swept and fat bounds, centroid, areas, mass, extent, ray and shape - casts, overlap, mover, proxy) and the collision filters. 440 checks: - test_shape.c's masses (sphere, analytic and transformed boxes, the - capsule bracketed by hulls), bounds, the sphere and capsule ray cases, - the overlap convention, the far-origin precision (our doubles sit - three orders under the reference's float floor and still hit at ten - million units), the cast through the dispatch; plus every kind under - one transform through every query, and the filters. Rays and masses - at parity; the GJK cast and overlap on proxies with radii 2-2.5x, - to profile (aephysics#9). The world-bound half of shape.c (creation - on a body, the broad-phase proxy, materials, events) comes with the - dynamics. -11. **compound** (done): compound.c as `aephysics.compound`: the - children's bounds into a tree rebuilt in full and carried in the - block with its traversal stack, materials deduplicated field by field - through core's LongMap, hulls and meshes by their own hash and bytes, - a mesh child's four material slots remapped; overlap, ray and shape - casts, the box query and the mover through the tree, each child in - its own frame. 159 checks: test_compound.c's creation, materials, - sharing, child order, bounds, casts, remap, overlap, query and mover - subtests (the byte roundtrip came with #148), plus the compound - through the shape dispatch and a field of a thousand spheres. The - same results as the reference; the build 0.7x, the queries 1.3-1.6x, - the ray cast 2.9x with the time in the tree's traversal itself - (aephysics#11). To make the dispatch reach the compound without a - cycle, sphere.c and capsule.c became `aephysics.sphere` and - `aephysics.capsule`, the material `aephysics.material`, and the - hull's mover moved beside the mesh's; `aephysics.shape` imports them - all. `tree_validate` accepts a baked tree (no parents). -12. **mover** (done): mover.c as `aephysics.mover`: the character - mover's plane solver (twenty Gauss-Seidel sweeps with the pushes - accumulated and clamped to each plane's limit, the slop keeping the - mover just off the surface) and the velocity clip. 56 checks: - test_mover.c's solver cases and every mover collision that needs no - world (sphere, capsule, hull, mesh, height field), plus a corner, a - soft plane and the clip. 0.9x the reference on a million solves with - one iteration's difference in 1.7 million. -13. **dynamics**: the reference's world is one mutually recursive body - of C (body.c, contact.c, joint.c, island.c, solver_set.c, - constraint_graph.c, sensor.c, broad_phase.c and half of - physics_world.c call into each other), so the cut into Aether's - acyclic modules is: - - `aephysics.mesh_contact` (done): mesh_contact.c's world-free - part: the triangle cache refreshed when the shape leaves its query - bounds (each triangle keeping a simplex and a separating-axis - cache; the reference's union is two fields), the manifold per - triangle in the convex shape's frame, the acceptance rules against - ghost collisions (a triangle face always; a hull face when aligned - or deep; the rest tentative, spheres by nearest-first feature - ownership, hulls and capsules skipping only owned flat edges), - clusters within cos 5 degrees, the four-point cull. The results - live in the caller's arena. What follows in the reference (the - warm-start matching, materials, rolling resistance, tangent - velocity) is the contact's and comes with the dynamics. 64 checks - of our own (the reference tests this through its world): the cull, - a box on a grid, the cache kept and refreshed, a sphere on seams, a - capsule, a height field, a ridge, a moved mesh. Two reference - quirks kept: the cull's tie rule lets zero-area points through on - their separation (collinear points can keep four), and a hull - face's far clip points stay as speculative ones. - - `aephysics.broad_phase` (done): broad_phase.c's trees per body - type, proxies keyed by type in the low bits, the moved-sibling - gathering, the self and cross pair walks and the pair set; the pair - filter and the compound lookups are visitors, and the update leaves - sorted keys for the client to turn into contacts. 36 checks against - a brute force: pairs found and not found across moves, the filter, - a forced static proxy, a destroyed proxy, a compound's children. - Found and fixed on the way: core's key_hash multiplied signed - longs (undefined in C; gcc at -O2 made two inlined copies disagree) - -- it now mixes in 32-bit products. - - `aephysics.dynamics` (in progress): one module for the world's - state and its bookkeeping -- the World with its sparse arrays and - id pools, the ids with generations, the body's sims and states in - solver sets (static, disabled, awake, one per sleeping island), - the shape's world half (creation on a body of every kind, the fat - bounds, the proxy in the tree of the body's type, materials, event - flags), the islands, the mass from the shapes or by hand, the - transforms and velocities, the validation. Done so far: worlds, - bodies, shapes, islands of one body, sleeping sets and waking them - (bodies and islands only), 156 checks: all of test_body.c and the - step-free parts of test_world.c, plus every set and shape kind, - transforms, velocities, locks, destruction in every order with the - sets validated throughout. The reference's hull database (hulls - deduplicated by content and refcounted across shapes) came later - (#147): a hull shape holds the world's copy, so its caller may free - the hull once the shape is made. The name cache came with #149. - Second slice: the contact (creation from the broad phase's pair - keys through the pair visitors, the edges on both bodies, the - manifold update -- convex pairs through the manifold module, - mesh and height field pairs and a compound's children through the - mesh contact with the reference's tail: the old manifolds matched - by normal and the points by feature and triangle, the materials - mixed or averaged per triangle, rolling resistance, tangent - velocity -- the recycling of a barely-moved pair, the collide pass - with the state changes in id order and the begin/end events), the - constraint graph's colouring (dynamic pairs from the front, static - pairs from the back, an overflow colour), the islands linked by - touching contacts, merged, and split by union-find, sleeping sets - with their touching contacts and waking them back into the graph. - 231 checks in all, contacts and islands checked against the - reference's rules on a cube on a slab, a stack, a row split, a - sphere on a mesh and a cube on a compound child. Bench: the - reference's zero-time step collides without solving, so the pass - compares (1.7x, aephysics#17 to profile with the solver). - Third slice: joint.c's base (the joint definitions of every kind, - creation on two bodies with the edges on both, the sim in the - disabled, static, awake (the graph, a sleeping set woken) or - sleeping set (two sleeping sets merged), the island link, the - collide-connected rule in the pair filter with the world's custom - filter, destruction, the accessors, the linear and angular - separations, the reaction for the joint events), the kinds' data - laid over a block at the end of the sim (the reference's union), - solver_set.c's transfers and merge, body.c's type change, disable - and enable (which move the joints along), sensor.c (a sensor per - sensor shape, the overlap pass over the three trees with the - visitors sorted and made unique, the begin and end events in id - order, the hits the continuous pass will feed it) and shape.c's - sensor accessors. 541 checks in all: test_joint.c's shared API on - every kind and the contacts a joint clears, the sets and islands - under sleep, wake, disable, enable and type changes, and - test_world.c's sensor moved by hand. Found and fixed on the way: - core's buffers grew from a capacity of one to one (the sleeping - sets of one body). The solving of the kinds (prepare, warm start, - solve, the per-kind accessors and constraint forces) comes with - joint_solver. - - `aephysics.contact_solver` (done, PR #19): contact_solver.c in its - scalar form (the reference's mesh and overflow path) for every - contact: ContactConstraint and ManifoldConstraint over a colour's - convex contacts and specs, the step context (b3StepContext, one - worker), prepare (the split separation, friction centre decay, - tangent/twist/rolling masses), warm start, the merged normal and - friction solve with the relax pass, restitution, store with the hit - events. 49 checks by hand on a cube on a slab: masses, the falling - cube stopped with its momentum spread over four points, the push-out - capped by the contact speed, the speculative gap, the friction bound - mu N and the friction centre, the twist bound, restitution above and - below its threshold, the warm start and the hit event. - - `aephysics.contact_solver_wide` (done, PR #25): the reference's - wide path (b3ContactConstraintWide, b3PrepareContacts_Convex and - its siblings) for a colour's convex contacts: FloatW lanes of four, - the constraint as a structure of arrays, gather and scatter of the - bodies' states, prepare, warm start, solve, restitution and store; - the mesh and overflow contacts stay scalar. The lanes are the - language's `f32x4` (std.lanes, Aether 0.706) over one - single-precision constraint record: four floats in a register, the - reference's SSE and NEON lanes. They were written twice for a while - -- in Aether as plain code over doubles, and in aephysics_native.c - as GCC vector code in single precision, which is where the speed - was (issue #22: the layout alone bought 10%, the vector lanes - another 20-30%). Once Aether had lanes, a kernel measured at what - the same C costs (bench/lanes.ae), the module's lanes became f32x4, - computed the native file's record to the bit and ran faster on the - pyramids (PR #48), and the C copy went (#46). The solve is then at - parity with the reference's; the rest of the step's gap is the - collide pass and the prepare (bench/RESULTS.md, stage by stage). - - `aephysics.joint_solver` (done, PR #20): the seven joints' prepare, - warm start and solve (distance, motor, parallel, prismatic, - revolute, spherical, weld, wheel: distance_joint.c and its - siblings), the kinds' accessors and their constraint forces and - torques, joint.c's dispatch with the constraint hertz clamped to a - quarter of the sub-step rate. Shared pieces factored once: the - prepare's base (masses, fixed rotation, awake indices), the world - frames, the hinge's perpendicular axes, the point constraint's mass - matrix, the limits' bias scales. math gains skew, blend3 and the - quaternion delta. 149 checks: test_joint.c's accessor round trips - on every kind, then each kind solved by hand on a cube hung from a - static ground (the velocities a rigid joint removes and keeps, the - limits' push back on a body turned past them and the relax undoing - it before the bodies move, springs, motors at their speed and - bounded by their force, the forces and torques from the impulses, - the warm start). - - `aephysics.solver` (done, PR #21): solver.c on one thread in the - reference's stage order (prepare joints and contacts; per sub-step - integrate velocities with the gyroscopic Newton step, warm start, - solve, integrate positions with the locks and speed caps, relax; - restitution; store), the overflow constraints first in every pass - and joints before contacts in a colour; finalize bodies (transforms - from the deltas, sleep velocities, move events, the transient flags, - fast bodies swept in solve_continuous with the time of impact - against the static tree -- bullets against all three, later -- the - bounds and proxies), the joint and hit events, the tree refits, the - sensors' hits, the island split the last step asked for and the - sleep decision. The shape module gained shape_time_of_impact (mesh, - height field and compound sweeps with the reference's early outs and - centroid-sphere fallback); dynamics the events, the per-step bit - sets, the pre-solve callback and the user material ids; math the - rotation integration and the modified cross; core stack_grow. 42 - checks stepping by hand: free fall against the sub-stepped closed - form, a cube settling and sleeping with its contact, a push waking - it, a stack of three, a bounce with its hit event, a weld's joint - event and its force, a pendulum's length, a sphere at 200 m/s - stopped by a wall 0.2 thick (and tunnelling with continuous off), - a bullet stopping at a dynamic cube, a sensor swept through, and two - worlds of twenty cubes alike bit for bit after two seconds. Found on - the way: putting islands to sleep creates solver sets and can move - the sets' array, so the awake set is looked up again in that loop. - The bench pair against b3World_Step landed at 1.9-2.0x with the - same heights, awake and contact counts (the reference's convex - contacts go four wide in SIMD and it computes in floats; ours were - scalar doubles), 1.2-1.4x once contact_solver_wide took them. - - `aephysics.physics_world` (done, PR #23): the step (the events - cleared, the pairs, the context with the contact hertz reduced for - large steps, the narrow phase, the solve when time passes, the - sensors, the stack grown, the end events swapped, the world locked - throughout), the events read back (end events from the buffer the - last step filled), the settings (sleeping off wakes every set), the - counters and bounds, the queries over the three trees re-centred on - their origin (overlap of a box or a proxy, the mover's planes, ray, - shape and mover casts, the closest ray hit) and body.c's queries at - a transform of the caller's (ray, shape, overlap, mover planes, the - mover's time of impact), explosions. 94 checks: test_world.c's - HelloWorld, contact and hit events with their materials, the - continuous move event matching the transform, the bullet through a - sensor, the explosion near and a ten million away; test_body_query.c - end to end; the world queries; a wave pile of sixty cubes stepping - alike twice with its sleep step as the checksum. Found on the way: - struct names are one namespace across modules (a QueryContext - already lived in compound), and a struct literal in a return with - module calls inside reaches C undeclared, as constants did. The - pair on the reference's own benchmark scenes matches its checksums - (heights, contacts, joints) at 1.8-2.4x. - PR #26 ported the rest of the reference's scene tests: the world - parts of test_mover.c (test_mover_world.ae, 38 checks), the - determinism scenes (test_determinism.ae: the reference's wave pile, - query spawn and mesh drop with its xorshift random numbers and - seeds, each run twice and compared bit for bit since its golden - hashes are single precision and ours is doubles; the query spawn - sleeps on the reference's own step with its 59 query hits, the - mesh drop one step apart), and test_large_world.c - (test_large_world.ae: a stack, a bullet and the origin-relative - queries at x = 0 and 1e7 agree, which the reference only asks of - its double precision build; b3Shape_RayCast came with it as - shape_ray_cast). Found on the way: a function named spawn_* loses - its arguments in the emitted C (aether#2126). Still to port: the - falling ragdolls (they need the human of shared/human.c, the - ground of the Euphoria work), test_world.c's compound hit events, - overflow colour pile and hull database (all since ported: #154, #147). - - `aephysics.human` (done, PR #29): the reference's ragdoll - (shared/human.c) as a module, with the body forces, torques and - impulses, the bullet setter and body_get_contact_data it needed on dynamics. 44 checks - (the figure's shape, a fall to rest in one piece, the setters, a - kick, the align spring standing it, the anchors holding its pose, - the same drop twice bit for bit); the falling ragdolls scene of - test_determinism.c (eight figures on grid meshes and tori) added to - test_determinism.ae; the reference's "rain" benchmark (300 ragdolls - dropped in columns over 400 steps) as a pair at 1.05x its time. The - research note below says what an active ragdoll takes from it. -14. **parallel** (done, PR #31): `aephysics.parallel` is the reference's - scheduler.c and parallel_for.c -- worker threads made once with the - world (std.worker's run_detached, a thread each) that wait 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 -- - and `aephysics.native` what Aether has not: a thread-local worker - index, atomics on an int in place, the semaphore. Every module's - scratch became a block per worker chosen by the thread's index - (Aether has neither stack arrays nor thread-local variables), the - dynamic tree's traversal stacks and the moved-marking included. The - narrow phase, the bodies' finalize and the bullets are parallel_for - tasks; the solver is the reference's stage machine (solver blocks - with an atomic sync index, stages published as sync bits by worker - 0, the overflow colour serial on it); each worker writes its own - task context (contact state, joint and hit bits, awake islands, - split candidate, sensor hits) and the step merges them. A step is - the same to the bit at any worker count, which test_determinism - checks; bench/parallel.ae against bench/parallel_box3d.c records - the scaling. Found on the way: every task on a fresh thread costs - 180 us a round (the persistent threads cost 8), and the stack - allocator's zeroing was a millisecond a step (the reference's does - not zero; poisoning the memory shows nothing reads it first). -15. **recording and replay**, `world_snapshot`: last, since they are the - tooling and not the engine. The snapshot is in (`aephysics.snapshot`, - #149's first part): a loaded world steps on bit for bit as the one it - was taken from. Recording and replay are in (`aephysics.recording`, - #149's second part): the hooks at the top of every call that changes a - world, generated from the calls' signatures, write the record before - the call runs, so nothing the call does is recorded twice; the player - restores its world in place (`dynamics.reset_world`, the reference's - deserialize into a shell), so ids handed to a viewer stay valid through - a seek. The world queries and the name cache are in (#149's third - part): a query records each visit with the caller's answer, and the - replay asks it again through a trampoline that gives the recorded - answers back, so a visitor that stops or clips a query is replayed - exactly; its record goes in whole under the recording's lock, since - queries may run on several threads at once. Names are interned per - world under the reference's 32-bit hash, the bodies and shapes holding - ids, so their records copy whole and a snapshot carries each name once; - where the reference gives two names on one hash the first name, the - second here takes the next free id. Debug draw came after (#163), the - reference's b3World_Draw with its colours, its joints' drawings and the - host's debug shapes, whose lives here also end with a geometry change - or the world's end, where the reference keeps a stale one or leaves them - to the host. A fast kinematic body's first frame (#167) is an - extension the reference does not have, off by default: with - `enable_kinematic_speculation`, a contact with a kinematic body keeps - speculative points out to the pair's travel over the step (the manifold - functions read a per-worker speculative distance the collide pass sets - per contact) and the kinematic body's fat bounds cover its next step. - `enable_travel_speculation` (#173) does the same for every contact and - every moving body, a fast body's bounds after its sweep included, for a - fast dynamic body: a 1.2 t car at 6.5 m/s moves 11 cm a step, under the - reference's continuous threshold (half its extent), and found the light - bodies it struck 5 to 8 cm inside it on the first frame. Two - functions no test called hid type errors (`body_apply_force`, - `body_get_local_point_velocity`) until the replay dispatcher called - every recorded one: `ae build` checked only what a program reaches - (aether#2613, fixed in 0.799.0), so the gate now runs `ae check` on - every module. A module checked on its own once passed a local named - after the function it calls, which a program importing it was refused - (aether#2683, fixed in 0.806.0; aephysics#169), so the gate counts on - each module also being checked as an import. -16. **benchmarks** (done, #141): `reference/benchmark/main.c`'s thirteen - scenes and its SAT runs, against the reference's own scene functions - on the same machine: the large pyramid, the many pyramids and the - joint grid in `bench/physics_world.ae`, the rain in `bench/human.ae`, - the other nine scenes and the SAT runs in `bench/suite.ae` beside - `bench/suite_box3d.c`; recorded in `bench/RESULTS.md`. - -## Measures - -- Every ported test passes with the original's tolerances. -- Determinism: `test_determinism` (the same scene twice, bit-identical), - and the port against the C original on a scene's first frames; across - platforms too: the per-step trace CI records on Linux matches the - Windows run bit for bit (PR #28), which the reference pins with golden - hashes and this port pins by diffing the traces. -- Speed: each benchmark scene, port against C, single thread first, - then by worker count. The honest expectation for a scalar double port - against a wide-SIMD float original is a gap; the work after the port - is closing it -- and where the gap is the SIMD contact solver, that - one loop goes native behind the same interface, measured. - -## The research this is the ground for - -The brief asked after the physics and ragdoll work behind Grand Theft -Auto. That is **NaturalMotion's Euphoria** (used in GTA IV, GTA V, Red Dead -Redemption, Max Payne 3), which Rockstar integrated into RAGE alongside -Bullet for collision. Euphoria is not a physics engine: it is an *active -ragdoll* -- a full rigid-body character driven by controllers ("behaviours") -that model balance, protective reflexes, grabbing, staggering -- what -NaturalMotion called Dynamic Motion Synthesis. The founders' research: - -- Torsten Reil and Phil Husbands, "Evolution of Central Pattern Generators - for Bipedal Walking in a Real-Time Physics Environment", IEEE - Transactions on Evolutionary Computation, 2002 -- neural oscillators - evolved to drive a physically simulated biped's joints, which is the - origin of NaturalMotion (Reil and Colm Massey, Oxford, 2001). -- The Euphoria behaviours themselves are unpublished; what is published - around them: Yin, Loken, van de Panne, "SIMBICON: Simple Biped Locomotion - Control" (SIGGRAPH 2007) for balance feedback on a simulated biped; - Geijtenbeek and Pronost, "Interactive Character Animation Using Simulated - Physics" (2012 survey); and for the modern procedural locomotion that GTA - VI is reported to build on, Clavet's "Motion Matching and The Road to - Next-Gen Animation" (GDC 2016) and Holden et al., "Phase-Functioned - Neural Networks for Character Control" (SIGGRAPH 2017) and "Learned - Motion Matching" (SIGGRAPH 2020). - -So the question's answer: it is related, and it sits *on top of* this -port. What an active ragdoll needs from the physics is exactly Box3D's -dynamics -- articulated bodies through joints with motors and limits -(revolute, spherical with cone and twist limits, the wheel/motor joints -for drive), joint-space PD control toward a target pose, and a solver that -holds a chain of a dozen bodies stably under those motors, which is what -Soft Step is for. The plan in ae3d, once this port stands: - -1. **Ragdoll from the skeleton**: a body per bone with capsule shapes and - mass from the mesh, joints from the bind pose with limits, a - `RigidBody`/`Ragdoll` component in the engine's GameObject shape. -2. **Animation-driven ragdoll**: every joint's motor tracks the animation - clip's pose (PD gains per joint), so a walking zombie is simulated and - a hit knocks it off the clip and back -- the base of Euphoria's look. -3. **Behaviours**: balance (a SIMBICON-style feedback on the hip and - stance foot), protective arms toward the ground on a fall, a stagger - that steps to recover -- each a controller over the ragdoll's motors, - the way Euphoria composes them. -4. **Locomotion**: motion matching over the clips the pipeline exports, - feeding the animation-driven ragdoll its target pose. - -Each is its own epic in ae3d (nicolas-maman/ae3d#365); none of them can -start before the dynamics layer here passes its tests. - -### What the ragdoll layer gives them (PR #29) - -`aephysics.human` is the reference's figure (shared/human.c) as a module: -twelve capsule bones, spherical joints with cone and twist limits at the -spine, neck, hips and shoulders, revolute joints at the knees and elbows, -a spring on every joint toward the reference pose, a motor on every -joint whose torque limit is its friction, and filter joints for the -limbs that would clash. The pieces an active ragdoll composes are all -there and measured: - -- **The pose drive.** Every joint has a spring (`hertz`, `damping_ratio`) - toward a target: `target_angle` on the hinges, `target_rotation` on - the spherical joints; `human_set_joint_spring_hertz` and - `human_set_joint_damping_ratio` tune the whole figure. Setting the - targets from an animation clip each step is the animation-driven - ragdoll (plan item 2); the springs are soft constraints, so a chain - of twelve holds under them without exploding, which the rain - benchmark shows at 300 figures. -- **The muscle budget.** The motors' `max_motor_torque` per joint - (`human_set_joint_friction_torque` scales all of them by a per-joint - share) is the torque limit a behaviour works within; a spring's - `max_spring_torque` on the motor joints bounds the drive itself. A - behaviour raises the budget on the limbs it needs (arms toward the - ground on a fall) and drops it on the rest (going limp). -- **The pose target as bodies.** `human_create_motor_anchors` hangs - every bone from a kinematic anchor through a motor joint's position - and rotation springs: move the anchors (from a clip, from a controller) - and the figure follows, with the world pushing back. The parallel - anchors (`human_create_parallel_anchors`) drive rotation only, with a - torque cap, and let the figure fall while it keeps its shape -- the - shape of Euphoria's "keep the pose while you tumble" reflexes. -- **Balance's inputs.** A controller in the SIMBICON line needs the centre - of mass and its velocity (the bones' masses and velocities are - readable), the support polygon (the feet's contact points and normal - impulses come through the contact events and `body_get_contact_data`, PR #29), - and the joint reactions (`joint_get_constraint_force/torque`); it - writes the hip and stance-ankle targets. The align spring - (`human_align_spring`) is the crudest balance: a parallel joint to the - ground that springs the pelvis upright, which the sample uses at 25 Hz. -- **Actuation beyond springs.** `body_apply_torque`, - `body_apply_force`, and the impulses (PR #29 added them) let a - controller act directly on a bone for the cases a joint spring cannot - express (a shove, a step's push-off). -- **Determinism.** The falling ragdolls repeat bit for bit and match - across platforms, so a behaviour's tests can pin outcomes. -- **A figure fitted to a rig** (ae3d#529). The reference is one rig's - figure, straight up the spine; a rig whose spine curves, dressed in - it, asks the joints to hold a bend. `human_reference_shape` gives the - figure as data (a `BoneSpec` per bone), `human_fit_shape` moves every - joint to a rig's and turns every bone along the rig's line -- each - capsule stretched by the length its line measures (the neck and the - forearms carry the head and the hands past the rig's joints, so they - take their parent's), every radius by the figure's size, each foot - turned about its shin to the rig's toe -- and `create_human_shaped` - builds it anywhere. Each joint's frame on its parent turns with the - bone, so the rest angle its spring and limits are taken from is the - reference's: the rig's bind pose is the fitted figure's rest pose. - `create_human` is the reference's shape through the same path, bit - for bit what it was. A bone keeps its own capsule's id (`shape_id`) - and a calf its foot's (`foot_shape_id`), since a body lists its - shapes newest first and the calf's first is the foot. - -What is not there yet, and belongs to the ae3d epics: the per-step -controller hook (a `before_step` callback so behaviours set -targets from the state the step will use), and the behaviours -themselves. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8fc5a64 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,249 @@ +# How aephysics is built + +aephysics is Box3D's design written in Aether: the same data layout, the +same step, the same solver, checked against Box3D's own tests and timed +against Box3D on the same machine. This document is the map: the modules +and what each ports, how a step runs, how the data is laid out, how the +work goes over threads and SIMD lanes, and why the results are the same +everywhere. The [guide](guide.md) is the user's view; +[testing.md](testing.md) says how all of it is checked. + +## Why an implementation and not a binding + +An Aether program could call Box3D through externs. The engine is written +in Aether instead so that Aether's programs, and the people and agents who +write them, can read, test and change the physics in the language, and so +that the library stands alone (`ae build --emit=lib`). Box3D and Jolt are +fetched unmodified beside it as the baseline: the engine is right when it +agrees with Box3D's tests and fast when it keeps up with Box3D's +benchmarks. Which design to follow was itself decided by measurement: on +identical scenes Box3D's solver was both stiffer and faster than Jolt's +([the bake-off](../bench/RESULTS.md#the-bake-off)). + +## The modules + +Aether modules cannot import each other in a cycle, and Box3D's world is +one mutually recursive body of C (body.c, contact.c, joint.c, island.c, +solver_set.c, constraint_graph.c, sensor.c and half of physics_world.c call +each other). So the geometry and the solvers are modules of their own, and +the world's bookkeeping is one module, `dynamics`. From the bottom up: + +| layer | module | ports | holds | +|---|---|---|---| +| foundation | `math` | math_functions | vectors, quaternions, transforms, 3x3 matrices, boxes, the deterministic atan2/cos/sin, single-precision twins (`Vec3f`, `Quatf`, `Matrix3f`) | +| | `basics` | core.c, bitset, id_pool, table, container, arena | bit sets, id pools, hash sets, arrays, the stack and arena allocators, the host's hooks, the content hash, the name cache, the SIMD width | +| | `native` | (C) | the one C file: a worker index per thread, atomics on an int in place, a semaphore, the processor count | +| | `parallel` | scheduler.c, parallel_for.c | worker threads made once that wait on a semaphore, tasks claimed by compare-and-swap, ranges in blocks | +| geometry | `dynamic_tree` | dynamic_tree.c | the bounding volume hierarchy: SAH insertion, rotations, refit, rebuild, box, ray and swept queries, save and load | +| | `hull` | hull.c, quickhull | quickhull with face merging, the half-edge hull, its mass, box/cylinder/cone/rock hulls, single-precision rows for the wide SAT | +| | `distance` | distance.c | GJK with its simplex cache, the shape cast, the time of impact | +| | `sat` | convex_manifold_wide.inl | the separating axis test between hulls, eight lanes wide | +| | `manifold` | manifold.c, convex_manifold.c | contact manifolds for every convex pairing: clipping, feature ids, reduction to four points | +| | `triangle_manifold` | triangle_manifold.c | one mesh triangle against a convex shape | +| | `mesh`, `height_field` | mesh.c, height_field.c | triangle meshes under a BVH, height fields on a grid, their queries | +| | `sphere`, `capsule`, `compound` | sphere.c, capsule.c, compound.c | the primitives' mass, bounds and casts; the baked compound under a static tree | +| | `shape` | shape.c (geometry half) | any shape kind under a transform: bounds, mass, casts, overlap, filters | +| | `material`, `files`, `mover` | material.h, file I/O, mover.c | surface materials; trees and height fields on disk; the character mover's plane solver | +| collision | `broad_phase` | broad_phase.c | a tree per body type, the moved proxies, pair finding against the pair set | +| | `mesh_contact` | mesh_contact.c | a convex shape against a mesh: the triangle cache, the ghost-collision rules, clusters | +| world | `dynamics` | body.c, shape.c, contact.c, joint.c, island.c, solver_set.c, constraint_graph.c, sensor.c, world.c | worlds, bodies, shapes, contacts, joints, islands, solver sets, the constraint graph, sensors, events | +| solving | `contact_solver` | contact_solver.c (scalar) | contact constraints one at a time: meshes and the overflow colour | +| | `contact_solver_wide` | contact_solver.c (SIMD) | a colour's convex contacts eight at a time | +| | `joint_solver` | joint.c and the nine joint files | each joint kind's prepare, warm start, solve and accessors | +| | `solver` | solver.c | the Soft Step stages over the workers | +| face | `physics_world` | physics_world.c | the step, settings, events, queries, explosions, counters, drawing | +| tools | `debug_draw` | debug_draw | the host's drawing callbacks and colours | +| | `snapshot` | world_snapshot.c | a world as bytes and back | +| | `recording` | recording.c, recording_replay.c | every call recorded, and a player that replays and seeks | +| | `human` | shared/human.c | the ragdoll | + +The naming follows Box3D: where a function is the same thing it has +Box3D's name without the prefix, in snake case, so a reader can hold a +Box3D source file next to its port. + +## The data + +**Worlds and ids.** Worlds live in a fixed table of 128. Every object is +reached through an id that carries its slot and the slot's generation, so +an id kept past its object's life fails validation instead of reaching the +next occupant. + +**Solver sets.** A body's simulation state lives in one of the world's +solver sets: the static set, the disabled set, the awake set, or one set +per sleeping island. The awake set is the only one a step touches; it holds +each body's `BodySim` (transform, centre, mass, forces) and `BodyState` +(the velocities and deltas the solver reads and writes, kept small and +dense). Sleeping and waking move whole islands between sets. + +**Islands.** Touching contacts and joints link bodies into islands, merged +as links appear and split by union-find when they go. An island that has +rested long enough goes to sleep as a unit. + +**The constraint graph.** Awake contacts and joints are coloured so that +no two constraints of a colour share a dynamic body: 24 colours, the +first 20 for pairs of dynamic bodies, pairs with a static body taken from +the back, and an overflow colour for what does not fit. Within a colour +every constraint can be solved at the same time, which is what makes both +the lanes and the threads safe, and the result independent of their +number. + +**Contacts.** A contact keeps its manifold (up to four points with their +impulses for warm starting), its bodies' sim indices (refreshed whenever a +sim moves, so the collide pass never walks from shape to body to set), and +its shapes' fat boxes in a dense array by shape id. + +## A step + +`physics_world.step(world, dt, sub_steps)` runs Box3D's step: + +1. **Pairs.** The broad phase walks the proxies that moved since the last + step through the trees (one per body type), checks candidate pairs + against the pair set in batches with their slots prefetched, and new + pairs become contacts. +2. **Collide.** The narrow phase computes every awake contact's manifold + in parallel: convex pairs through `manifold` and `sat`, meshes and + height fields through `mesh_contact`, compounds child by child. Contacts + that begin or stop touching are flagged per worker and applied after, + in contact order. +3. **Solve.** The Soft Step: the joints and contacts are prepared (anchors, + effective masses, softness); then for each sub-step the velocities are + integrated (gravity, damping, the gyroscopic torque), the constraints + warm started and solved with a soft bias, the positions integrated, and + the constraints relaxed without bias. Restitution runs after the + sub-steps, and the impulses are stored for the next step's warm start. +4. **Finalize.** Bodies get their new transforms, sleep velocities and move + events; fast bodies are swept for continuous collision, bullets against + dynamic bodies too; the trees are refit, joint and hit events gathered, + sensors updated, and resting islands put to sleep. + +Within the solve, joints come before contacts in each colour and the +overflow colour comes first. The wide stages take a colour's convex +contacts eight at a time; mesh contacts and the overflow are solved by the +scalar `contact_solver`. + +## Precision + +Positions are double: a body's transform and centre of mass, the geometry, +and every query, so a world far from the origin behaves as one at it. +Box3D keeps double positions only in its large-world build. + +Box3D's single precision is kept wherever its speed comes from the bytes +moved: the solver's body states (velocities and the step's deltas, one +cache line a body), a body's inertias, forces and dampings, the stored +manifolds and the contacts' cached values, the wide contact records, the +broad-phase tree's boxes (rounded outward from the doubles, as Box3D's +large-world build rounds them), and the hulls' rows for the separating +axis test. The narrow phase computes in double and narrows once as it +stores; the scalar solvers widen to double for their arithmetic. + +## SIMD + +Aether has SIMD types of its own (`f32x4`, `f32x8`, std.lanes), so the wide +paths are Aether, not C. `contact_solver_wide` keeps a colour's convex +contacts as one single-precision record per eight contacts and solves them +in `f32x8` lanes; the bodies are gathered into lanes and scattered back +through 8x8 register transposes, as Box3D's `b3GatherBodies` and +`b3ScatterBodies` do. `sat` runs Box3D's wide separating axis test, its +support search keeping the vertex index in the low mantissa bits so one +horizontal minimum finds it. + +Both modules are imported twice: plain, and with `@target("avx2")`, which +compiles a second copy for AVX2 inside the same program. A world picks its +copy when it is created (`basics.get_simd_width`: 8 where the CPU has AVX2, +4 otherwise, and `basics.set_simd_width` to choose), so one binary runs +eight lanes to a register on AVX2 and two four-lane halves elsewhere. The +two copies give the same bits; `test_determinism` steps every scene at +both widths in one process to hold them to that. + +## Threads + +`aephysics.parallel` is Box3D's scheduler: worker threads made with the +world, parked on a semaphore between steps, tasks in slots claimed by +compare-and-swap, the calling thread working as worker 0 while it waits. +The narrow phase, the bodies' finalize and the bullets are parallel loops +over blocks; the solver is Box3D's stage machine, where worker 0 publishes +each stage as a sync bit and every worker claims blocks of it. Each worker +writes its own task context (contact flags, joint and hit bits, awake +islands, sensor hits) and the step merges them after, in id order. + +Box3D keeps its scratch on each thread's stack. Aether has neither stack +arrays nor thread-local variables, so every module keeps a scratch block +per worker slot instead, chosen by the calling thread's index from +`native`. There are 64 slots, Box3D's own cap on workers; a world's workers +hold the world's, and any other thread claims one the first time it +queries and returns it when it exits. That is what lets queries run from +many threads at once. + +## Determinism + +A step gives the same bits on every platform, at any worker count and +either SIMD width, because: + +- the order of every floating-point sum is fixed: colours are solved in + order, workers' results are merged in id order, and lane reductions run + in a fixed order; +- the C compiler never fuses a multiply and an add (`-ffp-contract=off`); + GCC and Clang do by default wherever the target has FMA, and arm64 parted + from Linux on the first step until the flag was set; +- the trigonometry is the engine's own (Box3D's approximations), and the + square root is std.math's, exact at every optimization level (MinGW's + library square root rounds twice at `-O0`). + +`tests/golden/determinism_trace.txt` holds every body's checksum after +every step of five scenes, and CI diffs it on Linux, Linux with AVX2 and +FMA, macOS arm64, and Windows optimized and not. + +## Memory + +Every block the engine allocates goes through `basics`, which counts it and +hands it to the host's allocator when one is set. A step's temporary memory +comes from the world's stack allocator and each worker's arena, as in +Box3D. `physics_world.get_counters` reports the total; `dump_memory_stats` +breaks it down. + +## The C file + +`aephysics/native/aephysics_native.c` holds what Aether cannot express: +the thread-local worker index (a native TLS slot on Windows, where MinGW's +emulated TLS costs a call per read), atomics on an int in place, a +semaphore, and the processor count. `aephysics.native` names it with +`@source`, so `ae` builds it into every program that imports the engine. +The contact lanes lived in that file as GCC vector code until Aether had +lanes of its own; nothing else ever did. + +## Where it departs from Box3D + +The user-visible additions (travel and kinematic speculation, the +kinematic-static softness, elliptical and off-axis cones, hulls that come +to rest on an edge) are in the [guide](guide.md#where-aephysics-goes-past-box3d). +Inside, the differences are of form, not of result: + +- A hull shape holds the world's copy of its hull (Box3D's hull database), + so a caller frees its own at once. +- The name cache gives a second name that collides on Box3D's 32-bit hash + the next free id, where Box3D shares the first name. +- A host's debug drawable for a shape is released when its geometry + changes or its world goes, where Box3D keeps a stale one or leaves it to + the host. +- Bad input is refused and the world left as it was, where Box3D asserts. +- A mesh query has no 256-triangle cap. + +## What it is the ground for + +aephysics exists for [ae3d](https://github.com/aether-lang-dev/ae3d) and +the games built on it, and above all for active ragdolls: characters that +are simulated bodies driven toward an animation, the line NaturalMotion's +Euphoria made famous in Grand Theft Auto IV and V and Red Dead Redemption. +Euphoria's behaviours are unpublished; the research around them is +public: Reil and Husbands on evolved central pattern generators (2002), +SIMBICON's balance feedback (Yin, Loken and van de Panne, 2007), the +survey by Geijtenbeek and Pronost (2012), and for locomotion, motion +matching (Clavet, 2016) and learned motion matching (Holden et al., 2020). + +What an active ragdoll needs from the physics is what Box3D's dynamics +gives: articulated bodies on joints with limits, springs and motors, a +solver that holds a chain of a dozen bodies stable under those motors, +and determinism, so a behaviour's tests can pin outcomes. `aephysics.human` +provides the figure, its pose drive and its anchors (see +[the guide](guide.md#ragdolls)); the behaviours themselves (balance, +protective reflexes, stepping to recover) are ae3d's. diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..becd5e7 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,573 @@ +# Using aephysics + +This guide walks through a program's view of the engine: worlds, bodies, +shapes, joints, stepping, events, queries, threads and the tools around +them. Every call named here is real; the [examples](../examples) put most of +them in programs that CI builds and runs. + +The API is Box3D's. A function is Box3D's without its `b3` prefix, in snake +case, and a test or sample written against Box3D reads the same here: + +| Box3D | aephysics | +|---|---| +| `b3CreateWorld(&def)` | `dynamics.create_world(&def)` | +| `b3World_Step(id, dt, n)` | `physics_world.step(id, dt, n)` | +| `b3CreateBody(world, &def)` | `dynamics.create_body(world, &def)` | +| `b3Body_GetPosition(id)` | `dynamics.body_get_position(id)` | +| `b3CreateHullShape(body, &def, hull)` | `dynamics.create_hull_shape(body, &def, hull)` | +| `b3RevoluteJoint_SetMotorSpeed(id, s)` | `joint_solver.revolute_joint_set_motor_speed(id, s)` | + +## Contents + +1. [Modules a program imports](#modules-a-program-imports) +2. [Units and ids](#units-and-ids) +3. [Worlds and stepping](#worlds-and-stepping) +4. [Bodies](#bodies) +5. [Shapes](#shapes) +6. [Materials and filtering](#materials-and-filtering) +7. [Joints](#joints) +8. [Events](#events) +9. [Queries](#queries) +10. [Threads](#threads) +11. [Determinism](#determinism) +12. [Ragdolls](#ragdolls) +13. [Snapshots, recording and replay](#snapshots-recording-and-replay) +14. [Debug drawing](#debug-drawing) +15. [Fitting into a host engine](#fitting-into-a-host-engine) +16. [Where aephysics goes past Box3D](#where-aephysics-goes-past-box3d) + +## Modules a program imports + +Aether binds a module by its last name, so `import aephysics.dynamics` is +used as `dynamics.`. Most programs need four or five of these: + +| module | what a program uses it for | +|---|---| +| `aephysics.math` | `Vec3`, `Quat`, `Transform`, `AABB` and their arithmetic (`math.vec3`, `math.quat_identity`, `math.transform`) | +| `aephysics.dynamics` | creating and destroying worlds, bodies, shapes and joints; every body, shape and joint accessor; the definitions and their defaults | +| `aephysics.physics_world` | stepping, the world's settings and callbacks, events, queries, explosions, counters, drawing | +| `aephysics.hull` | convex hulls: boxes, cylinders, cones, rocks, and hulls of any points | +| `aephysics.manifold` | the `Sphere` and `Capsule` values shapes are made from (`manifold.sphere`, `manifold.capsule`) | +| `aephysics.mesh`, `aephysics.height_field`, `aephysics.compound` | triangle meshes, height fields and baked compounds | +| `aephysics.shape` | collision and query filters (`shape.default_filter`, `shape.default_query_filter`) | +| `aephysics.material` | `SurfaceMaterial` and `material.default_surface_material` | +| `aephysics.joint_solver` | each joint kind's accessors: limits, springs, motors, angles, forces | +| `aephysics.human` | the ragdoll | +| `aephysics.snapshot`, `aephysics.recording` | saving and loading worlds; recording and replaying them | +| `aephysics.debug_draw` | the callbacks a renderer draws a world with | +| `aephysics.basics` | the host's allocator, log and assert hooks; the SIMD width | + +The rest (`solver`, `contact_solver`, `broad_phase`, `dynamic_tree`, +`distance`, `sat`, ...) are the engine's own layers. A program can import +them, and the tests do, but nothing above needs to. + +## Units and ids + +Lengths are in metres, masses in kilograms, time in seconds and angles in +radians. The engine is tuned, as Box3D is, for objects between about 10 cm +and 10 m. Positions are double precision, so a world can sit far from the +origin (the tests run a stack and a bullet at x = 10,000 km). + +Gravity defaults to (0, -10, 0). Nothing else assumes which way is up. + +Worlds, bodies, shapes and joints are handed out as ids (`WorldId`, +`BodyId`, `ShapeId`, `JointId`): small values that carry a slot and its +generation. Once its object is destroyed an id fails +`dynamics.body_is_valid` (or `shape_is_valid`, `joint_is_valid`, +`physics_world.is_valid`), even after the slot is reused. Check an id you +kept from an earlier step before you use it. + +## Worlds and stepping + +```aether +world_def = dynamics.default_world_def() +world_def.gravity = math.vec3(0.0, 0.0 - 9.81, 0.0) +world_def.worker_count = 4 +world = dynamics.create_world(&world_def) + +while running { + physics_world.step(world, 1.0 / 60.0, 4) +} + +dynamics.destroy_world(world) +``` + +`step(world, time_step, sub_step_count)` advances the world by +`time_step` seconds, solved in `sub_step_count` sub-steps. Four sub-steps at +60 Hz is Box3D's recommendation and what every benchmark here uses; more +sub-steps make stacks and joint chains stiffer at a proportional cost. +Destroying a world destroys everything in it. + +The world definition's fields, with their defaults: + +| field | default | meaning | +|---|---|---| +| `gravity` | (0, -10, 0) | m/s² | +| `worker_count` | 1 | threads a step runs over (see [Threads](#threads)) | +| `scheduler` | null | a host's job system to step on instead | +| `enable_sleep` | true | resting islands go to sleep | +| `enable_continuous` | true | fast bodies are swept against static geometry | +| `contact_hertz`, `contact_damping_ratio` | 30, 10 | the contact spring | +| `contact_speed` | 3 | the fastest a contact pushes overlapping bodies apart, m/s | +| `restitution_threshold` | 1 | approach speed below which nothing bounces | +| `hit_event_threshold` | 1 | approach speed that makes a hit event | +| `maximum_linear_speed` | 400 | m/s | +| `friction_callback`, `restitution_callback` | geometric mean, maximum | how two materials mix | +| `enable_kinematic_static_softness`, `enable_kinematic_speculation`, `enable_travel_speculation` | false | see [Where aephysics goes past Box3D](#where-aephysics-goes-past-box3d) | + +Most settings can change between steps through `physics_world`: +`enable_sleeping`, `enable_continuous`, `enable_warm_starting`, +`set_gravity`, `set_contact_tuning`, `set_restitution_threshold`, +`set_hit_event_threshold`, `set_maximum_linear_speed`, +`set_worker_count`, and the callbacks `set_friction_callback`, +`set_restitution_callback`, `set_custom_filter_callback` and +`set_pre_solve_callback`. + +`physics_world.get_counters(world)` reports the world's sizes (bodies, +shapes, contacts, joints, islands, the bytes it holds, the tasks it ran), +`get_profile(world)` the milliseconds each stage of the last step took, and +`dump_memory_stats(world)` the memory part by part to the host's log. +`physics_world.explode(world, &def)` pushes bodies away from a point +(`default_explosion_def()`: position, radius, falloff, impulse per area). + +## Bodies + +```aether +body_def = dynamics.default_body_def() +body_def.kind = dynamics.BODY_DYNAMIC +body_def.position = math.vec3(0.0, 2.0, 0.0) +body_def.linear_velocity = math.vec3(1.0, 0.0, 0.0) +body = dynamics.create_body(world, &body_def) +``` + +A body is one of three kinds: + +- `BODY_STATIC` (the default): never moves; the ground and walls. Static + bodies cost nothing to step. +- `BODY_KINEMATIC`: moved by its velocity, unaffected by forces and + contacts; platforms, doors, a character's capsule driven by the game. + `body_set_target_transform(body, target, time_step, wake)` sets the + velocity that reaches a transform in one step. +- `BODY_DYNAMIC`: moved by gravity, forces, contacts and joints. Its mass + comes from its shapes' densities unless set by hand. + +What a body definition holds besides the kind: `position`, `rotation`, +`linear_velocity`, `angular_velocity`, `linear_damping`, +`angular_damping`, `gravity_scale`, `sleep_threshold`, `motion_locks` (any +of the six axes held fixed), `enable_sleep`, `is_awake`, `is_bullet`, +`is_enabled`, `allow_fast_rotation`, `name` and `user_data`. + +The calls on a body, all in `dynamics`: + +| what | calls | +|---|---| +| where it is | `body_get_position`, `body_get_rotation`, `body_get_transform`, `body_set_transform`, `body_get_world_point`, `body_get_local_point`, `body_get_world_center` | +| how it moves | `body_get_linear_velocity`, `body_set_linear_velocity`, `body_get_angular_velocity`, `body_set_angular_velocity`, `body_get_world_point_velocity` | +| pushing it | `body_apply_force(body, force, point, wake)`, `body_apply_force_to_center`, `body_apply_torque`, `body_apply_linear_impulse`, `body_apply_linear_impulse_to_center`, `body_apply_angular_impulse` | +| its mass | `body_get_mass`, `body_get_mass_data`, `body_set_mass_data`, `body_apply_mass_from_shapes`, `body_get_local_rotational_inertia` | +| its behaviour | `body_set_linear_damping`, `body_set_angular_damping`, `body_set_gravity_scale`, `body_set_motion_locks`, `body_set_bullet`, `body_set_type`, `body_disable`, `body_enable` | +| sleep | `body_is_awake`, `body_set_awake`, `body_enable_sleep`, `body_set_sleep_threshold` | +| what it holds | `body_get_shapes`, `body_get_joints`, `body_get_contact_data`, `body_set_user_data`, `body_set_name` | + +Forces last one step; apply them every step they act. A force or impulse +with `wake` false on a sleeping body does nothing. + +A body that moves more than half its size in a step is swept against static +geometry so it cannot tunnel through a wall. A bullet (`is_bullet`) is +swept against dynamic and kinematic bodies too, at more cost. + +## Shapes + +A body holds any number of shapes. Each is made from its geometry and a +shape definition: + +```aether +shape_def = dynamics.default_shape_def() +shape_def.density = 500.0 +shape_def.base_material.friction = 0.4 + +dynamics.create_sphere_shape(body, &shape_def, manifold.sphere(math.vec3_zero(), 0.5)) +dynamics.create_capsule_shape(body, &shape_def, manifold.capsule(math.vec3(0.0, 0.0 - 0.5, 0.0), math.vec3(0.0, 0.5, 0.0), 0.25)) + +box = hull.make_box_hull(0.5, 0.25, 1.0) // half extents +dynamics.create_hull_shape(body, &shape_def, box) +hull.destroy_hull(box) // the world kept its own copy +``` + +The six kinds: + +| kind | geometry | made with | +|---|---|---| +| sphere | a centre and a radius | `manifold.sphere(center, radius)` | +| capsule | two centres and a radius | `manifold.capsule(center1, center2, radius)` | +| convex hull | up to 128 vertices | `hull.make_box_hull`, `make_cube_hull`, `make_offset_box_hull`, `create_cylinder`, `create_cone`, `create_rock`, or `hull.create_hull(points, count, max_vertex_count)` for any points | +| triangle mesh | vertices and triangles under a tree | `mesh.mesh_def()` filled in, then `mesh.create_mesh(&def, null, 0)`; also `create_grid_mesh`, `create_wave_mesh`, `create_box_mesh` | +| height field | a grid of heights with materials and holes | `height_field.height_field_def()` filled in, then `height_field.create_height_field(&def)` | +| compound | spheres, capsules, hulls and meshes baked into one shape | `compound.compound_def()` filled in, then `compound.create_compound(&def)` | + +Meshes and height fields, and a compound's mesh children, collide only with +spheres, capsules and hulls, never with each other, so they belong on +static and kinematic bodies. + +Who owns the geometry: a hull shape holds the world's own copy (equal hulls +share one), so the caller may destroy its hull once the shape is made. A +mesh, height field or compound stays the caller's, and must outlive every +shape made from it; destroy it after the world. A hull shape can also be +made moved and scaled: `create_transformed_hull_shape(body, &def, hull, +transform, scale)`. A mesh shape takes a scale, which may mirror. + +The shape definition's fields: `density` (kg/m³, default 1000), +`base_material` (or `materials` and `material_count` for a mesh's +per-triangle materials), `filter`, `is_sensor`, the event switches +`enable_contact_events`, `enable_hit_events`, `enable_sensor_events` and +`enable_pre_solve_events` (all off by default), `enable_custom_filtering`, +`update_body_mass`, `name` and `user_data`. + +A sensor shape (`is_sensor`) detects overlaps without colliding. It reports +only when it has `enable_sensor_events`, and then only the shapes that have +it too. + +After creation, a shape's density, filter, friction, restitution and +material can change (`shape_set_density`, `shape_set_filter`, +`shape_set_friction`, `shape_set_restitution`, +`shape_set_surface_material`), and so can its geometry +(`shape_set_sphere`, `shape_set_capsule`, `shape_set_hull`, +`shape_set_mesh`). `destroy_shape(shape, update_body_mass)` removes one. + +## Materials and filtering + +A `SurfaceMaterial` has `friction` (default 0.6), `restitution` (0), +`rolling_resistance` (spheres and capsules), `tangent_velocity` (a +conveyor's surface speed in the shape's frame), `user_material_id` (passed +through to events and query results) and `custom_color` for drawing. Two +touching materials mix through the world's friction and restitution +callbacks, by default the geometric mean of the frictions and the larger +restitution. + +A shape's `Filter` decides what it collides with: `category_bits` (what it +is), `mask_bits` (what it collides with) and `group_index`. Two shapes +collide when each one's category is in the other's mask. A shared positive +group always collides, a shared negative group never does; a group overrides +the bits. + +Two bodies joined by a joint do not collide unless the joint's +`collide_connected` is set. For anything else, a shape with +`enable_custom_filtering` is passed to the world's custom filter callback +(`physics_world.set_custom_filter_callback(world, fn, context)`), which +answers per pair. A pre-solve callback (`set_pre_solve_callback`) sees each +contact of a shape with `enable_pre_solve_events` before it is solved and +can switch it off for that step: one-way platforms. + +## Joints + +A joint ties two bodies. Each kind has a definition whose `base` holds what +every joint has: the two bodies, a frame on each (`local_frame_a`, +`local_frame_b`, in each body's own coordinates), `collide_connected`, the +force and torque thresholds for joint events, and the constraint's +stiffness. + +```aether +hinge_def = dynamics.default_revolute_joint_def() +hinge_def.base.body_id_a = door_frame +hinge_def.base.body_id_b = door +hinge_def.base.local_frame_a.p = math.vec3(0.5, 1.0, 0.0) +hinge_def.base.local_frame_b.p = math.vec3(0.0 - 0.5, 0.0, 0.0) +hinge_def.enable_limit = true +hinge_def.lower_angle = 0.0 +hinge_def.upper_angle = 1.5 +hinge = dynamics.create_revolute_joint(world, &hinge_def) + +joint_solver.revolute_joint_enable_motor(hinge, true) +joint_solver.revolute_joint_set_motor_speed(hinge, 0.5) +``` + +The kinds, each with `default__joint_def` and `create__joint`: + +| kind | what it does | +|---|---| +| distance | keeps two points apart: rigid, or a spring with a length range, a motor | +| motor | drives B toward a target offset from A: a force and torque budget, springs | +| filter | no constraint; only stops the two bodies colliding | +| parallel | keeps B's axis parallel to A's, with a spring and a torque cap | +| prismatic | slides along an axis: limits, a spring, a motor | +| revolute | a hinge about frame A's z axis: limits, a spring toward a target angle, a motor | +| spherical | a ball joint: a cone limit about frame A's z axis, a twist limit, a spring toward a target rotation, a motor | +| weld | holds B fixed to A, rigid or springy | +| wheel | a suspension along an axis, a spinning motor, steering with its own spring and limits | + +The kinds' accessors are in `joint_solver`, named for the kind: +`revolute_joint_get_angle`, `prismatic_joint_set_limits`, +`spherical_joint_set_cone_limit`, `wheel_joint_set_spin_motor_speed`, and +so on. The calls common to every joint are in `dynamics`: +`joint_get_body_a`, `joint_set_local_frame_a`, `joint_set_collide_connected`, +`joint_wake_bodies`, `joint_set_constraint_tuning`, `destroy_joint(joint, +wake_attached)`. `joint_solver.joint_get_constraint_force` and +`joint_get_constraint_torque` give what the joint applied in the last step. + +Limits and springs are soft constraints, as in Box3D. A joint driven past +its limit by a motor, or loaded steadily, settles slightly past it, by the +torque over its stiffness. The stiffness is `constraint_hertz` (60 by +default), capped at a quarter of the sub-step rate. Raising it, or the +sub-step count, holds limits tighter. + +## Events + +Events are collected during a step and read after it, from `physics_world`. +They stay readable until the next step. + +| event | count and read | the shapes or bodies that ask | +|---|---|---| +| contact began touching | `contact_begin_event_count`, `contact_begin_event` | either shape has `enable_contact_events` | +| contact stopped touching | `contact_end_event_count`, `contact_end_event` | either shape has `enable_contact_events` | +| hit: a contact that closed faster than the hit threshold | `contact_hit_event_count`, `contact_hit_event` | either shape has `enable_hit_events` | +| a shape entered a sensor | `sensor_begin_event_count`, `sensor_begin_event` | both the sensor and the shape have `enable_sensor_events` | +| a shape left a sensor | `sensor_end_event_count`, `sensor_end_event` | both the sensor and the shape have `enable_sensor_events` | +| a body moved | `body_event_count`, `body_event` | every awake body that moved | +| a joint pulled harder than its threshold | `joint_event_count`, `joint_event` | joints with a force or torque threshold | + +```aether +k = 0 +while k < physics_world.contact_hit_event_count(world) { + hit = physics_world.contact_hit_event(world, k) + // hit.shape_id_a, hit.shape_id_b, hit.point, hit.normal, hit.approach_speed + k = k + 1 +} +``` + +A body move event carries the body's id, its new transform, its user data +and whether it fell asleep: the one list a renderer needs to update what +moved. + +## Queries + +The world answers queries between steps, from any thread. Every query takes +a `QueryFilter` (`shape.default_query_filter()` sees everything; its +`category_bits` and `mask_bits` work as a shape's filter does). + +| query | call | +|---|---| +| the closest hit along a ray | `cast_ray_closest(world, origin, translation, filter)` returns a `RayResult` (`hit`, `shape_id`, `point`, `normal`, `fraction`, `user_material_id`) | +| every hit along a ray | `cast_ray(world, origin, translation, filter, visitor, context)` | +| shapes whose bounds overlap a box | `overlap_aabb(world, box, filter, visitor, context)` | +| shapes that overlap a convex proxy | `overlap_shape(world, origin, proxy, filter, visitor, context)` | +| a convex proxy swept along a line | `cast_shape(world, origin, proxy, translation, filter, visitor, context)` | +| a character capsule swept, and the planes it rests on | `cast_mover`, `collide_mover` | +| the same against one body at a transform of your choosing | `body_cast_ray`, `body_cast_shape`, `body_overlap_shape`, `body_collide_mover` | + +A visitor is a plain function. An overlap visitor +`fn(ShapeId, ptr) -> bool` returns false to stop. A cast visitor +`fn(ShapeId, point, normal, fraction, user_material_id, triangle_index, +child_index, ptr) -> float` returns where to clip the cast: the hit's +fraction to find the closest, 1 to collect every hit, 0 to stop, -1 to +ignore this shape. The `context` pointer is passed to every call: + +```aether +count_shape(id: ShapeId, context: ptr) -> bool { + count = context as int[] + count[0] = count[0] + 1 + return true +} + +found = 0 +physics_world.overlap_aabb(world, box, shape.default_query_filter(), count_shape, (&found) as ptr) +``` + +A proxy is a convex point cloud with a radius: +`distance.shape_proxy(points, count, radius)`. One point and a radius is a +sphere; two points a capsule. + +The character mover is Box3D's: `cast_mover` sweeps a capsule and returns +the fraction it can travel, `collide_mover` gathers the planes it touches, +and `mover.solve_planes` and `mover.clip_vector` turn those into a +movement that slides along walls and stands on slopes. + +## Threads + +`world_def.worker_count` sets how many threads a step uses; 1, the default, +is the calling thread alone and never makes a thread. With more, the world +makes its worker threads when it is created and parks them between steps. +`native.processor_count()` gives the machine's count, and +`physics_world.set_worker_count` changes it between steps. + +The narrow phase, the solver's stages, the bodies' finalize and the bullets +are split over the workers in Box3D's way. The result does not depend on +the count: a world steps to the same bits on one worker or twenty-four. + +A host with its own job system lends it instead: create an +`aephysics.parallel` scheduler, keep it, and set `world_def.scheduler` to +it. The scheduler's worker count then rules. + +Queries may run on any number of threads at once, but not while that world +steps. Each thread takes one of 64 scratch slots the first time it queries, +and gives it back when it exits. + +## Determinism + +The same world stepped the same way gives the same bits: + +- every run, on every platform CI covers (Linux and Windows x86-64, macOS + arm64), optimized or not; +- at any worker count, and at either SIMD width; +- after a snapshot is saved and loaded. + +`dynamics.hash_world_state(physics_world.world_of(world))` is a hash of +every body's state, for checking that two runs agree. + +That holds only if the C compiler never fuses a multiply and an add into +one rounding, which GCC and Clang do by default wherever the target has +FMA. A program that builds aephysics in must pass `-ffp-contract=off` in its +own `aether.toml`, since `ae` reads only the root project's C flags. The +engine's trigonometry is its own (Box3D's approximations), and its square +root is std.math's, exact at every optimization level. + +The bits are the same across platforms but not across engine versions: a +change that alters results says so in its commit and regenerates the +golden trace. + +## Ragdolls + +`aephysics.human` is Box3D's ragdoll, grown for games: twelve capsule bones +(pelvis, two spine bones, the neck and head, thighs, calves, upper and lower +arms) on spherical and revolute joints. + +```aether +figure = Human { bones: null, filter_joints: null, filter_joint_count: 0, friction_torque: 0.0, is_spawned: false, ankles: false, bone_count: 0 } +human.create_human(&figure, world, feet_position, 5.0, 1.0, 0.7, 1, null) +// joint friction 5 N m, springs toward the standing pose at 1 Hz and a +// damping ratio of 0.7, and filter group 1 (see below) + +pelvis = human.bone_at(&figure, human.BONE_PELVIS).body_id +human.destroy_human(&figure) +``` + +- **Self-collision.** The lower spine and the thighs share the negative + filter group of the index given, so they never collide with each other, + and filter joints keep the other limbs that would clash apart. Give each + figure its own index, or two figures' hips pass through each other. +- **Joint ranges.** By default the joints have a person's ranges: shoulders + that reach overhead, hips on an elliptical cone, knees and elbows that + never pass straight. `human_tight_limits(shape)` puts Box3D's tighter + ones on a figure's shape before `create_human_shaped`. +- **Feet.** With `figure.ankles = true` before creation, the feet are bones + of their own on elliptical ankle cones, so a crouched figure's soles lie + flat. +- **Pose drive.** Every joint has a spring toward a target + (`human_set_joint_spring_hertz`, `human_set_joint_damping_ratio`) and a + motor whose torque limit is the joint's friction + (`human_set_joint_friction_torque`). Setting the targets from an + animation each step drives the ragdoll. +- **Anchors.** `human_create_motor_anchors` hangs every bone from a + kinematic anchor through a motor joint; move the anchors with + `human_set_anchor_target` and the figure follows, with the world pushing + back. `human_create_parallel_anchors` drives rotation only, so the figure + falls while it keeps its shape. `human_align_spring` springs the pelvis + upright. +- **A figure fitted to a rig.** `human_reference_shape` gives the figure as + data, `human_fit_shape` moves its joints and bones onto a rig's + skeleton, and `create_human_shaped` builds the fitted figure. + +## Snapshots, recording and replay + +A snapshot is a whole world as bytes: settings, bodies, shapes and their +geometry, contacts with their warm starts, joints, islands, the trees. +A loaded world steps on as the one it was taken from, bit for bit. + +```aether +data, size = snapshot.save_world(world) +copy = snapshot.load_world(&world_def, data, size) +ok = snapshot.restore_world(world, &world_def, data, size) // into an existing world, its id kept +snapshot.free_snapshot(data, size) +``` + +A snapshot of another layout, or one damaged in any byte, loads no world. + +A recording keeps every call that changes a world, field by field, with a +state hash after each step and every query with its answers. + +```aether +rec = recording.create_recording() +recording.start_recording(world, rec) +// ... create, step, query ... +recording.stop_recording(world) +recording.save_recording_to_file(rec, "session.rec") + +player = recording.create_player(recording.recording_data(rec), recording.recording_size(rec), 1) +frames = 0 +while recording.player_step_frame(player) { frames = frames + 1 } +diverged = recording.player_has_diverged(player) +recording.destroy_player(player) +recording.destroy_recording(rec) +``` + +The player replays into a world of its own (`player_world`), checks every +step's hash and every created id against the recording, and asks every +recorded query again. It seeks (`player_seek_frame`) through keyframes kept +under a memory budget. A recording made on several workers replays on one, +and the other way round. + +## Debug drawing + +`debug_draw.default_debug_draw()` gives a `DebugDraw` whose callbacks a +renderer fills in (segments, transforms, points, spheres, capsules, boxes, +strings, its own drawable per shape) and whose switches choose what to +draw: shapes, joints, contacts with their normals and forces, bounds, +islands, names and masses. `physics_world.world_draw(world, &draw, +mask_bits)` walks the world. The world definition's `create_debug_shape` +and `destroy_debug_shape` give a renderer its own mesh per shape, made once +and released when the shape, its geometry or the world goes. + +## Fitting into a host engine + +- **Memory.** `basics.set_allocator(allocate, release)` routes every block + the engine makes through the host. `physics_world.get_counters` and + `dump_memory_stats` say where the memory went. +- **Logging and failure.** `basics.set_log_fn` takes the engine's messages + and `basics.set_assert_fn` its one hard stop. Bad input (a NaN, a negative + radius, a hull of points that make no hull) is refused, never asserted: + the call does nothing and the world stays as it was. +- **Shared types.** Aether keeps struct names in one namespace across + modules. aephysics' math types (`Vec2`, `Vec3` as `{x, y, z}`, `Quat` as + `{x, y, z, w}`, `Transform`) are the same definitions ae3d's core uses, + so a transform crosses between the two without conversion. A host must + not define its own `World`, `Body`, `Plane` or `Buffer`; ae3d names its + own `VoxelWorld`, `FrustumPlane`, `GltfBuffer`. +- **User data.** Worlds, bodies, shapes and joints each carry a `user_data` + pointer and a name. +- **The C file.** `aephysics/native/aephysics_native.c` (threads, atomics, + the semaphore) is named by `aephysics.native` with `@source`, so `ae` + builds it into any program that imports the engine. A host that calls gcc + itself compiles it too, with the same `-ffp-contract=off`. +- **SIMD width.** A world runs its wide contact stages and its separating + axis test eight lanes at a time, on AVX2 where the CPU has it. + `basics.set_simd_width(4)` before a world is made forces the four-lane + path, and `physics_world.enable_sse2_fallback` switches an existing + world. Both give the same bits. + +## Where aephysics goes past Box3D + +The engine is Box3D's, with these additions. Each is off by default where +it changes results, so Box3D's own scenes give Box3D's checksums. + +- **Double positions.** Box3D keeps double world positions only in its + large-world build; here they are always double, so a transform is one + `Vec3` and one `Quat`. The solver's own data stays single precision, as + Box3D's. +- **Kinematic speculation** (`enable_kinematic_speculation`): a fast + kinematic body's contacts reach out over its travel in the step, so what + it strikes is brought up to speed in the step the gap closes instead of + being found inside it on the next. +- **Travel speculation** (`enable_travel_speculation`): the same for every + moving body, for fast dynamic bodies below Box3D's continuous threshold, + such as a car. +- **Kinematic-static softness** (`enable_kinematic_static_softness`): a + kinematic body's contacts take the stiffer static softness, so a body the + game drives hard does not sink into what it pushes. +- **Hulls that settle on an edge.** A hull resting on an edge or vertex + rocked forever in Box3D, in a 400-step cycle. Its face contact here also + takes the vertices already within the speculative distance, so it comes + to rest. A box resting flat has no such vertex, so stacks are unchanged. +- **Elliptical and off-axis cones** on spherical joints + (`spherical_joint_set_cone_ellipse`, `spherical_joint_set_cone_axis`), + for hips and shoulders. A circle about z stays the default, bit for bit. +- **Mesh queries without a cap.** Box3D's mesh contact fills a buffer of 256 + triangles and warns; here the buffer grows. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..0c2116a --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,158 @@ +# Testing and benchmarks + +aephysics is held to two references. Box3D's own tests are ported, at +their tolerances, for every layer that has them; where Box3D tests a layer +only through its world, the test here is our own, usually against a brute +force. And every layer is timed against the same code in Box3D, built on +the same machine. + +## Running the tests + +```sh +scripts/test.sh +``` + +What it runs, in order, counting every failure and exiting non-zero if +there is any: + +1. `ae check` on every module, whole. A warning fails it too. +2. Every benchmark built, not run, so an API change cannot leave one + behind. +3. Every `tests/test_*.ae` built and run. Each prints its checks and exits + non-zero on any failure. +4. Every `examples/*.ae` built and run. +5. `scripts/golden_trace.sh check`: the determinism trace (below). + +`AETHER_AEPHYSICS_CFLAGS` adds C flags to every build: `-mavx2` makes the +wide stages one AVX2 register in the plain copy too, `-O0` checks the +unoptimized build. + +## The tests + +**Foundation** + +| test | what it holds the engine to | +|---|---| +| `test_math` | Box3D's test_math.c: the deterministic atan2, cosine and sine against libm within their stated accuracy; vectors, quaternions, transforms and matrices | +| `test_basics` | Box3D's test_bitset.c, test_id.c, test_table.c, test_container.c and test_allocator.c; the name cache with Box3D's own hash values | +| `test_hash` | Box3D's test_hash.c's properties on the engine's content hash | +| `test_parallel` | tasks run as the worker they were given; `parallel_for` covers every item of a range exactly once, however the blocks fall | +| `test_host_api` | a host's allocator takes every block a world on four workers makes, and gets each back; the log, the counters, the version | + +**Geometry** + +| test | what it holds the engine to | +|---|---| +| `test_dynamic_tree` | Box3D's test_dynamic_tree.c: queries against a linear scan, refit and rebuild, save and load, ten corruptions refused, centimetre boxes at 10,000 km | +| `test_hull` | Box3D's test_hull.c: analytic cubes and tetrahedra, the vertex limit, redundant input, the stress builds | +| `test_distance` | Box3D's test_distance.c, plus spheres and capsules at analytic distances and warm against cold caches | +| `test_manifold` | Box3D's test_sat.c (the separating axis against a brute-force oracle over 7,000 random pairs) and test_manifold.c's hull, sphere and capsule parts | +| `test_triangle_manifold` | the triangle parts of Box3D's test_manifold.c | +| `test_mesh` | Box3D's test_mesh.c: strided, welded and clockwise meshes; rays and casts against analytic hits | +| `test_height_field` | Box3D's test_height_field.c, casts against a brute force over a wave, its files read and written in Box3D's own format | +| `test_compound` | Box3D's test_compound.c, the byte round trip, ten corruptions refused | +| `test_shape` | Box3D's test_shape.c: masses, bounds, rays, the far-origin precision | +| `test_collision` | Box3D's test_collision.c: box validity, a ray against a box in every case, fat bounds at 10,000 km | +| `test_mover` | Box3D's test_mover.c: the plane solver and the shapes' mover collisions | + +**Collision and the world** + +| test | what it holds the engine to | +|---|---| +| `test_broad_phase` | the pairs found against a brute force through every kind of proxy change | +| `test_mesh_contact` | the four-point cull, the triangle cache, spheres across seams, height fields | +| `test_dynamics` | the step-free parts of Box3D's test_body.c, test_world.c and test_joint.c: bodies, shapes, contacts, islands, sleep, joints, sensors | +| `test_sensor_overlaps` | 80 sensors against 240 visitors of every kind, against brute force | +| `test_world_queries` | 300 bodies of every kind; 3,000 rays, overlaps and casts against brute force | +| `test_threads` | four threads querying meshes, height fields, compounds and hulls at once, every answer the serial one | +| `test_mover_world` | the world parts of Box3D's test_mover.c | + +**Solving** + +| test | what it holds the engine to | +|---|---| +| `test_contact_solver` | a cube on a slab, the solver's passes run by hand with Box3D's numbers | +| `test_contact_solver_wide` | one scene solved one contact at a time and eight at a time lands in the same place | +| `test_joint_solver` | every kind's accessors as Box3D's test_joint.c uses them, every accessor asked of the wrong kind or a stale id, each kind solved by hand | +| `test_joint_frames` | five joint kinds with their frames turned every way on both bodies, 60 of each, every one held | +| `test_solver` | free fall against the closed form, sleeping, a stack, a bounce, a pendulum, a fast sphere stopped by a thin wall, a bullet, two worlds alike | + +**Whole scenes** + +| test | what it holds the engine to | +|---|---| +| `test_physics_world` | Box3D's test_world.c and test_body_query.c whole: HelloWorld, events, the explosion near and ten million metres away, the hull database | +| `test_determinism` | Box3D's determinism scenes (a wave pile, a query spawn, a mesh drop, falling ragdolls, a rolling mix), each run twice, on four workers and on a host's scheduler, at both SIMD widths, compared bit for bit | +| `test_large_world` | Box3D's test_large_world.c: a stack, a bullet and the queries at x = 0 and x = 10,000 km agree | +| `test_restitution` | Box3D's test_restitution.c whole; its numbers are Box3D's own to every printed digit | +| `test_edge_cases` | every number a world takes refused when it is NaN, infinite or out of range, the world left as it was; degenerate geometry; heavy chains, perfect bounces, free spins | +| `test_soak` | seeded piles of every convex kind thrown into a pen for fifteen seconds: no energy made, nothing through a wall, everything asleep in the end | +| `test_human` | the ragdoll's shape, its fall to rest in one piece, its joint ranges measured through the joints, the pose held on anchors | +| `test_snapshot` | a world of every kind of thing saved at four moments and stepped 150 steps beside its original, the same hash every step; every corruption refused | +| `test_recording` | Box3D's test_recording.c: every one of 160 calls recorded and replayed, seeking, query replay, names, file round trips, every corruption caught | +| `test_debug_draw` | every shape drawn once in its colour, every option counted against the world, the host's drawables made and released | + +## The determinism trace + +```sh +AEPHYSICS_TRACE=1 target/test_determinism # prints every scene's checksum after every step +scripts/golden_trace.sh check # diffs that against tests/golden/determinism_trace.txt +scripts/golden_trace.sh write # regenerates it +``` + +The trace is 1,250 lines, one per step of the five determinism scenes, +each a checksum of every body's state with its exact bits, and every +platform CI runs gives the same file. A change that alters +results on purpose regenerates the file and says why in its commit; a +change that alters it by accident is a bug. + +## CI + +`.github/workflows/test.yaml` runs on every pull request and every push to +`main`: + +| job | what it runs | +|---|---| +| Linux | `scripts/test.sh`, then the determinism, wide solver, soak and solver tests again with `-mavx2 -mfma` and the trace checked | +| macOS arm64 | `scripts/test.sh`, where the C compiler would fuse multiply-adds without `-ffp-contract=off` | +| Windows x86-64 | `scripts/test.sh` under MSYS2 UCRT64, then `test_determinism` at `-O0` with the trace checked | + +CI installs one Aether release, pinned in the workflow's `env`. Moving the +pin is a pull request of its own, green on every job. + +## Benchmarks + +The references are fetched and built first, once: + +```sh +scripts/fetch_references.sh # Box3D at the pinned commit and Jolt, into the ignored reference/ +``` + +Then: + +```sh +scripts/bench.sh tree # one layer: bench/tree.ae against bench/tree_box3d.c +scripts/bench.sh suite # Box3D's benchmark suite: its scenes against ours +scripts/bake.sh 300 # the bake-off: Box3D and Jolt on identical scenes +``` + +Every pair runs the same scene through the same code path in both engines, +one thread unless stated, and prints the checksums that show the two did +the same work. `bench/physics_world.ae` reads `AEPHYSICS_SIMD_WIDTH` (4 or +8) to time one width. Results go in [bench/RESULTS.md](../bench/RESULTS.md) +with the date, the machine and the build, ratios read within one session: +the machine's speed wanders between sessions, the ratios do not. + +## Profiling + +On Windows, where gprof and perf are not available under MinGW: + +```sh +scripts/profile.sh bench/physics_world.ae 30 # the 30 hottest functions +``` + +It links the program with `tools/sampler.c`, a thread that records the main +thread's instruction pointer every half millisecond, and ranks the samples +by symbol with `tools/rank.ae`. Unlike an instrumented profiler it costs +the small maths nothing, so the picture is not distorted. On Linux, +`ae build --profile` and perf do the same job. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f361dc8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Examples + +Small, complete programs. `scripts/test.sh` builds and runs every one, so +they keep working as the engine changes. + +| program | shows | +|---|---| +| [hello_world.ae](hello_world.ae) | Box3D's HelloWorld: a world, a static ground, a dynamic box, stepping, reading a position | +| [events_and_queries.ae](events_and_queries.ae) | a chain of capsules on spherical joints, contact and hit events from a bouncing ball, a ray cast and a box overlap with a visitor | +| [ragdoll_and_snapshot.ae](ragdoll_and_snapshot.ae) | the ragdoll shoved over, its world saved as a snapshot mid-fall and loaded into a second world, both stepped on to the same bits | + +From the repository root: + +```sh +ae build examples/hello_world.ae -o target/hello_world && target/hello_world +``` + +[The guide](../docs/guide.md) explains every call they make. diff --git a/examples/events_and_queries.ae b/examples/events_and_queries.ae new file mode 100644 index 0000000..5e6fdc2 --- /dev/null +++ b/examples/events_and_queries.ae @@ -0,0 +1,110 @@ +// Joints, events and queries: a chain of capsules hung from a static +// anchor swings down, a ball bounces beside it and reports its hits, +// then a ray and a box ask the world what is where. +// +// ae build examples/events_and_queries.ae -o target/events_and_queries && target/events_and_queries +import aephysics.math +import aephysics.hull +import aephysics.manifold +import aephysics.shape +import aephysics.dynamics +import aephysics.physics_world + +const LINK_COUNT = 6 +const LINK_HALF_LENGTH = 0.25 + +// The box query's visitor: count every shape it is shown and keep going +// (return false to stop the query). +count_shape(id: ShapeId, context: ptr) -> bool { + count = context as int[] + count[0] = count[0] + 1 + return true +} + +main() { + world_def = dynamics.default_world_def() + world_def.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + world = dynamics.create_world(&world_def) + + // The ground, its top face at y = 0. + ground_def = dynamics.default_body_def() + ground_def.position = math.vec3(0.0, 0.0 - 1.0, 0.0) + ground = dynamics.create_body(world, &ground_def) + slab = hull.make_box_hull(20.0, 1.0, 20.0) + ground_shape_def = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &ground_shape_def, slab) + + // A chain held out level from an anchor 6 m up: each link a capsule + // along x, joined to the one before at its end by a spherical joint. + // A joint's frames are in each body's own frame. + anchor_def = dynamics.default_body_def() + anchor_def.position = math.vec3(0.0, 6.0, 0.0) + previous = dynamics.create_body(world, &anchor_def) + previous_end = math.vec3_zero() + link = manifold.capsule(math.vec3(0.0 - LINK_HALF_LENGTH, 0.0, 0.0), math.vec3(LINK_HALF_LENGTH, 0.0, 0.0), 0.08) + link_shape_def = dynamics.default_shape_def() + i = 0 + while i < LINK_COUNT { + link_def = dynamics.default_body_def() + link_def.kind = dynamics.BODY_DYNAMIC + link_def.position = math.vec3(LINK_HALF_LENGTH * ((2 * i + 1) as float), 6.0, 0.0) + body = dynamics.create_body(world, &link_def) + dynamics.create_capsule_shape(body, &link_shape_def, link) + joint_def = dynamics.default_spherical_joint_def() + joint_def.base.body_id_a = previous + joint_def.base.body_id_b = body + joint_def.base.local_frame_a.p = previous_end + joint_def.base.local_frame_b.p = math.vec3(0.0 - LINK_HALF_LENGTH, 0.0, 0.0) + dynamics.create_spherical_joint(world, &joint_def) + previous = body + previous_end = math.vec3(LINK_HALF_LENGTH, 0.0, 0.0) + i = i + 1 + } + + // A ball dropped 5 m. Its shape asks for contact and hit events, + // which are off by default. + ball_def = dynamics.default_body_def() + ball_def.kind = dynamics.BODY_DYNAMIC + ball_def.position = math.vec3(0.0 - 3.0, 5.0, 0.0) + ball = dynamics.create_body(world, &ball_def) + ball_shape_def = dynamics.default_shape_def() + ball_shape_def.base_material.restitution = 0.5 + ball_shape_def.enable_contact_events = true + ball_shape_def.enable_hit_events = true + dynamics.create_sphere_shape(ball, &ball_shape_def, manifold.sphere(math.vec3_zero(), 0.5)) + + // Three seconds. A step's events are read after it and last until the + // next step. + began = 0 + step = 0 + while step < 180 { + physics_world.step(world, 1.0 / 60.0, 4) + began = began + physics_world.contact_begin_event_count(world) + k = 0 + while k < physics_world.contact_hit_event_count(world) { + hit = physics_world.contact_hit_event(world, k) + println("step ${step}: the ball hit the ground at ${hit.approach_speed} m/s") + k = k + 1 + } + step = step + 1 + } + println("${began} contacts began touching") + tip = dynamics.body_get_position(previous) + println("the chain's last link hangs at (${tip.x}, ${tip.y})") + + // The closest shape along a ray straight down from above the ball. + filter = shape.default_query_filter() + ray = physics_world.cast_ray_closest(world, math.vec3(0.0 - 3.0, 10.0, 0.0), math.vec3(0.0, 0.0 - 20.0, 0.0), filter) + if ray.hit { + println("the ray hit y = ${ray.point.y}, ${ray.fraction} of the way along") + } + + // Every shape whose bounds touch a box around the chain. + found = 0 + box = math.aabb(math.vec3(0.0 - 1.0, 2.0, 0.0 - 1.0), math.vec3(1.0, 7.0, 1.0)) + physics_world.overlap_aabb(world, box, filter, count_shape, (&found) as ptr) + println("${found} shapes in the box around the chain") + + dynamics.destroy_world(world) + hull.destroy_hull(slab) +} diff --git a/examples/hello_world.ae b/examples/hello_world.ae new file mode 100644 index 0000000..890705b --- /dev/null +++ b/examples/hello_world.ae @@ -0,0 +1,54 @@ +// A box dropped on the ground: Box3D's HelloWorld. +// +// ae build examples/hello_world.ae -o target/hello_world && target/hello_world +import aephysics.math +import aephysics.hull +import aephysics.dynamics +import aephysics.physics_world + +main() { + // A world with gravity along -y. + world_def = dynamics.default_world_def() + world_def.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + world = dynamics.create_world(&world_def) + + // The ground: a static body (the default kind) holding a box 100 m + // wide and 20 m tall, its top face at y = 0. + ground_def = dynamics.default_body_def() + ground_def.position = math.vec3(0.0, 0.0 - 10.0, 0.0) + ground = dynamics.create_body(world, &ground_def) + ground_box = hull.make_box_hull(50.0, 10.0, 50.0) + ground_shape_def = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &ground_shape_def, ground_box) + + // A dynamic body holding a 2 m cube, 4 m up. + body_def = dynamics.default_body_def() + body_def.kind = dynamics.BODY_DYNAMIC + body_def.position = math.vec3(0.0, 4.0, 0.0) + body = dynamics.create_body(world, &body_def) + cube = hull.make_box_hull(1.0, 1.0, 1.0) + shape_def = dynamics.default_shape_def() + shape_def.density = 1.0 + shape_def.base_material.friction = 0.3 + dynamics.create_hull_shape(body, &shape_def, cube) + + // A second and a half at 60 Hz, four sub-steps a step. + step = 0 + while step < 90 { + physics_world.step(world, 1.0 / 60.0, 4) + if step % 15 == 0 { + p = dynamics.body_get_position(body) + println("step ${step}: the cube's centre at y = ${p.y}") + } + step = step + 1 + } + p = dynamics.body_get_position(body) + println("at rest: y = ${p.y} (the cube's half height above the ground)") + + // The world frees its bodies and shapes. A hull shape keeps the + // world's own copy of its hull, so ours could have gone as soon as + // the shapes were made. + dynamics.destroy_world(world) + hull.destroy_hull(ground_box) + hull.destroy_hull(cube) +} diff --git a/examples/ragdoll_and_snapshot.ae b/examples/ragdoll_and_snapshot.ae new file mode 100644 index 0000000..52852f2 --- /dev/null +++ b/examples/ragdoll_and_snapshot.ae @@ -0,0 +1,74 @@ +// A ragdoll shoved over, its world saved mid-fall, and the save loaded +// into a second world: the two step on to the same bits. +// +// ae build examples/ragdoll_and_snapshot.ae -o target/ragdoll_and_snapshot && target/ragdoll_and_snapshot +import aephysics.math +import aephysics.hull +import aephysics.dynamics +import aephysics.physics_world +import aephysics.human +import aephysics.snapshot + +step_for(world: WorldId, steps: int) { + i = 0 + while i < steps { + physics_world.step(world, 1.0 / 60.0, 4) + i = i + 1 + } +} + +// Every body's state in one number, the hash the determinism tests use. +state_hash(world: WorldId) -> long { return dynamics.hash_world_state(physics_world.world_of(world)) } + +main() { + world_def = dynamics.default_world_def() + world_def.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + world = dynamics.create_world(&world_def) + + ground_def = dynamics.default_body_def() + ground_def.position = math.vec3(0.0, 0.0 - 1.0, 0.0) + ground = dynamics.create_body(world, &ground_def) + slab = hull.make_box_hull(20.0, 1.0, 20.0) + ground_shape_def = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &ground_shape_def, slab) + + // Twelve capsule bones on spherical and revolute joints with a + // person's ranges, its feet 1 m up. The joints' friction is 5 N m, + // their springs toward the standing pose 1 Hz at a damping ratio of + // 0.7. Filter group 1 keeps its lower spine and thighs out of each + // other; a second figure would take group 2. + figure = Human { bones: null, filter_joints: null, filter_joint_count: 0, friction_torque: 0.0, is_spawned: false, ankles: false, bone_count: 0 } + human.create_human(&figure, world, math.vec3(0.0, 1.0, 0.0), 5.0, 1.0, 0.7, 1, null) + human.human_apply_angular_impulse(&figure, math.vec3(0.0, 0.0, 30.0)) + + step_for(world, 30) + pelvis = dynamics.body_get_position(human.bone_at(&figure, human.BONE_PELVIS).body_id) + println("after half a second the pelvis is at y = ${pelvis.y}") + + // The whole world as bytes: bodies, shapes, contacts with their warm + // starts, joints, islands, the broad phase. + data, size = snapshot.save_world(world) + println("snapshot: ${size} bytes") + copy = snapshot.load_world(&world_def, data, size) + snapshot.free_snapshot(data, size) + + // Both worlds two more seconds on. + step_for(world, 120) + step_for(copy, 120) + a = state_hash(world) + b = state_hash(copy) + println("the original's hash ${a}, the copy's ${b}") + if a == b { + println("the same, bit for bit") + } else { + println("they differ") + exit(1) + } + + human.destroy_human(&figure) + dynamics.destroy_world(copy) + dynamics.destroy_world(world) + hull.destroy_hull(slab) +} + +extern exit(code: int) diff --git a/scripts/test.sh b/scripts/test.sh index fc1575c..87b0808 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build and run every test in tests/ with ae: what CI runs through aeb, -# runnable anywhere ae is. +# Build and run every test in tests/ and every program in examples/ with +# ae: what CI runs, runnable anywhere ae is. # # scripts/test.sh set -uo pipefail @@ -53,6 +53,26 @@ for test in tests/test_*.ae; do failures=$((failures + 1)) fi done +# The examples the README and docs/guide.md point to, built and run, so +# the documentation's programs keep working. +for example in examples/*.ae; do + name="$(basename "$example" .ae)" + if ! AETHER_LIB_DIR="$root" ae build "$example" -o "target/example_$name" >"target/example_$name.log" 2>&1; then + echo "FAIL example $name (build)" + grep -i "error" "target/example_$name.log" | head -5 + failures=$((failures + 1)) + continue + fi + exe="target/example_$name" + [ -x "$exe" ] || exe="$exe.exe" + if "$exe" >"target/example_$name.out" 2>&1; then + echo "ok example $name" + else + echo "FAIL example $name" + tail -5 "target/example_$name.out" + failures=$((failures + 1)) + fi +done # The determinism scenes' every step against the golden trace: the same # bits on every platform and build (#125). A deliberate change of results # regenerates it with scripts/golden_trace.sh and says why in its commit. diff --git a/tests/test_contact_solver_wide.ae b/tests/test_contact_solver_wide.ae index fc5a872..a9ab851 100644 --- a/tests/test_contact_solver_wide.ae +++ b/tests/test_contact_solver_wide.ae @@ -1,14 +1,12 @@ -// aephysics.contact_solver_wide's checks: the native lanes' layout is -// the module's; one scene stepped three ways (the convex contacts -// solved one at a time, four at a time in Aether's lanes, four at a -// time in the native lanes) lands in the same place: a stack of boxes -// (contacts in colours with a tail slot, dynamic pairs and static -// ones), a bouncing ball with restitution and hit events, a rolling -// sphere with rolling resistance, a box on a conveyor's tangent -// velocity, a box sliding to rest under friction. The lanes are the -// scalar solve's arithmetic in another order and (natively) in single -// precision, so the paths agree to a tolerance, not to the bit; the -// same path twice agrees to the bit. +// aephysics.contact_solver_wide's checks: one scene stepped two ways (the +// convex contacts solved one at a time by the scalar solver, and eight at +// a time in Aether's lanes) lands in the same place: a stack of boxes +// (contacts in colours with a tail slot, dynamic pairs and static ones), +// a bouncing ball with restitution and hit events, a rolling sphere with +// rolling resistance, a box on a conveyor's tangent velocity, a box +// sliding to rest under friction. The lanes are the scalar solve's +// arithmetic in another order and in single precision, so the two paths +// agree to a tolerance, not to the bit; the lanes twice agree to the bit. import std.string import aephysics.math import aephysics.basics From eb25620a8e5d0df4b733abdcb28c0b365a4cb9e7 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Wed, 21 Oct 2026 23:17:51 -0300 Subject: [PATCH 3/3] events_and_queries says how low the chain swung, not where it hangs Released level with nothing to damp it, the chain is a pendulum: after three seconds its tip is back near level (y 5.65), which the example printed as where it hangs. It now tracks the lowest point the tip reached (y 3.25, the chain's 3 m below the anchor) and prints both. --- examples/events_and_queries.ae | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/events_and_queries.ae b/examples/events_and_queries.ae index 5e6fdc2..c994b39 100644 --- a/examples/events_and_queries.ae +++ b/examples/events_and_queries.ae @@ -74,8 +74,10 @@ main() { dynamics.create_sphere_shape(ball, &ball_shape_def, manifold.sphere(math.vec3_zero(), 0.5)) // Three seconds. A step's events are read after it and last until the - // next step. + // next step. The chain swings from level; nothing damps it, so its tip + // comes back up near level a period later. began = 0 + lowest = 6.0 step = 0 while step < 180 { physics_world.step(world, 1.0 / 60.0, 4) @@ -86,11 +88,13 @@ main() { println("step ${step}: the ball hit the ground at ${hit.approach_speed} m/s") k = k + 1 } + tip = dynamics.body_get_position(previous) + if tip.y < lowest { lowest = tip.y } step = step + 1 } println("${began} contacts began touching") tip = dynamics.body_get_position(previous) - println("the chain's last link hangs at (${tip.x}, ${tip.y})") + println("the chain's tip swung down to y = ${lowest}, and is back at y = ${tip.y}") // The closest shape along a ray straight down from above the ball. filter = shape.default_query_filter() @@ -99,11 +103,11 @@ main() { println("the ray hit y = ${ray.point.y}, ${ray.fraction} of the way along") } - // Every shape whose bounds touch a box around the chain. + // Every shape whose bounds touch a box around the anchor. found = 0 box = math.aabb(math.vec3(0.0 - 1.0, 2.0, 0.0 - 1.0), math.vec3(1.0, 7.0, 1.0)) physics_world.overlap_aabb(world, box, filter, count_shape, (&found) as ptr) - println("${found} shapes in the box around the chain") + println("${found} shapes in the box around the anchor") dynamics.destroy_world(world) hull.destroy_hull(slab)