From add581af0425116c9534c9fcd0c7922b854d0e1e Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 9 Oct 2026 05:29:44 -0300 Subject: [PATCH] Host hooks, capacity, counters and the getters the reference has (#163, first part) A host's allocator takes every block the engine makes (the scheduler, its semaphore and the ragdoll layer now allocate through basics too), its log the engine's messages and its failure function the one hard stop. The world keeps its maximum capacity at each step's start and a snapshot carries it; Counters has the rest of b3Counters; the memory goes to the log part by part. Contact data by id, the joint tuning getter, the mesh material count, world counts, version, precision, AVX2 and the reference's b3Hash. --- README.md | 4 +- aephysics/basics/module.ae | 145 ++++++++++++-- aephysics/broad_phase/module.ae | 5 + aephysics/dynamics/module.ae | 91 ++++++++- aephysics/human/module.ae | 49 ++--- aephysics/native/aephysics_native.c | 87 +++++---- aephysics/native/module.ae | 29 +-- aephysics/parallel/module.ae | 39 ++-- aephysics/physics_world/module.ae | 267 +++++++++++++++++++++++++- aephysics/snapshot/module.ae | 11 +- aephysics/solver/module.ae | 15 +- bench/broad_phase.ae | 2 +- design.md | 9 +- tests/test_broad_phase.ae | 4 +- tests/test_host_api.ae | 281 ++++++++++++++++++++++++++++ tests/test_human.ae | 22 +-- 16 files changed, 932 insertions(+), 128 deletions(-) create mode 100644 tests/test_host_api.ae diff --git a/README.md b/README.md index c967b03..9dd4bfd 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ so a test written against the reference reads the same here. | module | holds | state | |---|---|---| | `aephysics.math` | vectors, quaternions, transforms, 3x3 matrices, bounding boxes, segment distances, inertia helpers, the deterministic atan2/cos/sin, and the same vectors in single precision (`Vec3f`, `Quatf`, `Matrix3f`) for the geometry the reference keeps in `float` ([#42](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 content hash, 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) | +| `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) | @@ -50,7 +50,7 @@ so a test written against the reference reads the same here. | `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 with `AETHER_AEPHYSICS_CFLAGS=-mavx2`, two four-lane halves without), the same prepare, warm start, solve, restitution and store, the bodies gathered into lanes and scattered back | `test_contact_solver_wide.ae` (one scene two ways -- the stack, the bounce with hit events, rolling resistance, a conveyor, a slide -- the lanes within a millimetre of the scalar solve, and the same lanes twice bit for bit) | [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, 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.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, 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.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, 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` (115 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) | [the reference's rain benchmark, 300 ragdolls over 400 steps, at 0.9x](bench/RESULTS.md#human) | diff --git a/aephysics/basics/module.ae b/aephysics/basics/module.ae index 56f30bb..565b2b6 100644 --- a/aephysics/basics/module.ae +++ b/aephysics/basics/module.ae @@ -23,7 +23,8 @@ import aephysics.native exports ( BitSet, IdPool, HashSet, Ints, Buffer, Stack, Arena, BlockAllocator, BLOCK_SIZE, block_allocator_create, block_allocator_destroy, block_allocate, block_free, - alloc, free_bytes, alloc_count, bytes_in_use, sort_ints, + alloc, free_bytes, alloc_count, bytes_in_use, sort_ints, set_allocator, clear_allocator, set_log_fn, set_assert_fn, log, + report_failure, HOST_ALIGNMENT, is_power_of_2, bounding_power_of_2, round_up_power_of_2, lower_power_of_2_exponent, clz32, ctz32, ctz64, pop_count64, lsr, lsr32, bitset_create, bitset_destroy, bitset_set_count_and_clear, bitset_grow, bitset_union, @@ -33,7 +34,7 @@ exports ( set_create, set_destroy, set_clear, set_add, set_remove, set_contains, set_contains_hashed, set_count, set_bytes, set_prefetch, SetItem, LongMap, map_create, map_destroy, map_clear, map_set, map_get, map_has, map_remove, map_count, map_bytes, - hash_bytes, section_fits, + hash_bytes, section_fits, HASH_INIT, djb2_hash, NameCache, NULL_NAME, name_cache_create, name_cache_destroy, name_hash32, name_cache_add, name_cache_find, name_cache_text, name_cache_load, name_cache_count, name_cache_id_at, name_cache_name_at, name_cache_length_at, name_cache_bytes, @@ -62,8 +63,98 @@ extern memchr(block: ptr, value: int, size: int) -> ptr var g_alloc_count = 0 var g_bytes_in_use = 0 -// Box3D's b3Alloc: zeroed, 16-byte aligned by the C runtime's own promise, -// and counted, so a leak is a number a test reads. +// What a host wires into the engine (b3SetAllocator, b3SetLogFcn, +// b3SetAssertFcn), made when the first is set: until then the C runtime +// allocates, println logs and a failure prints. The reference keeps them as +// function pointers it checks for null; here the record is what is null. +struct HostHooks { + has_allocator: bool + allocate: fn(int, int) -> ptr // a block of `size` bytes aligned to `alignment` + release: fn(ptr, int) // a block and the size it was asked for + log: fn(string) + failed: fn(string, string, int) -> int +} + +var g_hooks: ptr = null // *HostHooks, or null for none + +// The alignment a host's allocator is asked for: the widest lanes' (the +// reference's B3_ALIGNMENT). +const HOST_ALIGNMENT = 32 + +system_allocate(size: int, alignment: int) -> ptr { return calloc(1, size) } +system_release(block: ptr, size: int) { free(block) } +system_log(text: string) { println(text) } +system_failed(condition: string, file: string, line: int) -> int { + println("aephysics: ${condition} failed at ${file}:${line}") + return 0 +} + +hooks() -> *HostHooks { + if g_hooks == null { + made = calloc(1, sizeof(HostHooks)) as *HostHooks + made.has_allocator = false + made.allocate = system_allocate + made.release = system_release + made.log = system_log + made.failed = system_failed + g_hooks = made as ptr + } + return g_hooks as *HostHooks +} + +// Every block the engine allocates from now on comes from `allocate` and +// goes back through `release` with the size it was asked for (the +// reference's b3SetAllocator). Set at startup: false, and nothing changed, +// while blocks made by the allocator before are still out, since they must +// go back to the allocator that made them. +set_allocator(allocate: fn(int, int) -> ptr, release: fn(ptr, int)) -> bool { + if alloc_count() != 0 { return false } + h = hooks() + h.allocate = allocate + h.release = release + h.has_allocator = true + return true +} + +// The C runtime allocates again, on the same terms as set_allocator. +clear_allocator() -> bool { + if alloc_count() != 0 { return false } + if g_hooks != null { hooks().has_allocator = false } + return true +} + +// The engine's messages go to `log` (the reference's b3SetLogFcn). +set_log_fn(log: fn(string)) { hooks().log = log } + +// A failure the engine cannot go on from goes to `failed` with its +// condition, file and line before the engine aborts (the reference's +// b3SetAssertFcn). The engine checks what it is given and returns instead +// of asserting; the one such failure is the scheduler's tasks overflowing. +set_assert_fn(failed: fn(string, string, int) -> int) { hooks().failed = failed } + +// A message for the host. +log(text: string) { + if g_hooks == null { + println(text) + return + } + sink = hooks().log + sink(text) +} + +// A failure reported to the host, before the caller aborts. +report_failure(condition: string, file: string, line: int) { + if g_hooks == null { + system_failed(condition, file, line) + return + } + sink = hooks().failed + sink(condition, file, line) +} + +// Box3D's b3Alloc: zeroed and counted, so a leak is a number a test reads; +// from the host's allocator when one is set (aligned to HOST_ALIGNMENT and +// zeroed here), else the C runtime's (16-byte aligned by its own promise). // The counters move atomically: the collide pass and finalize allocate from // several workers at once (a mesh contact's triangle cache, an arena's // overflow, the continuous pass's sensor hits). @@ -71,6 +162,12 @@ alloc(size: int) -> ptr { if size <= 0 { return null } native.atomic_add((&g_alloc_count) as ptr, 1) native.atomic_add((&g_bytes_in_use) as ptr, size) + if g_hooks != null && hooks().has_allocator { + allocate = hooks().allocate + block = allocate(size, HOST_ALIGNMENT) + if block != null { memset(block, 0, size) } + return block + } return calloc(1, size) } @@ -78,6 +175,11 @@ free_bytes(block: ptr, size: int) { if block == null { return } native.atomic_add((&g_alloc_count) as ptr, 0 - 1) native.atomic_add((&g_bytes_in_use) as ptr, 0 - size) + if g_hooks != null && hooks().has_allocator { + release = hooks().release + release(block, size) + return + } free(block) } @@ -398,6 +500,30 @@ map_remove(m: *LongMap, key: long) -> bool { map_count(m: *LongMap) -> int { return m.count } map_bytes(m: *LongMap) -> int { return m.capacity * sizeof(MapItem) } +// --- the reference's determinism hash ---------------------------------------- + +// The reference's b3Hash: djb2 over the bytes, eight at a time as two +// little-endian words, then the rest one by one; unsigned 32-bit, started +// from HASH_INIT. For checking that two runs made the same bytes. +const HASH_INIT = 5381 + +djb2_hash(hash: long, data: ptr, count: int) -> long { + mask = (1 as long << 32) - (1 as long) + result = hash & mask + i = 0 + while i + 8 <= count { + word = native.load_word(data + i, 0 as long) + result = (result * (33 as long) + (word & mask)) & mask + result = (result * (33 as long) + lsr(word, 32)) & mask + i = i + 8 + } + while i < count { + result = (result * (33 as long) + (native.load_u8(data, i as long) as long)) & mask + i = i + 1 + } + return result +} + // --- the name cache ------------------------------------------------------------ // The names a world's bodies and shapes go by, each kept once (the @@ -465,13 +591,10 @@ name_entry_of(c: *NameCache, id: int) -> int { // The id of `name`, interned when it is new; NULL_NAME for none. name_cache_add(c: *NameCache, name: string) -> int { - text = native.name_copy(name) - if text == null { return NULL_NAME } - length = strlen(text) - id = NULL_NAME - if length > 0 { id = name_cache_add_bytes(c, text, length) } - native.name_free(text) - return id + if name == null { return NULL_NAME } + length = string.length(name) + if length == 0 { return NULL_NAME } + return name_cache_add_bytes(c, string.aether_string_raw_ptr(name), length) } // The id of the `length` bytes at `text`, interned when they are new. diff --git a/aephysics/broad_phase/module.ae b/aephysics/broad_phase/module.ae index 0749c6d..7995683 100644 --- a/aephysics/broad_phase/module.ae +++ b/aephysics/broad_phase/module.ae @@ -53,10 +53,15 @@ const STACK_SIZE = 512 const CANDIDATE_BATCH = 32 // What a world expects to hold, for the initial capacities. +// How much a world holds or is sized for (b3Capacity): the broad phase +// reads the shapes and contacts; a world's maximum, kept every step, adds +// the bodies (kinematic ones among the dynamic, as the reference counts). struct Capacity { static_shape_count: int dynamic_shape_count: int contact_count: int + static_body_count: int + dynamic_body_count: int } struct BroadPhase { diff --git a/aephysics/dynamics/module.ae b/aephysics/dynamics/module.ae index e41c085..a34081a 100644 --- a/aephysics/dynamics/module.ae +++ b/aephysics/dynamics/module.ae @@ -156,7 +156,9 @@ exports ( world_joint_event_count, world_joint_event, world_set_pre_solve, world_clear_pre_solve, shape_user_material_id, make_relative_sweep, body_move_event_at, TaskContext, task_context, task_context_at, task_sensor_hit_at, enter_world, leave_world, - reset_world + reset_world, + get_world_count, get_max_world_count, track_capacity, contact_get_data, joint_get_constraint_tuning, shape_get_mesh_material_count, + MANIFOLD_COUNT_BUCKETS, hull_entry_at, HullEntry, OwnedGeometry ) extern memset(block: ptr, value: int, size: int) -> ptr @@ -1174,6 +1176,10 @@ struct TaskContext { split_island_id: int split_sleep_time: float sensor_hits: Buffer // SensorHit, from the continuous pass + manifold_counts: int[8] // the collide pass's contacts by manifold count (1 to 7, the last 7 or more) + distance_iterations: int // the most any time of impact took, over the world's life + push_back_iterations: int + root_iterations: int } // Where a step's time went, in milliseconds (b3Profile): the broad @@ -1302,9 +1308,23 @@ struct World { user_data: ptr recording: ptr // *Recording while the world records (aephysics.recording), else null names: NameCache // the bodies' and shapes' names, each once + max_capacity: Capacity // the most it has held at a step's start (b3World_GetMaxCapacity) + manifold_counts: int[8] // the last collide pass's contacts by manifold count, every worker's + task_count: int // the tasks the last step ran on the scheduler +} + +// The buckets of the contacts' manifold counts (the reference's +// B3_CONTACT_MANIFOLD_COUNT_BUCKETS): a contact with n manifolds counts in +// bucket n - 1, the last holding seven or more. +const MANIFOLD_COUNT_BUCKETS = 8 + +count_manifolds(tc: *TaskContext, manifold_count: int) { + bucket = math.min_int(manifold_count, MANIFOLD_COUNT_BUCKETS - 1) + if bucket > 0 { tc.manifold_counts[bucket - 1] = tc.manifold_counts[bucket - 1] + 1 } } var g_worlds: ptr = null // World[MAX_WORLDS] +var g_max_world_count = 0 // the most worlds there have been at once worlds_ready() { if g_worlds == null { g_worlds = basics.alloc(MAX_WORLDS * sizeof(World)) } @@ -1446,9 +1466,38 @@ create_world(def: *WorldDef) -> WorldId { w.restitution_callback = def.restitution_callback w.user_data = def.user_data init_world_contents(w, def) + g_max_world_count = math.max_int(g_max_world_count, get_world_count()) return WorldId { index1: index + 1, generation: generation } } +// The worlds there are (b3GetWorldCount), and the most there have been at +// once (b3GetMaxWorldCount). +get_world_count() -> int { + if g_worlds == null { return 0 } + count = 0 + i = 0 + while i < MAX_WORLDS { + if world_at(i).in_use { count = count + 1 } + i = i + 1 + } + return count +} + +get_max_world_count() -> int { return g_max_world_count } + +// The world's maximum capacity brought up to what it holds now, at the +// start of a step (as the reference keeps it): a host sizes the next +// world's def from it to grow nothing while stepping. +track_capacity(w: *World) { + c = &w.max_capacity + c.static_shape_count = math.max_int(c.static_shape_count, dynamic_tree.tree_get_proxy_count(broad_phase.broad_phase_tree(&w.broad_phase, BODY_STATIC))) + c.dynamic_shape_count = math.max_int(c.dynamic_shape_count, dynamic_tree.tree_get_proxy_count(broad_phase.broad_phase_tree(&w.broad_phase, BODY_DYNAMIC))) + static_bodies = solver_set_at(w, STATIC_SET).body_sims.count + c.static_body_count = math.max_int(c.static_body_count, static_bodies) + c.dynamic_body_count = math.max_int(c.dynamic_body_count, world_body_count(w) - static_bodies) + c.contact_count = math.max_int(c.contact_count, basics.idpool_count(&w.contact_id_pool)) +} + // Everything a world holds (its bodies, shapes, joints, contacts, sets, // islands, geometry and events), made fresh as `def` sizes and sets it; // what the host wires in (workers, callbacks, filters, user data) is not @@ -1470,7 +1519,9 @@ init_world_contents(w: *World, def: *WorldDef) { w.contact_end_events1 = basics.buffer_create(sizeof(ContactEndTouchEvent), 4) w.end_event_index = 0 create_graph(&w.constraint_graph, 16) - broad_phase.create_broad_phase(&w.broad_phase, Capacity { static_shape_count: def.static_shape_count, dynamic_shape_count: def.dynamic_shape_count, contact_count: def.contact_count }) + broad_phase.create_broad_phase(&w.broad_phase, Capacity { static_shape_count: def.static_shape_count, dynamic_shape_count: def.dynamic_shape_count, contact_count: def.contact_count, + static_body_count: def.static_body_count, dynamic_body_count: def.dynamic_body_count }) + w.max_capacity = Capacity { static_shape_count: 0, dynamic_shape_count: 0, contact_count: 0, static_body_count: 0, dynamic_body_count: 0 } w.body_id_pool = basics.idpool_create() w.bodies = basics.buffer_create(sizeof(Body), math.max_int(16, def.static_body_count + def.dynamic_body_count)) w.solver_set_id_pool = basics.idpool_create() @@ -3410,6 +3461,8 @@ reset_shape_proxy(w: *World, s: *WorldShape, wake_bodies: bool, remake: bool) { w.locked = false } shape_get_surface_material(id: ShapeId) -> SurfaceMaterial { return get_shape_full_id(world_at(id.world0), id).material } +// The materials a shape has: a mesh's or a compound's table, else one (b3Shape_GetMeshMaterialCount). +shape_get_mesh_material_count(id: ShapeId) -> int { return get_shape_full_id(world_at(id.world0), id).material_count } shape_set_surface_material(id: ShapeId, m: SurfaceMaterial) { if recording_on(id.world0) { recorder = rec_begin(id.world0, OP_SHAPE_SET_SURFACE_MATERIAL); rec_id(recorder, id.index1, id.generation); rec_surface_material_value(recorder, m); rec_end(recorder) } set_surface_material(id, m) @@ -4435,6 +4488,25 @@ contact_is_valid(id: ContactId) -> bool { return c.contact_id == id.index1 - 1 && c.generation == id.generation } +// One contact's shapes and manifolds by its id (b3Contact_GetData): the +// manifolds are the contact's own, read until the next step; an id that is +// not a contact's gives none. +contact_get_data(id: ContactId) -> ContactData { + if contact_is_valid(id) == false { + return ContactData { contact_id: id, shape_id_a: null_shape_id(), shape_id_b: null_shape_id(), manifolds: null, manifold_count: 0 } + } + w = world_at(id.world0) + c = contact_at(w, id.index1 - 1) + manifolds = null + count = 0 + if c.manifold_count > 0 { + manifolds = c.manifolds + count = c.manifold_count + } + return ContactData { contact_id: id, shape_id_a: make_shape_id(w, world_shape_at(w, c.shape_id_a)), shape_id_b: make_shape_id(w, world_shape_at(w, c.shape_id_b)), + manifolds: manifolds, manifold_count: count } +} + make_contact_id(w: *World, c: *Contact) -> ContactId { return ContactId { index1: c.contact_id + 1, world0: w.world_id, generation: c.generation } } make_shape_id(w: *World, s: *WorldShape) -> ShapeId { return ShapeId { index1: s.id + 1, world0: w.world_id, generation: s.generation } } @@ -5860,6 +5932,7 @@ try_recycle_contact(w: *World, c: *Contact, sim_a: *BodySim, sim_b: *BodySim, st } tc = task_context(w) tc.recycled_contact_count = tc.recycled_contact_count + 1 + count_manifolds(tc, c.manifold_count) return true } @@ -5898,6 +5971,7 @@ collide_contact(w: *World, contact_id: int) { c.cached_relative_q = math.narrow_quat(relative.q) c.flags = c.flags | RELATIVE_TRANSFORM_VALID touching = update_contact(w, c, shape_a, math.widen3(sim_a.local_center), sim_a.transform, shape_b, math.widen3(sim_b.local_center), sim_b.transform, is_fast) + count_manifolds(task_context(w), c.manifold_count) if touching && was_touching && (c.flags & SIM_MESH_CONTACT) != 0 { contact_spec_at(graph_color(w, c.color_index), c.local_index).manifold_count = c.manifold_count } @@ -6126,6 +6200,7 @@ collide_pass(w: *World) -> int { tc.sat_call_count = 0 tc.sat_cache_hit_count = 0 tc.recycled_contact_count = 0 + memset((&tc.manifold_counts) as ptr, 0, MANIFOLD_COUNT_BUCKETS * 4) i = i + 1 } // The contacts in blocks over the workers; each writes its own context. @@ -6137,6 +6212,7 @@ collide_pass(w: *World) -> int { w.sat_call_count = 0 w.sat_cache_hit_count = 0 w.recycled_contact_count = 0 + memset((&w.manifold_counts) as ptr, 0, MANIFOLD_COUNT_BUCKETS * 4) i = 0 while i < w.worker_count { tc = task_context_at(w, i) @@ -6144,6 +6220,11 @@ collide_pass(w: *World) -> int { w.sat_call_count = w.sat_call_count + tc.sat_call_count w.sat_cache_hit_count = w.sat_cache_hit_count + tc.sat_cache_hit_count w.recycled_contact_count = w.recycled_contact_count + tc.recycled_contact_count + b = 0 + while b < MANIFOLD_COUNT_BUCKETS { + w.manifold_counts[b] = w.manifold_counts[b] + tc.manifold_counts[b] + b = b + 1 + } basics.arena_sync(&tc.arena) i = i + 1 } @@ -7066,6 +7147,12 @@ joint_set_constraint_tuning(id: JointId, hertz: float, damping_ratio: float) { sim.constraint_hertz = hertz sim.constraint_damping_ratio = damping_ratio } +// The joint's constraint stiffness and damping (b3Joint_GetConstraintTuning). +joint_get_constraint_tuning(id: JointId) -> (float, float) { + w = world_at(id.world0) + sim = get_joint_sim(w, get_joint_full_id(w, id)) + return sim.constraint_hertz, sim.constraint_damping_ratio +} joint_get_constraint_hertz(id: JointId) -> float { w = world_at(id.world0) return get_joint_sim(w, get_joint_full_id(w, id)).constraint_hertz diff --git a/aephysics/human/module.ae b/aephysics/human/module.ae index 301c9e9..1a9b381 100644 --- a/aephysics/human/module.ae +++ b/aephysics/human/module.ae @@ -36,16 +36,14 @@ exports ( BONE_UPPER_ARM_L, BONE_LOWER_ARM_L, BONE_UPPER_ARM_R, BONE_LOWER_ARM_R, BONE_COUNT, FILTER_JOINT_COUNT, BONE_FOOT_L, BONE_FOOT_R, MAX_BONE_COUNT, ANKLE_PLANTAR_DEGREES, ANKLE_DORSAL_DEGREES, ANKLE_SIDE_DEGREES, ANKLE_ROLL_DEGREES, HumanBone, Human, BoneSpec, bone_at, filter_joint_at, spec_at, - human_reference_shape, human_anatomical_limits, human_tight_limits, human_chain_child, human_shape_head, human_shape_tail, human_shape_toe, human_fit_shape, create_human_shaped, + human_reference_shape, human_anatomical_limits, human_tight_limits, human_chain_child, human_shape_head, human_shape_tail, human_shape_toe, human_fit_shape, destroy_shape, create_human_shaped, create_human, destroy_human, human_set_velocity, human_apply_angular_impulse, human_set_joint_friction_torque, human_set_joint_spring_hertz, human_set_joint_damping_ratio, human_align_spring, human_release_align_spring, human_create_motor_anchors, human_create_parallel_anchors, human_destroy_anchors, human_has_anchors, human_set_anchor_target, human_set_bullet ) -extern calloc(count: int, size: int) -> ptr extern sin(x: float) -> float extern cos(x: float) -> float -extern free(p: ptr) const BONE_PELVIS = 0 const BONE_SPINE_01 = 1 @@ -152,10 +150,10 @@ struct BoneSpec { cone_major_axis: Vec3 } -// A figure's shape is BONE_COUNT BoneSpecs in one heap block: the -// reference's from human_reference_shape, a rig's once human_fit_shape -// has fitted it, and create_human_shaped builds either. The caller frees -// it with free(). +// A figure's shape is BONE_COUNT BoneSpecs in one block from the engine's +// allocator: the reference's from human_reference_shape, a rig's once +// human_fit_shape has fitted it, and create_human_shaped builds either. +// The caller frees it with destroy_shape. spec_at(shape: ptr, index: int) -> *BoneSpec { return (shape + index * sizeof(BoneSpec)) as *BoneSpec } set_spec(shape: ptr, index: int, spec: BoneSpec) { (shape as BoneSpec[])[index] = spec } @@ -180,7 +178,7 @@ const FOOT_FRICTION = 0.9 // The reference's figure, bone by bone (its frames measured from the rig, // its limits the reference's: cones about the rest, hinges either side of it). reference_tight_shape() -> ptr { - shape = calloc(BONE_COUNT, sizeof(BoneSpec)) + shape = basics.alloc(BONE_COUNT * sizeof(BoneSpec)) no_joint = math.transform_identity() on_axis = math.vec3_axis_z() set_spec(shape, BONE_PELVIS, BoneSpec { @@ -346,15 +344,15 @@ const LINE_MIN = 0.005 human_fit_shape(shape: ptr, heads: ptr, tails: ptr, toes: ptr) { head_at = heads as Vec3[] tail_at = tails as Vec3[] - turns = calloc(BONE_COUNT, sizeof(Quat)) + turns = basics.alloc(BONE_COUNT * sizeof(Quat)) turn = turns as Quat[] - olds = calloc(BONE_COUNT, sizeof(Quat)) + olds = basics.alloc(BONE_COUNT * sizeof(Quat)) old = olds as Quat[] - units = calloc(BONE_COUNT, sizeof(Vec3)) + units = basics.alloc(BONE_COUNT * sizeof(Vec3)) unit = units as Vec3[] - scales = calloc(BONE_COUNT, 8) + scales = basics.alloc(BONE_COUNT * 8) scale = scales as float[] - bends = calloc(BONE_COUNT, 8) + bends = basics.alloc(BONE_COUNT * 8) bend = bends as float[] reference_total = 0.0 fitted_total = 0.0 @@ -430,13 +428,16 @@ human_fit_shape(shape: ptr, heads: ptr, tails: ptr, toes: ptr) { } i = i + 1 } - free(turns) - free(olds) - free(units) - free(scales) - free(bends) + basics.free_bytes(turns, BONE_COUNT * sizeof(Quat)) + basics.free_bytes(olds, BONE_COUNT * sizeof(Quat)) + basics.free_bytes(units, BONE_COUNT * sizeof(Vec3)) + basics.free_bytes(scales, BONE_COUNT * 8) + basics.free_bytes(bends, BONE_COUNT * 8) } +// A figure's shape given back (human_reference_shape's block). +destroy_shape(shape: ptr) { basics.free_bytes(shape, BONE_COUNT * sizeof(BoneSpec)) } + cone_off_axis(axis: Vec3) -> bool { return axis.x != 0.0 || axis.y != 0.0 || axis.z <= 0.0 } // --- a body's reach ---------------------------------------------------------------------------- @@ -503,7 +504,7 @@ human_tight_limits(shape: ptr) { spec.cone_major_axis = t.cone_major_axis i = i + 1 } - free(tight) + destroy_shape(tight) } across(v: Vec3, axis: Vec3) -> Vec3 { return math.sub(v, math.mul_sv(math.dot(v, axis), axis)) } @@ -710,7 +711,7 @@ place_foot(h: *Human, world_id: WorldId, index: int, calf_index: int, placement: create_human(h: *Human, world_id: WorldId, position: Vec3, friction_torque: float, hertz: float, damping_ratio: float, group_index: int, user_data: ptr) { shape = human_reference_shape() create_human_shaped(h, world_id, Transform { p: position, q: math.quat_identity() }, shape, friction_torque, hertz, damping_ratio, group_index, user_data) - free(shape) + destroy_shape(shape) } // A figure of a shape, its space put in the world by `placement`. @@ -725,8 +726,8 @@ create_human_shaped(h: *Human, world_id: WorldId, placement: Transform, shape: p if h.ankles && (spec_at(shape, BONE_CALF_L).has_foot == false || spec_at(shape, BONE_CALF_R).has_foot == false) { return } h.bone_count = BONE_COUNT if h.ankles { h.bone_count = MAX_BONE_COUNT } - h.bones = calloc(h.bone_count, sizeof(HumanBone)) - h.filter_joints = calloc(FILTER_JOINT_COUNT, sizeof(JointId)) + h.bones = basics.alloc(h.bone_count * sizeof(HumanBone)) + h.filter_joints = basics.alloc(FILTER_JOINT_COUNT * sizeof(JointId)) h.filter_joint_count = 0 i = 0 while i < h.bone_count { @@ -864,8 +865,8 @@ take_down(h: *Human) { if dynamics.body_is_valid(bone.anchor_id) { dynamics.destroy_body(bone.anchor_id) } i = i + 1 } - free(h.bones) - free(h.filter_joints) + basics.free_bytes(h.bones, h.bone_count * sizeof(HumanBone)) + basics.free_bytes(h.filter_joints, FILTER_JOINT_COUNT * sizeof(JointId)) h.bones = null h.filter_joints = null h.filter_joint_count = 0 diff --git a/aephysics/native/aephysics_native.c b/aephysics/native/aephysics_native.c index e30e136..0f43631 100644 --- a/aephysics/native/aephysics_native.c +++ b/aephysics/native/aephysics_native.c @@ -33,19 +33,10 @@ // --- names ---------------------------------------------------------------------------------------------------- -// A body's or shape's name, kept as a plain C copy the world owns and frees -// (free()): Aether cannot hold on to a string it did not make without the -// runtime reclaiming it, and frees only what carries its own header, so a -// plain buffer handed back is read as a literal and left alone. -void *aephysics_name_copy(const char *s) -{ - if (s == NULL) return NULL; - size_t n = strlen(s); - char *copy = (char *)malloc(n + 1); - if (copy != NULL) memcpy(copy, s, n + 1); - return copy; -} - +// A name the engine keeps (the world's name cache, a recording's tags) as a +// plain zero-ended buffer, read back as a string: Aether frees only what +// carries its own header, so a plain buffer handed back is read as a +// literal and left alone. Empty for none. const char *aephysics_name_text(const void *name) { return name != NULL ? (const char *)name : ""; } // --- threads -------------------------------------------------------------------------------------------------- @@ -231,47 +222,69 @@ int aephysics_processor_count(void) #endif } +// Whether this CPU runs AVX2 (the reference's b3IsAVX2Available): the +// instruction set and the operating system's saving of its registers, as +// GCC's and Clang's CPU check reads them. Never on a CPU that is not x86. +int aephysics_has_avx2(void) +{ +#if defined(__x86_64__) || defined(__i386__) + __builtin_cpu_init(); + return __builtin_cpu_supports("avx2") ? 1 : 0; +#else + return 0; +#endif +} + // A counting semaphore (the reference's b3Semaphore): the scheduler's // threads wait on it for work and are signalled one per task, or once // each to shut down. Win32's, Apple's dispatch one, POSIX's elsewhere. +// Its block is the caller's (allocated through the engine's allocator, as +// the reference's b3CreateSemaphore allocates its own through b3Alloc): +// aephysics_semaphore_bytes says how big, init makes it, destroy undoes +// init and leaves the block to its owner. #ifdef _WIN32 -void *aephysics_semaphore_new(int initial) { return CreateSemaphoreExW(NULL, initial, INT_MAX, NULL, 0, SEMAPHORE_ALL_ACCESS); } -void aephysics_semaphore_free(void *s) { CloseHandle((HANDLE)s); } -void aephysics_semaphore_wait(void *s) { WaitForSingleObjectEx((HANDLE)s, INFINITE, FALSE); } -void aephysics_semaphore_signal(void *s, int count) { ReleaseSemaphore((HANDLE)s, count, NULL); } +typedef struct { HANDLE handle; } AephysicsSemaphore; +int aephysics_semaphore_init(void *block, int initial) +{ + AephysicsSemaphore *s = (AephysicsSemaphore *)block; + s->handle = CreateSemaphoreExW(NULL, initial, INT_MAX, NULL, 0, SEMAPHORE_ALL_ACCESS); + return s->handle != NULL; +} +void aephysics_semaphore_destroy(void *block) { CloseHandle(((AephysicsSemaphore *)block)->handle); } +void aephysics_semaphore_wait(void *block) { WaitForSingleObjectEx(((AephysicsSemaphore *)block)->handle, INFINITE, FALSE); } +void aephysics_semaphore_signal(void *block, int count) { ReleaseSemaphore(((AephysicsSemaphore *)block)->handle, count, NULL); } #elif defined(__APPLE__) -void *aephysics_semaphore_new(int initial) { return (void *)dispatch_semaphore_create(initial); } -void aephysics_semaphore_free(void *s) { dispatch_release((dispatch_semaphore_t)s); } -void aephysics_semaphore_wait(void *s) { dispatch_semaphore_wait((dispatch_semaphore_t)s, DISPATCH_TIME_FOREVER); } -void aephysics_semaphore_signal(void *s, int count) +typedef struct { dispatch_semaphore_t handle; } AephysicsSemaphore; +int aephysics_semaphore_init(void *block, int initial) { - for (int i = 0; i < count; ++i) dispatch_semaphore_signal((dispatch_semaphore_t)s); + AephysicsSemaphore *s = (AephysicsSemaphore *)block; + s->handle = dispatch_semaphore_create(initial); + return s->handle != NULL; } -#else -void *aephysics_semaphore_new(int initial) +void aephysics_semaphore_destroy(void *block) { dispatch_release(((AephysicsSemaphore *)block)->handle); } +void aephysics_semaphore_wait(void *block) { dispatch_semaphore_wait(((AephysicsSemaphore *)block)->handle, DISPATCH_TIME_FOREVER); } +void aephysics_semaphore_signal(void *block, int count) { - sem_t *s = malloc(sizeof(sem_t)); - if (s != NULL && sem_init(s, 0, (unsigned int)initial) != 0) { - free(s); - return NULL; - } - return s; + for (int i = 0; i < count; ++i) dispatch_semaphore_signal(((AephysicsSemaphore *)block)->handle); } -void aephysics_semaphore_free(void *s) +#else +typedef struct { sem_t handle; } AephysicsSemaphore; +int aephysics_semaphore_init(void *block, int initial) { - sem_destroy((sem_t *)s); - free(s); + return sem_init(&((AephysicsSemaphore *)block)->handle, 0, (unsigned int)initial) == 0; } -void aephysics_semaphore_wait(void *s) +void aephysics_semaphore_destroy(void *block) { sem_destroy(&((AephysicsSemaphore *)block)->handle); } +void aephysics_semaphore_wait(void *block) { - while (sem_wait((sem_t *)s) != 0) { + while (sem_wait(&((AephysicsSemaphore *)block)->handle) != 0) { } } -void aephysics_semaphore_signal(void *s, int count) +void aephysics_semaphore_signal(void *block, int count) { - for (int i = 0; i < count; ++i) sem_post((sem_t *)s); + for (int i = 0; i < count; ++i) sem_post(&((AephysicsSemaphore *)block)->handle); } #endif +int aephysics_semaphore_bytes(void) { return (int)sizeof(AephysicsSemaphore); } // The prefetch, the pause and the atomics are static inline in // aephysics_inline.h, so the generated C inlines them. diff --git a/aephysics/native/module.ae b/aephysics/native/module.ae index a20f5ae..596471d 100644 --- a/aephysics/native/module.ae +++ b/aephysics/native/module.ae @@ -8,10 +8,10 @@ @source("aephysics_native.c") @c_include("aephysics_inline.h") exports ( - MAX_WORKERS, worker_index, set_worker_index, claim_slots, release_slots, enter_traversal, leave_traversal, yield_thread, pause, processor_count, scratch, + MAX_WORKERS, worker_index, set_worker_index, claim_slots, release_slots, enter_traversal, leave_traversal, yield_thread, pause, processor_count, has_avx2, scratch, atomic_load, atomic_store, atomic_add, atomic_or, atomic_cas, - semaphore_new, semaphore_free, semaphore_wait, semaphore_signal, - name_copy, name_text, name_free, prefetch, load_word, load_u8, load_u16, f32_bits, f64_bits, + semaphore_bytes, semaphore_init, semaphore_destroy, semaphore_wait, semaphore_signal, + name_text, prefetch, load_word, load_u8, load_u16, f32_bits, f64_bits, round_down_f32, round_up_f32 ) @@ -27,14 +27,16 @@ extern aephysics_enter_traversal(depths: ptr) -> int extern aephysics_leave_traversal(depths: ptr) extern aephysics_yield() extern aephysics_processor_count() -> int +extern aephysics_has_avx2() -> int extern aephysics_pause() @c_import extern aephysics_atomic_load(p: ptr) -> int @c_import extern aephysics_atomic_store(p: ptr, value: int) @c_import extern aephysics_atomic_add(p: ptr, value: int) -> int @c_import extern aephysics_atomic_or(p: ptr, value: int) -> int @c_import extern aephysics_atomic_cas(p: ptr, expected: int, desired: int) -> int @c_import -extern aephysics_semaphore_new(initial: int) -> ptr -extern aephysics_semaphore_free(s: ptr) +extern aephysics_semaphore_bytes() -> int +extern aephysics_semaphore_init(block: ptr, initial: int) -> int +extern aephysics_semaphore_destroy(block: ptr) extern aephysics_semaphore_wait(s: ptr) extern aephysics_semaphore_signal(s: ptr, count: int) extern aephysics_prefetch(p: ptr) @c_import @@ -45,9 +47,7 @@ extern aephysics_f32_bits(value: f32) -> long @c_import extern aephysics_f64_bits(value: float) -> long @c_import extern aephysics_round_down_f32(x: float) -> f32 @c_import extern aephysics_round_up_f32(x: float) -> f32 @c_import -extern aephysics_name_copy(s: string) -> ptr extern aephysics_name_text(name: ptr) -> string -extern free(p: ptr) // The cache line at `p` fetched ahead of its use; harmless on any pointer. prefetch(p: ptr) { aephysics_prefetch(p) } @@ -71,11 +71,8 @@ f64_bits(value: float) -> long { return aephysics_f64_bits(value) } round_down_f32(x: float) -> f32 { return aephysics_round_down_f32(x) } round_up_f32(x: float) -> f32 { return aephysics_round_up_f32(x) } -// A name the world keeps: a plain copy of `s` (null for none), its text -// (empty for none), and its release. -name_copy(s: string) -> ptr { return aephysics_name_copy(s) } +// A plain zero-ended name the engine keeps, as a string; empty for none. name_text(name: ptr) -> string { return aephysics_name_text(name) } -name_free(name: ptr) { if name != null { free(name) } } // The worker slot the calling thread is running as: the task's inside a // world's step, else the slot the thread claimed the first time it asked @@ -99,6 +96,9 @@ yield_thread() { aephysics_yield() } pause() { aephysics_pause() } processor_count() -> int { return aephysics_processor_count() } +// Whether this CPU runs AVX2 instructions. +has_avx2() -> bool { return aephysics_has_avx2() != 0 } + // Atomics on an int where it sits (`(&s.field) as ptr`), sequentially // consistent: the solver's block claims and completion counts, its sync // bits, the bullets' count and the tree's moved flags, which workers @@ -112,8 +112,11 @@ atomic_or(p: ptr, value: int) -> int { return aephysics_atomic_or(p, value) } atomic_cas(p: ptr, expected: int, desired: int) -> bool { return aephysics_atomic_cas(p, expected, desired) != 0 } // A counting semaphore: the scheduler's threads wait on it for work. -semaphore_new(initial: int) -> ptr { return aephysics_semaphore_new(initial) } -semaphore_free(s: ptr) { aephysics_semaphore_free(s) } +// Its block is the caller's: semaphore_bytes of it, made by init (false +// when the system refuses), undone by destroy. +semaphore_bytes() -> int { return aephysics_semaphore_bytes() } +semaphore_init(block: ptr, initial: int) -> bool { return aephysics_semaphore_init(block, initial) != 0 } +semaphore_destroy(block: ptr) { aephysics_semaphore_destroy(block) } semaphore_wait(s: ptr) { aephysics_semaphore_wait(s) } semaphore_signal(s: ptr, count: int) { aephysics_semaphore_signal(s, count) } diff --git a/aephysics/parallel/module.ae b/aephysics/parallel/module.ae index 8e01aa6..5874bb5 100644 --- a/aephysics/parallel/module.ae +++ b/aephysics/parallel/module.ae @@ -27,14 +27,14 @@ // worker, so the slots never run out. import std.worker import aephysics.native +import aephysics.basics exports ( MAX_WORKERS, MAX_TASKS, Task, Scheduler, - create_scheduler, destroy_scheduler, reset_scheduler, begin_round, enqueue, finish, parallel_for, scheduler_worker_count + create_scheduler, destroy_scheduler, reset_scheduler, begin_round, enqueue, finish, parallel_for, scheduler_worker_count, + scheduler_task_count ) -extern calloc(count: int, size: int) -> ptr -extern free(p: ptr) extern abort() const MAX_WORKERS = 64 // native.MAX_WORKERS, which a const cannot name @@ -69,6 +69,7 @@ struct Scheduler { live: int // atomic for_tasks: ptr // ForTask[MAX_WORKERS], parallel_for's, the scheduler's own for_handles: ptr // *Task[MAX_WORKERS] + task_count: int // atomic: the tasks parallel_for has spread } task_at(s: *Scheduler, index: int) -> *Task { return (s.tasks + index * sizeof(Task)) as *Task } @@ -111,12 +112,13 @@ create_scheduler(worker_count: int) -> *Scheduler { count = worker_count if count < 1 { count = 1 } if count > MAX_WORKERS { count = MAX_WORKERS } - s = calloc(1, sizeof(Scheduler)) as *Scheduler + s = basics.alloc(sizeof(Scheduler)) as *Scheduler s.worker_count = count - s.tasks = calloc(MAX_TASKS, sizeof(Task)) - s.for_tasks = calloc(MAX_WORKERS, sizeof(ForTask)) - s.for_handles = calloc(MAX_WORKERS, 8) - s.semaphore = native.semaphore_new(0) + s.tasks = basics.alloc(MAX_TASKS * sizeof(Task)) + s.for_tasks = basics.alloc(MAX_WORKERS * sizeof(ForTask)) + s.for_handles = basics.alloc(MAX_WORKERS * 8) + s.semaphore = basics.alloc(native.semaphore_bytes()) + native.semaphore_init(s.semaphore, 0) i = 1 while i < count { if worker.run_detached(| _c: ptr | { worker_main(s) }) { @@ -135,15 +137,21 @@ destroy_scheduler(s: *Scheduler) { while native.atomic_load((&s.live) as ptr) != 0 { native.yield_thread() } - native.semaphore_free(s.semaphore) - free(s.tasks) - free(s.for_tasks) - free(s.for_handles) - free(s as ptr) + native.semaphore_destroy(s.semaphore) + basics.free_bytes(s.semaphore, native.semaphore_bytes()) + basics.free_bytes(s.tasks, MAX_TASKS * sizeof(Task)) + basics.free_bytes(s.for_tasks, MAX_WORKERS * sizeof(ForTask)) + basics.free_bytes(s.for_handles, MAX_WORKERS * 8) + basics.free_bytes(s as ptr, sizeof(Scheduler)) } scheduler_worker_count(s: *Scheduler) -> int { return s.worker_count } +// The tasks parallel_for has spread over the workers since the scheduler was +// made, a range counting as many tasks as workers took part (the +// reference's world task count). +scheduler_task_count(s: *Scheduler) -> int { return native.atomic_load((&s.task_count) as ptr) } + // The slots taken back, once every task of the last round is complete. reset_scheduler(s: *Scheduler) { native.atomic_store((&s.next_slot) as ptr, 0) @@ -187,7 +195,8 @@ enqueue(s: *Scheduler, body: fn(ptr) -> void, context: ptr, worker_index: int) - slot = native.atomic_add((&s.next_slot) as ptr, 1) if slot >= MAX_TASKS { // Rounds begin with begin_round and hold at most MAX_WORKERS - 1 tasks, so this cannot be reached. - println("aephysics.parallel: more than ${MAX_TASKS} tasks in flight") + // (Aether has no line macro: the place is named instead.) + basics.report_failure("more than ${MAX_TASKS} tasks in flight", "aephysics.parallel.enqueue", 0) abort() } t = task_at(s, slot) @@ -255,6 +264,7 @@ parallel_for(s: *Scheduler, body: fn(int, int, int, ptr) -> void, item_count: in // given the worker's index within the range. caller_index = native.worker_index() if worker_count <= 1 { + native.atomic_add((&s.task_count) as ptr, 1) body(0, item_count, 0, context) return 1 } @@ -264,6 +274,7 @@ parallel_for(s: *Scheduler, body: fn(int, int, int, ptr) -> void, item_count: in block_count = (item_count + block_size - 1) / block_size task_count = worker_count if block_count < task_count { task_count = block_count } + native.atomic_add((&s.task_count) as ptr, task_count) shared = ForShared { next_block: 0, block_count: block_count, block_size: block_size, item_count: item_count, body: body, context: context } begin_round(s) diff --git a/aephysics/physics_world/module.ae b/aephysics/physics_world/module.ae index ae6d4f6..3021c9d 100644 --- a/aephysics/physics_world/module.ae +++ b/aephysics/physics_world/module.ae @@ -28,9 +28,10 @@ import aephysics.dynamics import aephysics.contact_solver import aephysics.joint_solver import aephysics.solver +import aephysics.parallel exports ( - get_profile, + get_profile, get_max_capacity, world_byte_count, dump_memory_stats, Version, get_version, is_double_precision, is_avx2_available, get_world_count, get_max_world_count, RayResult, BodyCastResult, BodyPlaneResult, BodyTOIResult, ExplosionDef, Counters, step, world_of, is_valid, body_event_count, body_event, contact_begin_event_count, contact_begin_event, contact_end_event_count, contact_end_event, @@ -98,6 +99,37 @@ struct ExplosionDef { impulse_per_area: float } +// A version (b3Version). +struct Version { + major: int + minor: int + revision: int +} + +// The reference version this engine follows, in its API and its results: +// e77352c, which calls itself 0.2.0 (b3GetVersion). +get_version() -> Version { return Version { major: 0, minor: 2, revision: 0 } } + +// World positions are double, as in the reference's large-world build +// (b3IsDoublePrecision); the rest single where the reference has it. +is_double_precision() -> bool { return true } + +// Whether this CPU runs AVX2 (b3IsAVX2Available). The wide contact solver's +// width is still the build's (-mavx2), #156. +is_avx2_available() -> bool { return native.has_avx2() } + +// The worlds there are, and the most there have been at once. +get_world_count() -> int { return dynamics.get_world_count() } +get_max_world_count() -> int { return dynamics.get_max_world_count() } + +// The most the world has held at a step's start (b3World_GetMaxCapacity): +// sized from it, the next world's def grows nothing while it steps. +get_max_capacity(id: WorldId) -> Capacity { + w = world_of(id) + if w == null { return Capacity { static_shape_count: 0, dynamic_shape_count: 0, contact_count: 0, static_body_count: 0, dynamic_body_count: 0 } } + return w.max_capacity +} + struct Counters { body_count: int shape_count: int @@ -112,6 +144,13 @@ struct Counters { awake_contact_count: int // the contacts the collide pass touches: the graph's and the awake non-touching recycled_contact_count: int color_counts: int[24] // each colour's contacts and joints, the overflow last + byte_count: long // what the world holds, as world_byte_count adds it up + arena_capacity: int // the most any worker's arena was asked for at once + task_count: int // the tasks the last step spread over the workers + manifold_counts: int[8] // the last collide pass's contacts with 1, 2, ... 7 or more manifolds + distance_iterations: int // the most a time of impact's outer loop took, over the world's life + push_back_iterations: int // the most push backs + root_iterations: int // the most root solver iterations } // The world of an id, or null when the id is stale or the world is locked. @@ -145,6 +184,8 @@ step(id: WorldId, time_step: float, sub_step_count: int) { // pool; the index is given back at the end. caller_index = dynamics.enter_world(w) solver.stage_step() + dynamics.track_capacity(w) + tasks_before = parallel.scheduler_task_count(w.scheduler as *Scheduler) w.locked = true basics.buffer_clear(&w.body_move_events) basics.buffer_clear(&w.sensor_begin_events) @@ -192,6 +233,9 @@ step(id: WorldId, time_step: float, sub_step_count: int) { basics.stack_grow(&w.stack) dynamics.swap_end_events(w) w.profile.step = ms(os.now_monotonic_ns() - t0) + // The step's tasks: exact on the world's own scheduler; on one a host + // shares between worlds stepping at once, theirs are counted too. + w.task_count = parallel.scheduler_task_count(w.scheduler as *Scheduler) - tasks_before w.locked = false dynamics.leave_world(caller_index) if dynamics.recording_on(id.index1 - 1) { record_step_result(id, w) } @@ -435,9 +479,230 @@ get_counters(id: WorldId) -> Counters { i = i + 1 } c.awake_contact_count = awake_contacts + c.byte_count = world_byte_count(w, false) + c.task_count = w.task_count + k = 0 + while k < dynamics.MANIFOLD_COUNT_BUCKETS { + c.manifold_counts[k] = w.manifold_counts[k] + k = k + 1 + } + i = 0 + while i < w.worker_count { + tc = dynamics.task_context_at(w, i) + c.arena_capacity = math.max_int(c.arena_capacity, basics.arena_peak(&tc.arena)) + c.distance_iterations = math.max_int(c.distance_iterations, tc.distance_iterations) + c.push_back_iterations = math.max_int(c.push_back_iterations, tc.push_back_iterations) + c.root_iterations = math.max_int(c.root_iterations, tc.root_iterations) + i = i + 1 + } return c } +// The bytes a world holds, part by part as the reference adds them up +// (b3GetWorldByteCount), and logged part by part when asked; ours adds the +// fat boxes, the geometry it owns and the names. +world_byte_count(w: *World, logged: bool) -> long { + total = 0 as long + body_ids = id_bytes(&w.body_id_pool) + set_ids = id_bytes(&w.solver_set_id_pool) + joint_ids = id_bytes(&w.joint_id_pool) + contact_ids = id_bytes(&w.contact_id_pool) + island_ids = id_bytes(&w.island_id_pool) + shape_ids = id_bytes(&w.shape_id_pool) + total = total + ((body_ids + set_ids + joint_ids + contact_ids + island_ids + shape_ids) as long) + if logged { + basics.log("id pools") + basics.log("body ids: ${body_ids}") + basics.log("solver set ids: ${set_ids}") + basics.log("joint ids: ${joint_ids}") + basics.log("contact ids: ${contact_ids}") + basics.log("island ids: ${island_ids}") + basics.log("shape ids: ${shape_ids}") + } + + island_links = 0 + i = 0 + while i < w.islands.count { + island = dynamics.island_at(w, i) + if island.island_id != NULL_INDEX { + island_links = island_links + basics.ints_bytes(&island.bodies) + basics.buffer_bytes(&island.contacts) + basics.buffer_bytes(&island.joints) + } + i = i + 1 + } + bodies = basics.buffer_bytes(&w.bodies) + sets = basics.buffer_bytes(&w.solver_sets) + joints = basics.buffer_bytes(&w.joints) + contacts = basics.buffer_bytes(&w.contacts) + islands = basics.buffer_bytes(&w.islands) + shapes = basics.buffer_bytes(&w.shapes) + sensors = basics.buffer_bytes(&w.sensors) + fat_boxes = basics.buffer_bytes(&w.fat_aabbs) + total = total + ((bodies + sets + joints + contacts + islands + island_links + shapes + sensors + fat_boxes) as long) + if logged { + basics.log("world arrays") + basics.log("bodies: ${bodies}") + basics.log("solver sets: ${sets}") + basics.log("joints: ${joints}") + basics.log("contacts: ${contacts}") + basics.log("islands: ${islands}") + basics.log("island links: ${island_links}") + basics.log("shapes: ${shapes}") + basics.log("sensors: ${sensors}") + basics.log("fat bounds: ${fat_boxes}") + } + + overlaps = 0 + i = 0 + while i < w.sensors.count { + sensor = dynamics.sensor_at(w, i) + overlaps = overlaps + basics.buffer_bytes(&sensor.hits) + basics.buffer_bytes(&sensor.overlaps1) + basics.buffer_bytes(&sensor.overlaps2) + i = i + 1 + } + total = total + (overlaps as long) + if logged { + basics.log("owned arrays") + basics.log("sensor overlaps: ${overlaps}") + } + + hull_map = basics.buffer_bytes(&w.hull_entries) + basics.map_bytes(&w.hull_heads) + hull_data = 0 + i = 0 + while i < w.hull_entries.count { + entry = dynamics.hull_entry_at(w, i) + if entry.hull != null { hull_data = hull_data + (entry.hull as *HullData).byte_count } + i = i + 1 + } + owned = basics.buffer_bytes(&w.owned_geometry) + i = 0 + while i < w.owned_geometry.count { + owned = owned + ((w.owned_geometry.data + i * sizeof(OwnedGeometry)) as *OwnedGeometry).byte_count + i = i + 1 + } + total = total + ((hull_map + hull_data + owned) as long) + if logged { + basics.log("hulls") + basics.log("database: ${hull_map} (${w.hull_count}, ${w.hull_entries.count})") + basics.log("hull data: ${hull_data}") + basics.log("owned geometry: ${owned}") + } + + static_tree = dynamic_tree.tree_get_byte_count(broad_phase.broad_phase_tree(&w.broad_phase, dynamics.BODY_STATIC)) + kinematic_tree = dynamic_tree.tree_get_byte_count(broad_phase.broad_phase_tree(&w.broad_phase, dynamics.BODY_KINEMATIC)) + dynamic_tree_bytes = dynamic_tree.tree_get_byte_count(broad_phase.broad_phase_tree(&w.broad_phase, dynamics.BODY_DYNAMIC)) + pair_set = basics.set_bytes(&w.broad_phase.pair_set) + total = total + ((static_tree + kinematic_tree + dynamic_tree_bytes + pair_set) as long) + if logged { + basics.log("broad-phase") + basics.log("static tree: ${static_tree}") + basics.log("kinematic tree: ${kinematic_tree}") + basics.log("dynamic tree: ${dynamic_tree_bytes}") + basics.log("pairSet: ${pair_set} (${basics.set_count(&w.broad_phase.pair_set)}, ${w.broad_phase.pair_set.capacity})") + } + + allocator_array = basics.buffer_bytes(&w.manifold_allocators) + allocator_blocks = 0 + allocators = w.manifold_allocators.data as BlockAllocator[] + i = 0 + while i < w.manifold_allocators.count { + allocator_blocks = allocator_blocks + basics.buffer_bytes(&allocators[i].blocks) + allocators[i].blocks.count * basics.BLOCK_SIZE * allocators[i].element_size + i = i + 1 + } + total = total + ((allocator_array + allocator_blocks) as long) + if logged { + basics.log("manifold allocators") + basics.log("allocator array: ${allocator_array}") + basics.log("blocks: ${allocator_blocks}") + } + + body_sims = 0 + body_states = 0 + joint_sims = 0 + contact_sims = 0 + island_sims = 0 + i = 0 + while i < w.solver_sets.count { + set = dynamics.solver_set_at(w, i) + if set.set_index != NULL_INDEX { + body_sims = body_sims + basics.buffer_bytes(&set.body_sims) + body_states = body_states + basics.buffer_bytes(&set.body_states) + joint_sims = joint_sims + basics.buffer_bytes(&set.joint_sims) + contact_sims = contact_sims + basics.ints_bytes(&set.contact_indices) + island_sims = island_sims + basics.buffer_bytes(&set.island_sims) + } + i = i + 1 + } + total = total + ((body_sims + body_states + joint_sims + contact_sims + island_sims) as long) + if logged { + basics.log("solver sets") + basics.log("body sim: ${body_sims}") + basics.log("body state: ${body_states}") + basics.log("joint sim: ${joint_sims}") + basics.log("contact sim: ${contact_sims}") + basics.log("island sim: ${island_sims}") + } + + body_bits = 0 + graph_contacts = 0 + graph_joints = 0 + i = 0 + while i < dynamics.GRAPH_COLOR_COUNT { + color = dynamics.graph_color(w, i) + body_bits = body_bits + basics.bitset_bytes(&color.body_set) + graph_contacts = graph_contacts + basics.ints_bytes(&color.convex_contacts) + basics.buffer_bytes(&color.contacts) + graph_joints = graph_joints + basics.buffer_bytes(&color.joint_sims) + i = i + 1 + } + total = total + ((body_bits + graph_joints + graph_contacts) as long) + if logged { + basics.log("constraint graph") + basics.log("body bit sets: ${body_bits}") + basics.log("joint sim: ${graph_joints}") + basics.log("contact sim: ${graph_contacts}") + } + + task_contexts = w.worker_count * sizeof(TaskContext) + i = 0 + while i < w.worker_count { + tc = dynamics.task_context_at(w, i) + task_contexts = task_contexts + basics.buffer_bytes(&tc.sensor_hits) + basics.bitset_bytes(&tc.contact_state) + basics.bitset_bytes(&tc.joint_state_bits) + + basics.bitset_bytes(&tc.hit_event_bits) + basics.bitset_bytes(&tc.awake_island_bits) + basics.arena_capacity(&tc.arena) + i = i + 1 + } + total = total + (task_contexts as long) + if logged { + basics.log("task contexts") + basics.log("worker: ${task_contexts}") + } + + events = basics.buffer_bytes(&w.body_move_events) + basics.buffer_bytes(&w.sensor_begin_events) + basics.buffer_bytes(&w.contact_begin_events) + + basics.buffer_bytes(&w.sensor_end_events0) + basics.buffer_bytes(&w.sensor_end_events1) + basics.buffer_bytes(&w.contact_end_events0) + + basics.buffer_bytes(&w.contact_end_events1) + basics.buffer_bytes(&w.contact_hit_events) + basics.buffer_bytes(&w.joint_events) + names = basics.name_cache_bytes(&w.names) + total = total + ((events + names) as long) + if logged { + basics.log("events: ${events}") + basics.log("names: ${names}") + } + + stack = basics.stack_capacity(&w.stack) + total = total + (stack as long) + if logged { + basics.log("stack allocator: ${stack}") + basics.log("total: ${total / (1024 as long)} KB") + } + return total +} + +// An id pool's free list, in bytes (the reference's b3GetIdBytes). +id_bytes(p: *IdPool) -> int { return basics.ints_bytes(&p.free_ids) } + +// The world's memory, part by part, to the host's log (b3World_DumpMemoryStats). +dump_memory_stats(id: WorldId) { + w = world_of(id) + if w == null { return } + world_byte_count(w, true) +} + // The worker count, set between steps and clamped to [1, MAX_WORKERS] // (b3World_SetWorkerCount); a world on a host's scheduler keeps the // scheduler's count. diff --git a/aephysics/snapshot/module.ae b/aephysics/snapshot/module.ae index 2e31f31..6f129b0 100644 --- a/aephysics/snapshot/module.ae +++ b/aephysics/snapshot/module.ae @@ -42,7 +42,7 @@ extern memcpy(dst: ptr, src: ptr, size: int) -> ptr extern memset(block: ptr, value: int, size: int) -> ptr const SNAPSHOT_MAGIC = 0x4E535041 // "APSN", little-endian -const SNAPSHOT_VERSION = 2 +const SNAPSHOT_VERSION = 3 const NULL_INDEX = 0 - 1 // --- the bytes written ------------------------------------------------------------------------------- @@ -262,7 +262,7 @@ name_known(c: *NameCache, id: int) -> bool { return id == basics.NULL_NAME || ba snapshot_layout() -> long { hash = 0 - 3750763034362895579 prime = 1099511628211 as long - sizes_block = basics.alloc(24 * 8) + sizes_block = basics.alloc(25 * 8) sizes = sizes_block as long[] sizes[0] = sizeof(Body) as long sizes[1] = sizeof(WorldShape) as long @@ -288,12 +288,13 @@ snapshot_layout() -> long { sizes[21] = compound.COMPOUND_VERSION as long sizes[22] = dynamic_tree.tree_save_version() sizes[23] = dynamics.GRAPH_COLOR_COUNT as long + sizes[24] = sizeof(Capacity) as long i = 0 - while i < 24 { + while i < 25 { hash = (hash ^ sizes[i]) * prime i = i + 1 } - basics.free_bytes(sizes_block, 24 * 8) + basics.free_bytes(sizes_block, 25 * 8) return hash } @@ -374,6 +375,7 @@ save_world(world_id: WorldId) -> (ptr, int) { w_int(&wr, w.compound_shape_count) w_float(&wr, w.inv_h) w_float(&wr, w.inv_dt) + w_bytes(&wr, (&w.max_capacity) as ptr, sizeof(Capacity)) w_idpool(&wr, &w.body_id_pool) w_idpool(&wr, &w.solver_set_id_pool) @@ -652,6 +654,7 @@ fill_world(w: *World, rd: *SnapReader) -> bool { w.compound_shape_count = r_int(rd) w.inv_h = r_float(rd) w.inv_dt = r_float(rd) + r_bytes(rd, (&w.max_capacity) as ptr, sizeof(Capacity)) if rd.ok == false { return false } if w.restitution_iterations < 0 || w.restitution_iterations > dynamics.MAX_RESTITUTION_ITERATIONS { return false } diff --git a/aephysics/solver/module.ae b/aephysics/solver/module.ae index 2642065..9f168b4 100644 --- a/aephysics/solver/module.ae +++ b/aephysics/solver/module.ae @@ -104,7 +104,7 @@ stage_report() { if stages_timed() == false || g_stage_steps == 0 || g_stage_ms == null { return } clock = stage_clock() n = g_stage_steps as float - println("aephysics stages ms/step: prepare_joints ${clock[STAGE_PREPARE_JOINTS] / n} prepare_wide ${clock[STAGE_PREPARE_WIDE] / n} prepare_contacts ${clock[STAGE_PREPARE_CONTACTS] / n} integrate_v ${clock[STAGE_INTEGRATE_VELOCITIES] / n} warm ${clock[STAGE_WARM_START] / n} solve ${clock[STAGE_SOLVE] / n} integrate_p ${clock[STAGE_INTEGRATE_POSITIONS] / n} relax ${clock[STAGE_RELAX] / n} restitution ${clock[STAGE_RESTITUTION] / n} store ${clock[STAGE_STORE_WIDE] / n}") + basics.log("aephysics stages ms/step: prepare_joints ${clock[STAGE_PREPARE_JOINTS] / n} prepare_wide ${clock[STAGE_PREPARE_WIDE] / n} prepare_contacts ${clock[STAGE_PREPARE_CONTACTS] / n} integrate_v ${clock[STAGE_INTEGRATE_VELOCITIES] / n} warm ${clock[STAGE_WARM_START] / n} solve ${clock[STAGE_SOLVE] / n} integrate_p ${clock[STAGE_INTEGRATE_POSITIONS] / n} relax ${clock[STAGE_RELAX] / n} restitution ${clock[STAGE_RESTITUTION] / n} store ${clock[STAGE_STORE_WIDE] / n}") basics.free_bytes(clock as ptr, STAGE_COUNT * 8) basics.free_bytes(g_stage_ms, native.MAX_WORKERS * STAGE_COUNT * 8) g_stage_ms = null @@ -247,6 +247,9 @@ struct ContinuousContext { sensor_hits: ptr // SensorHit[MAX_CONTINUOUS_SENSOR_HITS] sensor_fractions: ptr // float[MAX_CONTINUOUS_SENSOR_HITS] sensor_count: int + distance_iterations: int // the most the accepted hits took + push_back_iterations: int + root_iterations: int } var g_sensor_hits: ptr = null @@ -307,6 +310,9 @@ continuous_query_visitor(proxy_id: int, user_data: long, context: ptr) -> bool { if did_hit { native.atomic_or((&ctx.fast_sim.flags) as ptr, dynamics.HAD_TIME_OF_IMPACT) ctx.fraction = output.fraction + ctx.distance_iterations = math.max_int(ctx.distance_iterations, output.distance_iterations) + ctx.push_back_iterations = math.max_int(ctx.push_back_iterations, output.push_back_iterations) + ctx.root_iterations = math.max_int(ctx.root_iterations, output.root_iterations) } } return true @@ -340,7 +346,8 @@ solve_continuous(w: *World, sim_index: int, dt: float) { dynamic_tree_ = broad_phase.broad_phase_tree(&w.broad_phase, dynamics.BODY_DYNAMIC) fast_body = dynamics.body_at(w, fast_sim.body_id) ctx = ContinuousContext { w: w, fast_sim: fast_sim, fast_shape: null, sweep: sweep, base: base, fraction: 1.0, - sensor_hits: sensor_hits(), sensor_fractions: sensor_fractions(), sensor_count: 0 } + sensor_hits: sensor_hits(), sensor_fractions: sensor_fractions(), sensor_count: 0, + distance_iterations: 0, push_back_iterations: 0, root_iterations: 0 } is_bullet = (fast_sim.flags & dynamics.IS_BULLET) != 0 mask = dynamic_tree.default_mask_bits() shape_id = fast_body.head_shape_id @@ -412,6 +419,10 @@ solve_continuous(w: *World, sim_index: int, dt: float) { } i = i + 1 } + tc = dynamics.task_context(w) + tc.distance_iterations = math.max_int(tc.distance_iterations, ctx.distance_iterations) + tc.push_back_iterations = math.max_int(tc.push_back_iterations, ctx.push_back_iterations) + tc.root_iterations = math.max_int(tc.root_iterations, ctx.root_iterations) } // --- finalize ------------------------------------------------------------------------------------------------- diff --git a/bench/broad_phase.ae b/bench/broad_phase.ae index cba5513..25135d3 100644 --- a/bench/broad_phase.ae +++ b/bench/broad_phase.ae @@ -45,7 +45,7 @@ adopt(bp: *BroadPhase) { main() { bp_block = calloc(1, sizeof(BroadPhase)) bp = bp_block as *BroadPhase - broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 16, dynamic_shape_count: COUNT, contact_count: 4 * COUNT }) + broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 16, dynamic_shape_count: COUNT, contact_count: 4 * COUNT, static_body_count: 0, dynamic_body_count: 0 }) boxes_block = calloc(COUNT + 1, sizeof(AABB)) boxes = boxes_block as AABB[] keys_block = calloc(COUNT + 1, 4) diff --git a/design.md b/design.md index 6eb8985..ce6ecbf 100644 --- a/design.md +++ b/design.md @@ -32,8 +32,9 @@ started until its tests pass. 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/load not ported; the moved-marking is atomic, as the reference's, - since the parallel layer (14). + 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 @@ -113,7 +114,7 @@ started until its tests pass. 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 is not ported), plus the compound + 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 @@ -178,7 +179,7 @@ started until its tests pass. 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 is not ported. + 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, diff --git a/tests/test_broad_phase.ae b/tests/test_broad_phase.ae index 7e60184..2c29ce8 100644 --- a/tests/test_broad_phase.ae +++ b/tests/test_broad_phase.ae @@ -167,7 +167,7 @@ test_keys() { test_grid() { bp_block = calloc(1, sizeof(BroadPhase)) bp = bp_block as *BroadPhase - broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 4, dynamic_shape_count: 64, contact_count: 64 }) + broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 4, dynamic_shape_count: 64, contact_count: 64, static_body_count: 0, dynamic_body_count: 0 }) // A 6 x 6 grid of dynamic unit boxes at 1.5 spacing (no overlaps yet), // a static ground under them, and a kinematic platform through the // first row. @@ -316,7 +316,7 @@ test_compound_pairs() { bp_block = calloc(1, sizeof(BroadPhase)) bp = bp_block as *BroadPhase - broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 4, dynamic_shape_count: 4, contact_count: 4 }) + broad_phase.create_broad_phase(bp, Capacity { static_shape_count: 4, dynamic_shape_count: 4, contact_count: 4, static_body_count: 0, dynamic_body_count: 0 }) scene_block = calloc(1, sizeof(Scene)) scene = scene_block as *Scene scene.boxes = calloc(2, sizeof(AABB)) diff --git a/tests/test_host_api.ae b/tests/test_host_api.ae new file mode 100644 index 0000000..d9ee13d --- /dev/null +++ b/tests/test_host_api.ae @@ -0,0 +1,281 @@ +// The API a host reaches for beyond the reference's tests (#163): its own +// allocator, set before anything is made, takes every block the engine +// allocates (a world on four workers, its scheduler and semaphore, a +// ragdoll, a snapshot, a recording) and gets every one back but the +// scratch the engine keeps for the program, each zeroed and asked at the +// reference's alignment; its log takes the world's memory, part by part, +// adding up to the counters' byte count; its failure function is called. +// Then a contact by its id, a joint's tuning, a mesh's material count, the +// world counts, the version, the maximum capacity (kept at each step's +// start, never lowered, carried by a snapshot), the counters the reference +// keeps (tasks, manifold counts, time of impact iterations) and the +// reference's determinism hash against its own values. + +import std.string +import aephysics.math +import aephysics.native +import aephysics.basics +import aephysics.hull +import aephysics.mesh +import aephysics.material +import aephysics.shape +import aephysics.manifold +import aephysics.dynamics +import aephysics.joint_solver +import aephysics.physics_world +import aephysics.human +import aephysics.snapshot +import aephysics.recording + +extern malloc(size: int) -> ptr +extern calloc(count: int, size: int) -> ptr +extern free(p: ptr) +extern memset(block: ptr, value: int, size: int) -> ptr +extern exit(code: int) + +var failures = 0 +var checks = 0 + +ensure(name: string, ok: bool) { + checks = checks + 1 + if !ok { + println("host api: FAIL ${name}") + failures = failures + 1 + } +} + +// --- the host's allocator, log and failure function ------------------------------------------------ + +var g_allocations = 0 +var g_releases = 0 +var g_bytes = 0 +var g_wrong_alignment = 0 + +// Dirty memory, so a block the engine reads before writing would show. +host_allocate(size: int, alignment: int) -> ptr { + native.atomic_add((&g_allocations) as ptr, 1) + native.atomic_add((&g_bytes) as ptr, size) + if alignment != basics.HOST_ALIGNMENT { native.atomic_add((&g_wrong_alignment) as ptr, 1) } + block = malloc(size) + memset(block, 205, size) + return block +} + +host_release(block: ptr, size: int) { + native.atomic_add((&g_releases) as ptr, 1) + native.atomic_add((&g_bytes) as ptr, 0 - size) + free(block) +} + +var g_lines = 0 +var g_saw_pools = false +var g_saw_broad_phase = false +var g_saw_solver_sets = false +var g_total_kb = 0 + +host_log(text: string) { + g_lines = g_lines + 1 + if string.equals(text, "id pools") == 1 { g_saw_pools = true } + if string.equals(text, "broad-phase") == 1 { g_saw_broad_phase = true } + if string.equals(text, "solver sets") == 1 { g_saw_solver_sets = true } + if string.starts_with(text, "total: ") == 1 { + kb, _err = string.to_int(string.substring(text, 7, string.length(text) - 3)) + g_total_kb = kb + } +} + +var g_failed_line = 0 +host_failed(condition: string, file: string, line: int) -> int { + if string.equals(condition, "a < b") == 1 && string.equals(file, "here") == 1 { g_failed_line = line } + return 0 +} + +// --- a scene of every kind of allocation ---------------------------------------------------------- + +static_body(world_id: WorldId, p: Vec3) -> BodyId { + bdef = dynamics.default_body_def() + bdef.position = p + return dynamics.create_body(world_id, &bdef) +} + +dynamic_body(world_id: WorldId, p: Vec3) -> BodyId { + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.position = p + return dynamics.create_body(world_id, &bdef) +} + +host_api() { + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + wdef.worker_count = 4 + before_worlds = physics_world.get_world_count() + world_id = dynamics.create_world(&wdef) + w = dynamics.get_world(world_id.index1 - 1) + ensure("worlds: one more", physics_world.get_world_count() == before_worlds + 1 && physics_world.get_max_world_count() >= before_worlds + 1) + ensure("allocator: refused while blocks are out", basics.set_allocator(host_allocate, host_release) == false) + + sdef = dynamics.default_shape_def() + ground = static_body(world_id, math.vec3(0.0, 0.0 - 1.0, 0.0)) + floor_hull = hull.make_box_hull(20.0, 1.0, 20.0) + dynamics.create_hull_shape(ground, &sdef, floor_hull) + hull.destroy_hull(floor_hull) + wave = mesh.create_wave_mesh(8, 8, 1.0, 0.2, 0.3, 0.2) + materials_block = calloc(3, sizeof(SurfaceMaterial)) + materials = materials_block as SurfaceMaterial[] + k = 0 + while k < 3 { + materials[k] = material.default_surface_material() + k = k + 1 + } + mdef = dynamics.default_shape_def() + mdef.materials = materials_block + mdef.material_count = 3 + mesh_shape = dynamics.create_mesh_shape(static_body(world_id, math.vec3(30.0, 0.0, 0.0)), &mdef, wave, math.vec3_one()) + free(materials_block) + ensure("materials: the mesh's three", dynamics.shape_get_mesh_material_count(mesh_shape) == 3) + + box = hull.make_box_hull(0.4, 0.4, 0.4) + boxes = 0 + while boxes < 24 { + b = dynamic_body(world_id, math.vec3(0.9 * ((boxes % 6) as float) - 2.5, 0.4 + 0.85 * ((boxes / 6) as float), 0.0)) + dynamics.create_hull_shape(b, &sdef, box) + boxes = boxes + 1 + } + // A bullet straight down onto the ground: the continuous pass finds it. + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.position = math.vec3(8.0, 4.0, 0.0) + bdef.linear_velocity = math.vec3(0.0, 0.0 - 300.0, 0.0) + bdef.is_bullet = true + bullet = dynamics.create_body(world_id, &bdef) + ball = dynamics.create_sphere_shape(bullet, &sdef, manifold.sphere(math.vec3_zero(), 0.1)) + ensure("materials: a sphere's one", dynamics.shape_get_mesh_material_count(ball) == 1) + figure = calloc(1, sizeof(Human)) + h = figure as *Human + human.create_human(h, world_id, math.vec3(0.0 - 8.0, 2.0, 0.0), 2.0, 1.0, 0.5, 1, null) + + // A hinge and its tuning. + a = dynamic_body(world_id, math.vec3(12.0, 3.0, 0.0)) + dynamics.create_hull_shape(a, &sdef, box) + rdef = dynamics.default_revolute_joint_def() + rdef.base.body_id_a = ground + rdef.base.body_id_b = a + rdef.base.local_frame_a = Transform { p: math.vec3(12.0, 4.0, 0.0), q: math.quat_identity() } + rdef.base.local_frame_b = Transform { p: math.vec3(0.0, 0.5, 0.0), q: math.quat_identity() } + hinge = dynamics.create_revolute_joint(world_id, &rdef) + dynamics.joint_set_constraint_tuning(hinge, 30.0, 2.0) + hertz, damping = dynamics.joint_get_constraint_tuning(hinge) + ensure("tuning: what was set", hertz == 30.0 && damping == 2.0) + + // Recorded for a few frames, then on. + r = recording.create_recording() + recording.start_recording(world_id, r) + i = 0 + while i < 90 { + physics_world.step(world_id, 1.0 / 60.0, 4) + if i == 9 { recording.stop_recording(world_id) } + i = i + 1 + } + ensure("recording: plays", recording.validate_replay(recording.recording_data(r), recording.recording_size(r), 2)) + recording.destroy_recording(r) + + // A contact by its id: the one a box reports. + data_block = calloc(8, sizeof(ContactData)) + data = data_block as ContactData[] + found = 0 + probe = 1 + while found == 0 && probe < 40 { + candidate = BodyId { index1: probe, world0: world_id.index1 - 1, generation: dynamics.body_at(w, probe - 1).generation } + if dynamics.body_is_valid(candidate) { + found = dynamics.body_get_contact_data(candidate, data, 8) + } + probe = probe + 1 + } + ensure("contact data: a touching contact found (${found})", found > 0) + if found > 0 { + by_id = dynamics.contact_get_data(data[0].contact_id) + ensure("contact data: by id, the same shapes and manifolds", by_id.shape_id_a.index1 == data[0].shape_id_a.index1 && by_id.shape_id_b.index1 == data[0].shape_id_b.index1 && + by_id.manifold_count == data[0].manifold_count && by_id.manifolds == data[0].manifolds && by_id.manifold_count > 0) + } + stale = dynamics.contact_get_data(ContactId { index1: 0, world0: world_id.index1 - 1, generation: 0 }) + ensure("contact data: none for an id that is not one", stale.manifold_count == 0 && stale.manifolds == null && stale.shape_id_a.index1 == 0) + free(data_block) + + // The counters the reference keeps. + c = physics_world.get_counters(world_id) + ensure("counters: the byte count is the world's", c.byte_count == physics_world.world_byte_count(w, false) && c.byte_count > (100000 as long)) + ensure("counters: the step's tasks (${c.task_count})", c.task_count > 0) + buckets = 0 + k = 0 + while k < dynamics.MANIFOLD_COUNT_BUCKETS { + buckets = buckets + c.manifold_counts[k] + k = k + 1 + } + ensure("counters: the contacts by manifold count (${buckets})", buckets > 0 && c.manifold_counts[0] > 0) + ensure("counters: the bullet's time of impact (${c.distance_iterations})", c.distance_iterations > 0) + ensure("counters: the arena's demand", c.arena_capacity >= 0) + + // The memory, part by part, to the host's log. + physics_world.dump_memory_stats(world_id) + ensure("log: every part (${g_lines} lines)", g_saw_pools && g_saw_broad_phase && g_saw_solver_sets && g_lines > 40) + ensure("log: the total is the byte count (${g_total_kb} KB)", (g_total_kb as long) == c.byte_count / (1024 as long)) + + // The maximum capacity: what the world held at the last step's start, + // never lowered, carried by a snapshot. + cap = physics_world.get_max_capacity(world_id) + statics = dynamics.get_solver_set(w, dynamics.STATIC_SET).body_sims.count + ensure("capacity: the bodies (${cap.static_body_count}, ${cap.dynamic_body_count})", cap.static_body_count == statics && cap.dynamic_body_count == dynamics.world_body_count(w) - statics) + ensure("capacity: the shapes and contacts", cap.static_shape_count >= 2 && cap.dynamic_shape_count >= 26 && cap.contact_count > 0) + human.destroy_human(h) + physics_world.step(world_id, 1.0 / 60.0, 4) + after = physics_world.get_max_capacity(world_id) + ensure("capacity: never lowered", after.dynamic_body_count == cap.dynamic_body_count && after.dynamic_shape_count == cap.dynamic_shape_count) + block, byte_count = snapshot.save_world(world_id) + copy_def = dynamics.default_world_def() + copy = snapshot.load_world(©_def, block, byte_count) + copied = physics_world.get_max_capacity(copy) + ensure("capacity: carried by a snapshot", copied.dynamic_body_count == after.dynamic_body_count && copied.contact_count == after.contact_count) + snapshot.free_snapshot(block, byte_count) + dynamics.destroy_world(copy) + + hull.destroy_hull(box) + dynamics.destroy_world(world_id) + mesh.destroy_mesh(wave) + free(figure) + ensure("worlds: back to as many", physics_world.get_world_count() == before_worlds) +} + +main() { + // Before anything is allocated: the engine's every block is the host's. + ensure("allocator: taken at startup", basics.set_allocator(host_allocate, host_release)) + basics.set_log_fn(host_log) + basics.set_assert_fn(host_failed) + basics.report_failure("a < b", "here", 7) + ensure("failure: the host's function told", g_failed_line == 7) + + host_api() + + // Every block came from the host and went back to it, but the scratch + // the modules keep for the program, which the counters still hold. + out = native.atomic_load((&g_allocations) as ptr) - native.atomic_load((&g_releases) as ptr) + ensure("allocator: every block the host's (${native.atomic_load((&g_allocations) as ptr)} made, ${out} kept, ${basics.alloc_count()} counted)", + native.atomic_load((&g_allocations) as ptr) > 500 && out == basics.alloc_count() && native.atomic_load((&g_bytes) as ptr) == basics.bytes_in_use()) + ensure("allocator: asked at the reference's alignment", native.atomic_load((&g_wrong_alignment) as ptr) == 0) + + // The rest needs no world. + version = physics_world.get_version() + ensure("version: the reference's 0.2.0", version.major == 0 && version.minor == 2 && version.revision == 0) + ensure("precision: world positions are double", physics_world.is_double_precision()) + ensure("avx2: the CPU's", physics_world.is_avx2_available() == native.has_avx2()) + ensure("hash: the reference's b3Hash of 13 bytes", basics.djb2_hash(basics.HASH_INIT as long, string.aether_string_raw_ptr("hello physics"), 13) == (4027293447 as long)) + ensure("hash: of 16 bytes", basics.djb2_hash(basics.HASH_INIT as long, string.aether_string_raw_ptr("0123456789abcdef"), 16) == (1204685188 as long)) + ensure("hash: of none", basics.djb2_hash(basics.HASH_INIT as long, null, 0) == (5381 as long)) + + println("host api: ${checks} checks") + if failures > 0 { + println("host api: ${failures} failure(s)") + exit(1) + } + println("host api: all checks passed") +} diff --git a/tests/test_human.ae b/tests/test_human.ae index bb1438d..f4ef68a 100644 --- a/tests/test_human.ae +++ b/tests/test_human.ae @@ -203,7 +203,7 @@ test_align_spring() { shape = human.human_reference_shape() human.human_tight_limits(shape) human.create_human_shaped(&h, world_id, Transform { p: math.vec3(0.0, 0.05, 0.0), q: math.quat_identity() }, shape, 5.0, 1.0, 0.7, 1, null) - free(shape) + human.destroy_shape(shape) human.human_align_spring(&h, world_id, ground_of(world_id), 25.0, 1.0) pelvis = human.bone_at(&h, human.BONE_PELVIS) ensure("align: the pelvis got its joint", dynamics.joint_is_valid(pelvis.joint_id)) @@ -353,8 +353,8 @@ test_fit_identity() { } ensure("fit: the reference fitted to its own landmarks is the reference", same) free(block) - free(shape) - free(reference) + human.destroy_shape(shape) + human.destroy_shape(reference) } // A rig of the reference's size with the spine curved as MPFB's is (the @@ -447,8 +447,8 @@ test_fit_curved() { human.destroy_human(&h) dynamics.destroy_world(world_id) free(block) - free(shape) - free(reference) + human.destroy_shape(shape) + human.destroy_shape(reference) } // The same figure dropped in two worlds lands bit for bit alike. @@ -575,7 +575,7 @@ reach(prone: bool, tight: bool, kind: int, degrees: float) -> float { free(aims) human.destroy_human(&h) dynamics.destroy_world(world_id) - free(shape) + human.destroy_shape(shape) return worst } @@ -625,7 +625,7 @@ test_reach() { arm = human.spec_at(shape, human.BONE_UPPER_ARM_L) knee = human.spec_at(shape, human.BONE_CALF_R) ensure("tight: the reference's cone and hinge", arm.swing_degrees == 45.0 && arm.cone_axis.z == 1.0 && knee.lower_degrees == 0.0 - 30.0 && knee.upper_degrees == 30.0) - free(shape) + human.destroy_shape(shape) } // A thigh sprung toward `degrees` from hanging down, ahead or out to its @@ -673,7 +673,7 @@ thigh_reach(index: int, out_side: bool, degrees: float) -> ThighReach { abduction: math.atan2_approx(math.dot(have, out_w), math.dot(have, down)) * math.RAD_TO_DEG } human.destroy_human(&h) dynamics.destroy_world(world_id) - free(shape) + human.destroy_shape(shape) return result } @@ -727,7 +727,7 @@ test_hips() { } human.destroy_human(&h) dynamics.destroy_world(world_id) - free(shape) + human.destroy_shape(shape) // Moved by springs: flexion reaches, and a thigh pushed out sideways // slides along the edge toward its widest, short of a split. ahead = thigh_reach(human.BONE_THIGH_L, false, 120.0) @@ -772,7 +772,7 @@ hinge_driven(index: int, toward: Vec3, past_upper: bool) -> float { if math.dot(math.sub(shin, math.mul_sv(math.dot(shin, math.normalize(thigh)), math.normalize(thigh))), way) < 0.0 { bend = 0.0 - bend } human.destroy_human(&h) dynamics.destroy_world(world_id) - free(shape) + human.destroy_shape(shape) return bend } @@ -780,7 +780,7 @@ hinge_driven(index: int, toward: Vec3, past_upper: bool) -> float { test_hinges() { shape = human.human_reference_shape() axes = figure_axes(shape) - free(shape) + human.destroy_shape(shape) back = math.neg(axes.forward) // Which end is straight depends on the way the hinge's axis runs (the // left knee's and the right's are mirrored): each is driven past both.