diff --git a/README.md b/README.md index 2c02c10..3a07e29 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ 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 block hash (the reference's core.c, named so a host's own `core` module and this one can share a program) | done, `test_basics.ae` (100k checks) | +| `aephysics.basics` | bit set, id pool, hash set, a long-to-int map, arrays, the stack and arena allocators, the content hash (the reference's core.c, named so a host's own `core` module and this one can share a program) | done, `test_basics.ae` (100k checks, test_container.c and test_allocator.c among them); `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.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 | done, `test_dynamic_tree.ae` (12k checks); [same tree as the reference; inserts 0.4x its time, queries 1.1x, rays 0.9x](bench/RESULTS.md#dynamic_tree) | -| `aephysics.hull` | quickhull with face merging, the half-edge hull with its mass properties, box / cylinder / cone / rock hulls, clone-and-transform with mirroring, support functions, ray cast, the 2D hull | done, `test_hull.ae` (440 checks); [same hulls as the reference; sphere hulls at parity, merge-heavy cubes 2x, box hulls 3.6x (a heap block against its stack value)](bench/RESULTS.md#hull) | +| `aephysics.hull` | quickhull with face merging, the half-edge hull with its mass properties, box / cylinder / cone / rock hulls and PEEL's convex (the reference's complex hull, the same 32 points), clone-and-transform with mirroring, support functions, ray cast, the 2D hull | done, `test_hull.ae` (445 checks); [same hulls as the reference; sphere hulls at parity, merge-heavy cubes 2x, box hulls 3.6x (a heap block against its stack value)](bench/RESULTS.md#hull) | | `aephysics.distance` | GJK with the warm-started simplex cache, the shape cast by conservative advancement, the time of impact by separating-axis root finding | done, `test_distance.ae` (1.1k checks); [same results as the reference; 1.1-1.2x its time, the time of impact faster](bench/RESULTS.md#distance) | | `aephysics.manifold` | contact manifolds for sphere, capsule and hull in every pairing: the separating axis test with its cache, reference-face clipping, the feature pairs, reduction to four points | done, `test_manifold.ae` (43k checks, 7,000 pairs against a brute-force oracle); [same manifolds as the reference, warm cache at parity](bench/RESULTS.md#manifold) | | `aephysics.triangle_manifold` | one mesh triangle against a sphere, capsule or hull: back-side cull with hysteresis, GJK shallow, the separating axis test deep with the triangle's edges as zero-area faces, the feature recorded for the mesh contact's ghost-collision reduction | done, `test_triangle_manifold.ae` (1.5k checks); [same manifolds as the reference, within 10% on hulls](bench/RESULTS.md#triangle_manifold) | @@ -44,12 +44,12 @@ so a test written against the reference reads the same here. | `aephysics.mover` | the character mover's plane solver: pushes accumulated and clamped over twenty sweeps, the velocity clip | done, `test_mover.ae` (56 checks); [same results as the reference, 0.9x its time](bench/RESULTS.md#mover) | | `aephysics.broad_phase` | the broad phase: a tree per body type, proxies keyed by type, the pair update through the moved siblings and cross-tree seeds with the filter and compound lookups as visitors, the pair set, the keys sorted | done, `test_broad_phase.ae` (36 checks against a brute force); [10,000 moving boxes at 4.6 ms a step](bench/RESULTS.md#broad_phase) | | `aephysics.mesh_contact` | a convex shape against a mesh or height field: the triangle cache with per-triangle warm starts, the manifolds per triangle, the seam rules against ghost collisions, clusters by normal, the four-point cull | done, `test_mesh_contact.ae` (64 checks); [a box on a wave at 3.2 us a step](bench/RESULTS.md#mesh_contact) | -| `aephysics.dynamics` | the world's state and bookkeeping: worlds, bodies (creation, mass from shapes or by hand, transforms, velocities, extents, locks, sleep and enable flags, the type changed, disabled and enabled) in solver sets, shapes of every kind on bodies with their broad-phase proxies, islands linked by contacts and joints and split by union-find, sleeping sets, waking and merging, the constraint graph's colouring, contacts from the broad phase's pairs with the narrow phase (convex, mesh, height field, compound children) and the state changes with their events, contact recycling, joints of every kind (definitions, creation with the edges, sets and islands, collide-connected in the pair filter, destruction, separations, reactions), sensors with their begin and end events | `test_dynamics.ae` (631 checks: test_body.c, the step-free test_world.c and test_joint.c, contacts, islands, sleep, joints, sensors); [creation 0.9x, the first collide at parity, recycling 1.1-1.2x, joints 0.6x, sensors 0.75-1.0x](bench/RESULTS.md#dynamics-bookkeeping-contacts-joints-sensors) | +| `aephysics.dynamics` | the world's state and bookkeeping: worlds, bodies (creation, mass from shapes or by hand, transforms, velocities, extents, locks, sleep and enable flags, the type changed, disabled and enabled) in solver sets, shapes of every kind on bodies with their broad-phase proxies, islands linked by contacts and joints and split by union-find, sleeping sets, waking and merging, the constraint graph's colouring, contacts from the broad phase's pairs with the narrow phase (convex, mesh, height field, compound children) and the state changes with their events, contact recycling, joints of every kind (definitions, creation with the edges, sets and islands, collide-connected in the pair filter, destruction, separations, reactions), sensors with their begin and end events | `test_dynamics.ae` (635 checks: test_body.c, the step-free test_world.c and test_joint.c, test_shape.c's flags, contacts, islands, sleep, joints, sensors); [creation 0.9x, the first collide at parity, recycling 1.1-1.2x, joints 0.6x, sensors 0.75-1.0x](bench/RESULTS.md#dynamics-bookkeeping-contacts-joints-sensors) | | `aephysics.contact_solver` | the contact constraints of a step, scalar: prepared from the manifolds (anchors, base separations, normal masses, the friction centre and its tangent mass, twist and rolling masses), warm started, solved per sub-step (soft normal constraints with speculative bias, then central friction, twist friction and rolling resistance in the relax pass), the restitution pass, the impulses stored with the hit events; the step context | `test_contact_solver.ae` (50 checks: the passes by hand on a cube on a slab) | [ours alone](bench/RESULTS.md#contact_solver) | | `aephysics.contact_solver_wide` | a colour's convex contacts eight at a time: the constraint of eight contacts as one single-precision record in lanes (`f32x8`, std.lanes -- the reference's SIMD path; one AVX2 register 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` (118 checks: test_world.c's HelloWorld, contact, hit, move and sensor events, the explosion near and far; test_body_query.c's casts, overlaps, mover planes and time of impact; the world queries; a wave pile stepping alike twice) | [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) | +| `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` (159 checks: test_world.c's HelloWorld, empty and recycled worlds, every setting, contact, hit, move and sensor events, the explosion near and far, the bullet and sleep flags, the compound count, an enlarged proxy destroyed; test_body_query.c's casts, overlaps, mover planes and time of impact; test_broad_phase.c's static rebuild; 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.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, 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` (96 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 reference's rain benchmark, 300 ragdolls over 400 steps, at 0.9x](bench/RESULTS.md#human) | | `aephysics` | the public API | | @@ -77,19 +77,20 @@ Install the released toolchain with the shared, version-pinned installer ```sh curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.778.0 AEB_REF=v0.325 sh + | AE_PIN=0.794.0 AEB_REF=v0.325 sh export PATH="$HOME/.local/bin:$PATH" ``` -This ensures `ae` 0.778.0 or newer and installs `aeb` v0.325 under +This installs `ae` 0.794.0, the release CI pins, and `aeb` v0.325 under `~/.local` (`PREFIX` overrides it), using release binaries where available. -These bootstrap versions are separate from the project's minimum API floor. +The code needs 0.788.0 or newer (the eight-wide lanes, `f32x8`). ``` scripts/test.sh # builds and runs every tests/test_*.ae with ae; what CI runs scripts/fetch_references.sh # Box3D and Jolt into reference/, built (needs cmake, ninja, gcc) scripts/bake.sh [steps] # the bake-off scenes on the references scripts/bench.sh [layer] # a layer of aephysics against the same code in the reference +scripts/bench.sh suite # the rest of the reference's benchmark suite, its own scenes against ours scripts/profile.sh file.ae [top] # a sampling profile on Windows (tools/sampler.c + tools/rank.ae) ``` @@ -112,7 +113,7 @@ the threads' helpers Aether has not (a thread-local worker index, the atomics in place, a semaphore). `aephysics.native` names it (`@source`, Aether 0.704), so every program that imports the library builds it in with no flag of its own. The contact lanes lived there as GCC vector -code until Aether had lanes of its own; they are the module's `f32x4` +code until Aether had lanes of its own; they are the module's `f32x8` now. `solver.set_wide_contacts(false)` runs the scalar solve instead ([what the lanes buy](bench/RESULTS.md#solver)). @@ -156,7 +157,10 @@ falling ragdolls, each run to sleep twice and over four workers and compared bit for bit; the query spawn sleeps a step after the reference's with its 59 query hits, the mesh drop two; 20 checks) and `test_large_world.ae` (a stack, a bullet and the origin-relative queries at x = 0 and at x = 1e7 agree, the whole -engine being in doubles; 43 checks) and `test_edge_cases.ae` (every +engine being in doubles; 43 checks), `test_collision.ae` (the reference's +test_collision.c: boxes valid and not, a ray against a box in its twelve +cases, a hull manifold and a shape's fat bounds at 1e7 against the origin; +56 checks) and `test_edge_cases.ae` (every number a world takes refused when it is NaN, infinite or negative where it must not be, as the reference asserts it, the world left as it was; a world or a ragdoll of such numbers never made, a ragdoll made whole @@ -171,8 +175,9 @@ hinge whose limits meet, a hinge's motor pressing past its limit settling where the reference's does, an elliptical cone stopping a swing at each half-angle, a kinematic body's contacts made stiff on request, and a hull rocking about a vertex on the floor coming to rest, and the -contacts' sim indices kept current through every move of a sim; 142 -checks). +contacts' sim indices kept current through every move of a sim, and +a weld and a hinge between static and kinematic bodies surviving a +disable and an enable; 145 checks). A joint's limits are soft constraints, as the reference's are: driven past one by a motor or held under a steady load, it settles past itself diff --git a/aephysics/basics/module.ae b/aephysics/basics/module.ae index 9a005f2..fc5ce6b 100644 --- a/aephysics/basics/module.ae +++ b/aephysics/basics/module.ae @@ -370,10 +370,14 @@ map_bytes(m: *LongMap) -> int { return m.capacity * sizeof(MapItem) } // --- a hash over a block -------------------------------------------------------- -// This engine's hash of a block of 8-byte words (the count rounded down); -// never zero. The reference uses rapidhash for the same purpose: identity -// of hull and mesh data. +// This engine's hash of a block of bytes; never zero. The reference uses +// rapidhash for the same purpose: identity of hull and mesh data. Every +// byte and the length reach it (test_hash.c's properties, #145): the whole +// words through four lanes, the tail of fewer than eight bytes as one more +// word, the byte count folded in at the end. hash_bytes(block: ptr, byte_count: int) -> long { + // An empty block has nothing to mix: the reserved value, the reference's. + if byte_count <= 0 { return 1 as long } // Four lanes take every fourth word, so their mixes (each a chain of // multiplies) overlap instead of waiting on one another: a word at a // time through one chain was most of building a box hull. @@ -400,8 +404,19 @@ hash_bytes(block: ptr, byte_count: int) -> long { a = mix32(mix32(a ^ (w & m32)) ^ lsr(w, 32)) i = i + 1 } - // The lanes and the length folded into two halves of the result. - low = mix32(mix32(a ^ (count as long)) ^ b) + // The bytes after the last whole word, little-endian into one word. + tail_count = byte_count - 8 * count + if tail_count > 0 { + w = 0 as long + k = tail_count - 1 + while k >= 0 { + w = (w << 8) | (native.load_u8(block, (8 * count + k) as long) as long) + k = k - 1 + } + b = mix32(mix32(b ^ (w & m32)) ^ lsr(w, 32)) + } + // The lanes and the length in bytes folded into two halves of the result. + low = mix32(mix32(a ^ (byte_count as long)) ^ b) high = mix32(mix32(c ^ low) ^ d) // high kept to 31 bits: a shift into the sign bit is undefined in C, and // GCC compiled it differently where the hash was inlined. diff --git a/aephysics/hull/module.ae b/aephysics/hull/module.ae index ec3beda..2d41206 100644 --- a/aephysics/hull/module.ae +++ b/aephysics/hull/module.ae @@ -30,7 +30,7 @@ exports ( hull_vertices, hull_points, hull_edges, hull_planes, hull_faces, create_hull, destroy_hull, clone_hull, clone_and_transform_hull, is_valid_hull, hash_hull_data, compare_hull_data, - create_cylinder, create_cone, create_rock, + create_cylinder, create_cone, create_rock, create_complex_hull, make_box_hull, make_cube_hull, make_offset_box_hull, make_transformed_box_hull, make_scaled_box_hull, scale_box, find_hull_support_vertex, find_hull_support_face, @@ -1707,6 +1707,45 @@ create_rock(radius: float) -> *HullData { return h } +// PEEL's BasicRandom: a 32-bit linear congruential generator. +struct PeelRandom { + state: long +} + +// The next value in [-0.5, 0.5], in single precision as PEEL's. +peel_random_float(g: *PeelRandom) -> f32 { + g.state = (g.state * (2147001325 as long) + (715136305 as long)) & (4294967295 as long) + return ((g.state & (65535 as long)) as f32) / (65535.0 as f32) - (0.5 as f32) +} + +// PEEL's large convex (b3CreateComplexHull): the hull of 32 random +// directions at the radius, from a fixed seed so it is the same hull every +// run. The directions are drawn and normalised in single precision as the +// reference draws them, so the points are the reference's points. +create_complex_hull(radius: float) -> *HullData { + count = 32 + block = basics.alloc(count * sizeof(Vec3)) + points = block as Vec3[] + g = PeelRandom { state: 42 as long } + r = radius as f32 + i = 0 + while i < count { + // A point of the cube kept when it falls in the ball of radius one half. + p = math.vec3f_zero() + while true { + p.x = peel_random_float(&g) + p.y = peel_random_float(&g) + p.z = peel_random_float(&g) + if math.dotf(p, p) <= (0.25 as f32) { break } + } + points[i] = math.widen3(math.mul_svf(r, math.normalizef(p))) + i = i + 1 + } + h = create_hull(points, count, count) + basics.free_bytes(block, count * sizeof(Vec3)) + return h +} + // --- the box hull ---------------------------------------------------------------- // The signs of a box corner's coordinates, in the order of the box's diff --git a/aephysics/manifold/module.ae b/aephysics/manifold/module.ae index c83c610..bb10a6d 100644 --- a/aephysics/manifold/module.ae +++ b/aephysics/manifold/module.ae @@ -450,7 +450,7 @@ reduce_manifold_points(m: *LocalManifold, capacity: int, points: LocalManifoldPo // 1: the extreme touching point. best_index = NULL_INDEX best_score = 0.0 - math.MAX_FLOAT - search = arbitrary_perp(normal) + search = math.arbitrary_perp(normal) i = 0 while i < count { if points[i].separation <= SPECULATIVE_DISTANCE { @@ -541,22 +541,6 @@ reduce_manifold_points(m: *LocalManifold, capacity: int, points: LocalManifoldPo } } -// A fixed perpendicular to a unit vector, the reference's, so the -// reduction's tie-break is the same on every platform. -arbitrary_perp(v: Vec3) -> Vec3 { - a = 0.67 - b = 0.0 - 0.42 - p = math.vec3_zero() - if v.x < 0.0 - 0.5 || 0.5 < v.x { - p = math.vec3(a * v.y + b * v.z, 0.0 - a * v.x, 0.0 - b * v.x) - } else if v.y < 0.0 - 0.5 || 0.5 < v.y { - p = math.vec3(a * v.y, 0.0 - a * v.x + b * v.z, 0.0 - b * v.y) - } else { - p = math.vec3(a * v.z, b * v.z, 0.0 - a * v.x - b * v.y) - } - return math.normalize(p) -} - // --- spheres and capsules ---------------------------------------------------------------- one_point(m: *LocalManifold, normal: Vec3, point: Vec3, separation: float, pair: FeaturePair) { diff --git a/aephysics/math/module.ae b/aephysics/math/module.ae index 4780952..a1aa080 100644 --- a/aephysics/math/module.ae +++ b/aephysics/math/module.ae @@ -41,7 +41,7 @@ exports ( min_int, max_int, clamp_int, abs_float, min_float, max_float, clamp_float, lerp_float, sqrt_float, atan2_approx, compute_cos_sin, sin_approx, cos_approx, unwind_angle, add, sub, mul, neg, dot, length, length_squared, distance, distance_squared, - normalize, get_length_and_normalize, perp, is_normalized, mul_add, mul_sub, mul_sv, + normalize, get_length_and_normalize, perp, arbitrary_perp, is_normalized, mul_add, mul_sub, mul_sv, cross, lerp, blend2, blend3, skew, delta_quat_to_rotation, integrate_rotation, modified_cross, abs_vec3, sign, min_vec3, max_vec3, clamp_vec3, safe_scale, scalar_triple_product, is_normalized_quat, rotate_vector, inv_rotate_vector, dot_quat, mul_quat, inv_mul_quat, conjugate, negate_quat, normalize_quat, make_quat_from_axis_angle, get_axis_angle, @@ -348,6 +348,24 @@ perp(a: Vec3) -> Vec3 { return normalize(Vec3 { x: 0.0, y: a.z, z: 0.0 - a.y }) } +// A fixed perpendicular to a unit vector (b3ArbitraryPerp): no component +// of the vector goes into its own slot, so the dot product cancels. The +// manifold reduction searches along it, so its tie-break is the same on +// every platform. +arbitrary_perp(v: Vec3) -> Vec3 { + a = 0.67 + b = 0.0 - 0.42 + p = vec3_zero() + if v.x < 0.0 - 0.5 || 0.5 < v.x { + p = vec3(a * v.y + b * v.z, 0.0 - a * v.x, 0.0 - b * v.x) + } else if v.y < 0.0 - 0.5 || 0.5 < v.y { + p = vec3(a * v.y, 0.0 - a * v.x + b * v.z, 0.0 - b * v.y) + } else { + p = vec3(a * v.z, b * v.z, 0.0 - a * v.x - b * v.y) + } + return normalize(p) +} + is_normalized(a: Vec3) -> bool { aa = dot(a, a) return abs_float(1.0 - aa) < 100.0 * EPSILON diff --git a/aephysics/native/aephysics_inline.h b/aephysics/native/aephysics_inline.h index c06e2d2..50a1afe 100644 --- a/aephysics/native/aephysics_inline.h +++ b/aephysics/native/aephysics_inline.h @@ -54,6 +54,12 @@ static inline long long aephysics_load_word(const void *p, long long index) return word; } +// The byte at `index` into `p`, unsigned (a hashed block's tail). +static inline int aephysics_load_u8(const void *p, long long index) +{ + return ((const unsigned char *)p)[index]; +} + // The unsigned 16-bit value at `index` halves into `p` (a height field's // quanta), one load where two byte reads and a shift were the field's // queries' extra cost. diff --git a/aephysics/native/module.ae b/aephysics/native/module.ae index f6055bc..d3b42f6 100644 --- a/aephysics/native/module.ae +++ b/aephysics/native/module.ae @@ -11,7 +11,7 @@ exports ( MAX_WORKERS, worker_index, set_worker_index, claim_slots, release_slots, enter_traversal, leave_traversal, yield_thread, pause, processor_count, 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_u16 + name_copy, name_text, name_free, prefetch, load_word, load_u8, load_u16 ) // The most workers a world can have: every module's scratch is a block @@ -38,6 +38,7 @@ extern aephysics_semaphore_wait(s: ptr) extern aephysics_semaphore_signal(s: ptr, count: int) extern aephysics_prefetch(p: ptr) @c_import extern aephysics_load_word(p: ptr, index: long) -> long @c_import +extern aephysics_load_u8(p: ptr, index: long) -> int @c_import extern aephysics_load_u16(p: ptr, index: long) -> int @c_import extern aephysics_name_copy(s: string) -> ptr extern aephysics_name_text(name: ptr) -> string @@ -50,6 +51,9 @@ prefetch(p: ptr) { aephysics_prefetch(p) } // written as (C's aliasing rule). load_word(p: ptr, index: long) -> long { return aephysics_load_word(p, index) } +// The byte at `index` into `p`, unsigned. +load_u8(p: ptr, index: long) -> int { return aephysics_load_u8(p, index) } + // The unsigned 16-bit value at `index` halves into `p` (little-endian, as // every platform aephysics runs on). load_u16(p: ptr, index: long) -> int { return aephysics_load_u16(p, index) } diff --git a/bench/RESULTS.md b/bench/RESULTS.md index c39871b..d6960e5 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -1188,6 +1188,57 @@ at 9 m/s is a step's 15 cm in before its contact exists), the farthest body 6.8991 m against 6.8992, at rest 0.8-4.7 mm against our 0.6-5.0, every body asleep at the end in both. +## suite (the reference's benchmark suite) + +The rest of the reference's `benchmark/main.c` (#141): its nine other +scenes, built by its own functions (`shared/benchmarks.c`, `e77352c`, the +release sizes) in `bench/suite_box3d.c` and by their port in +`bench/suite.ae`, each run as the reference's runner runs it: the scene's +own step before every step, the first step untimed. One thread, pinned to +a core, the better of two runs, ms per step: + +| scene | aephysics | Box3D `e77352c`, AVX2 | | height sum, contacts (ours / reference) | +|---|---|---|---|---| +| convex_pile: 5,120 of PEEL's 32-point convexes | 11.89 | 9.84 | 1.21x | 37,935 / 37,950; 51,767 / 51,706 | +| junkyard: 10,584 rocks swept by a kinematic cylinder | 22.69 | 21.80 | 1.04x | 85,925 / 90,716; 195,896 / 193,126 | +| large_world: a million static boxes, 100 spheres dropped | 0.0109 | 0.009 | 1.21x | 76.189 / 76.189; 380 / 380 | +| sleep: ten pyramids, one woken each step | 2.18 | 1.80 | 1.21x | 14,322.8 / 14,322.8; 5,890 / 5,890 | +| spinner: 1,500 spheres churned in a cylinder mesh | 3.46 | 3.17 | 1.09x | 19,206 / 19,059; 7,496 / 7,564 | +| trees25 | 1.45 | 1.25 | 1.16x | 43.99 / 44.48; 1,112 / 1,103 | +| trees50 | 0.543 | 0.513 | 1.06x | 45.59 / 45.60; 1,106 / 1,100 | +| trees100 | 0.310 | 0.299 | 1.04x | 45.20 / 45.33; 1,100 / 1,100 | +| washer: 8,000 cubes in a kinematic drum | 16.87 | 15.14 | 1.11x | 91,422 / 85,532; 41,649 / 43,736 | + +Where nothing is chaotic the two agree: the large world and the sleep +scene to the printed digit, the spinner's motor angle after 800 steps to +five (-0.157365 against -0.157361). The piles that tumble (the junkyard, +the washer, the spinner's spheres) part at the rounding of the first +collisions and end in different heaps of the same size. The reference's +trees25 warns that a mesh query filled its 256-triangle buffer; ours has +no such cap, hence its nine more contacts. + +The SAT runs (`shared/sat_benchmark.c`): 1,000 pairs of each hull pairing +placed half the speculative distance apart, the best of four runs of +twenty repeats, microseconds a query, ours / the reference's: + +| pair | GJK cold | GJK warm | SAT | SAT, no inscribed-sphere bound | +|---|---|---|---|---| +| complex/complex | 0.654 / 0.618 | 0.212 / 0.196 | 1.78 / 0.954 | 16.3 / 6.08 | +| complex/rock | 0.433 / 0.411 | 0.139 / 0.129 | 1.02 / 0.645 | 5.63 / 2.76 | +| complex/cylinder | 1.13 / 1.09 | 0.380 / 0.361 | 2.78 / 1.07 | 14.9 / 6.00 | +| rock/rock | 0.240 / 0.225 | 0.080 / 0.073 | 0.560 / 0.379 | 1.99 / 1.18 | +| rock/cylinder | 0.944 / 0.842 | 0.296 / 0.273 | 1.58 / 0.738 | 5.18 / 3.02 | +| cylinder/cylinder | 1.45 / 1.41 | 0.485 / 0.474 | 4.77 / 1.41 | 13.3 / 7.48 | + +GJK is within 3-12% of the reference's. The separating axis test is +1.5-3.4x: the reference's is the wide one of `convex_manifold_wide.inl` +(the hull's vertices as eight-lane rows, the edge candidates filtered and +sorted before any is tested), ours the four-lane edge pass of an earlier +reference. That is the convex pile's gap, and #150. Across the scenes the +pairs phase is 1.4-1.6x the reference's (2.59 against 1.63 ms on the +convex pile): the broad phase's boxes are double, where the reference +keeps float boxes rounded outward even in its large-world build (#151). + ## Build flags (inlining) The generated C of every Aether function is a plain `static` function, diff --git a/bench/suite.ae b/bench/suite.ae new file mode 100644 index 0000000..ae94291 --- /dev/null +++ b/bench/suite.ae @@ -0,0 +1,863 @@ +// The rest of the reference's benchmark suite (benchmark/main.c, its +// scenes in shared/benchmarks.c at e77352c, #141): the convex pile, the +// junkyard, the large world, sleep, the spinner, the trees at three +// scales and the washer, each built as the reference builds them (its +// release sizes) and run as its runner runs them: the scene's own step +// before every step, the first step untimed, the rest timed. +// bench/suite_box3d.c runs the reference's own scene functions the same +// way and prints the same line, so the two are read side by side. +// +// The SAT runs come last: shared/sat_benchmark.c's hull pairs (PEEL's +// convex, the rock, a 32-sided cylinder) placed half the speculative +// distance apart, timed through GJK cold and warm and the separating axis +// test with and without its inscribed-sphere bound. +// +// AEPHYSICS_BENCH_SCENE=convex_pile|junkyard|large_world|sleep|spinner| +// trees25|trees50|trees100|washer|sat runs one; unset, all of them. +import std.string +import std.os +import aephysics.math +import aephysics.basics +import aephysics.hull +import aephysics.mesh +import aephysics.manifold +import aephysics.distance +import aephysics.dynamics +import aephysics.joint_solver +import aephysics.physics_world + +extern calloc(count: int, size: int) -> ptr +extern free(p: ptr) + +clock() -> long { return os.now_monotonic_ns() } +ms(ns: long) -> float { return (ns as float) / 1000000.0 } + +const TIME_STEP = 0.016666666666666666 +const SUB_STEPS = 4 + +// The hulls and meshes a scene's shapes hold, freed after its world. +var g_hulls: ptr = null +var g_hull_count = 0 +var g_meshes: ptr = null +var g_mesh_count = 0 + +keep_hull(h: *HullData) -> *HullData { + hulls = g_hulls as ptr[] + hulls[g_hull_count] = h as ptr + g_hull_count = g_hull_count + 1 + return h +} + +keep_mesh(m: *MeshData) -> *MeshData { + meshes = g_meshes as ptr[] + meshes[g_mesh_count] = m as ptr + g_mesh_count = g_mesh_count + 1 + return m +} + +release_kept() { + hulls = g_hulls as ptr[] + i = 0 + while i < g_hull_count { + hull.destroy_hull(hulls[i] as *HullData) + i = i + 1 + } + g_hull_count = 0 + meshes = g_meshes as ptr[] + i = 0 + while i < g_mesh_count { + mesh.destroy_mesh(meshes[i] as *MeshData) + i = i + 1 + } + g_mesh_count = 0 +} + +// The dynamic bodies' heights summed in id order, as the reference's +// driver sums them. +height_sum(world_id: WorldId) -> float { + w = dynamics.get_world(world_id.index1 - 1) + sum = 0.0 + i = 0 + while i < w.bodies.count { + body = dynamics.body_at(w, i) + if body.set_index != dynamics.NULL_INDEX && body.kind == dynamics.BODY_DYNAMIC { + sum = sum + dynamics.get_body_sim(w, body).transform.p.y + } + i = i + 1 + } + return sum +} + +// --- the scenes' own steps ------------------------------------------------------------------------------------ + +const SCENE_NONE = 0 +const SCENE_JUNKYARD = 1 +const SCENE_LARGE_WORLD = 2 +const SCENE_SLEEP = 3 + +var g_pusher: ptr = null // BodyId[1]: the junkyard's kinematic cylinder +var g_pusher_degrees = 0.0 +var g_pusher_radius = 35.0 +var g_spheres_dropped = 0 +var g_sleep_a: ptr = null // BodyId[SLEEP_PYRAMID_COUNT] +var g_sleep_b: ptr = null +var g_spinner: ptr = null // JointId[1]: the spinner's motor, while its scene runs + +scene_step(scene: int, world_id: WorldId, step: int) { + if scene == SCENE_JUNKYARD { step_junkyard() } + else if scene == SCENE_LARGE_WORLD { step_large_world(world_id, step) } + else if scene == SCENE_SLEEP { step_sleep(world_id) } +} + +run(name: string, world_id: WorldId, steps: int, scene: int) { + scene_step(scene, world_id, 0) + physics_world.step(world_id, TIME_STEP, SUB_STEPS) + pairs_ms = 0.0 + collide_ms = 0.0 + t0 = clock() + s = 1 + while s < steps { + scene_step(scene, world_id, s) + physics_world.step(world_id, TIME_STEP, SUB_STEPS) + seen = physics_world.get_profile(world_id) + pairs_ms = pairs_ms + seen.pairs + collide_ms = collide_ms + seen.collide + s = s + 1 + } + t1 = clock() + timed = (steps - 1) as float + counters = physics_world.get_counters(world_id) + awake = physics_world.get_awake_body_count(world_id) + println("aephysics suite: ${name}: ${steps} steps of ${counters.body_count} bodies ${ms(t1 - t0)} ms, ${ms(t1 - t0) / timed} ms per step (height sum ${height_sum(world_id)}, ${counters.contact_count} contacts, ${counters.joint_count} joints, ${awake} awake)") + println("aephysics phases ms/step: pairs ${pairs_ms / timed} collide ${collide_ms / timed}") + spinner_joint = g_spinner as JointId[] + if spinner_joint[0].index1 != 0 { + println("aephysics spinner angle ${joint_solver.revolute_joint_get_angle(spinner_joint[0])}") + spinner_joint[0] = JointId { index1: 0, world0: 0, generation: 0 } + } + dynamics.destroy_world(world_id) + release_kept() +} + +world_with(static_bodies: int, dynamic_bodies: int, static_shapes: int, dynamic_shapes: int, contacts: int) -> WorldId { + wdef = dynamics.default_world_def() + wdef.static_body_count = static_bodies + wdef.dynamic_body_count = dynamic_bodies + wdef.static_shape_count = static_shapes + wdef.dynamic_shape_count = dynamic_shapes + wdef.contact_count = contacts + return dynamics.create_world(&wdef) +} + +// A static ground: a box of half extents (hx, hy, hz) at height y. +ground_box(world_id: WorldId, y: float, hx: float, hy: float, hz: float) -> BodyId { + bdef = dynamics.default_body_def() + bdef.position = math.vec3(0.0, y, 0.0) + body = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(body, &sdef, keep_hull(hull.make_box_hull(hx, hy, hz))) + return body +} + +// --- convex pile: a huge pile of PEEL's large convexes ------------------------------------------------------ + +convex_pile() { + world_id = world_with(0, 5120, 0, 5120, 64 * 1024) + ground_box(world_id, 0.0 - 1.0, 250.0, 1.0, 250.0) + count_x = 8 + count_z = 8 + layers = 80 + amplitude = 2.0 + scatter = 2.0 * amplitude + convex = keep_hull(hull.create_complex_hull(amplitude)) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + sdef = dynamics.default_shape_def() + layer = 0 + while layer < layers { + z = 0 + while z < count_z { + x = 0 + while x < count_x { + px = ((x as float) - 0.5 * (count_x as float)) * scatter + pz = ((z as float) - 0.5 * (count_z as float)) * scatter + py = amplitude + 2.0 * amplitude * (layer as float) + bdef.position = math.vec3(px, py, pz) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_hull_shape(body, &sdef, convex) + x = x + 1 + } + z = z + 1 + } + layer = layer + 1 + } + run("convex_pile", world_id, 500, SCENE_NONE) +} + +// --- junkyard: rocks in a walled yard, swept by a kinematic cylinder -------------------------------------- + +junkyard() { + world_id = world_with(16, 20 * 20 * 24 + 1, 16, 20 * 20 * 24 + 1, 250 * 1024) + bdef = dynamics.default_body_def() + bdef.position = math.vec3(0.0, 0.0 - 1.0, 0.0) + ground = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(ground, &sdef, keep_hull(hull.make_box_hull(120.0, 1.0, 120.0))) + dynamics.create_hull_shape(ground, &sdef, keep_hull(hull.make_offset_box_hull(1.0, 8.0, 50.0, math.vec3(0.0 - 50.0, 8.0, 0.0)))) + dynamics.create_hull_shape(ground, &sdef, keep_hull(hull.make_offset_box_hull(1.0, 8.0, 50.0, math.vec3(50.0, 8.0, 0.0)))) + dynamics.create_hull_shape(ground, &sdef, keep_hull(hull.make_offset_box_hull(50.0, 8.0, 1.0, math.vec3(0.0, 8.0, 0.0 - 50.0)))) + dynamics.create_hull_shape(ground, &sdef, keep_hull(hull.make_offset_box_hull(50.0, 8.0, 1.0, math.vec3(0.0, 8.0, 50.0)))) + rock = keep_hull(hull.create_rock(1.5)) + count = 24 + height = 24.0 + rdef = dynamics.default_body_def() + rdef.kind = dynamics.BODY_DYNAMIC + Y = 0 + while Y < count { + X = 0 + while X <= 20 { + Z = 0 + while Z <= 20 { + rdef.position = math.vec3(0.0 - 40.0 + 4.0 * (X as float), 4.0 * (Y as float) + height + 1.0, 0.0 - 40.0 + 4.0 * (Z as float)) + body = dynamics.create_body(world_id, &rdef) + dynamics.create_hull_shape(body, &sdef, rock) + Z = Z + 1 + } + X = X + 1 + } + Y = Y + 1 + } + g_pusher_radius = 35.0 + g_pusher_degrees = 0.0 + pdef = dynamics.default_body_def() + pdef.kind = dynamics.BODY_KINEMATIC + pdef.position = math.vec3(g_pusher_radius, 0.0, 0.0) + pusher = g_pusher as BodyId[] + pusher[0] = dynamics.create_body(world_id, &pdef) + dynamics.create_hull_shape(pusher[0], &sdef, keep_hull(hull.create_cylinder(24.0, 4.0, 0.0, 16))) + run("junkyard", world_id, 500, SCENE_JUNKYARD) +} + +// The pusher driven round a circle at six degrees a second, by target. +step_junkyard() { + omega = 0.0 - 6.0 + g_pusher_degrees = g_pusher_degrees + omega * TIME_STEP + cs = math.compute_cos_sin(g_pusher_degrees * math.PI / 180.0) + r = g_pusher_radius + target = Transform { p: math.vec3(r * cs.cosine, 0.0, r * cs.sine), q: math.quat_identity() } + pusher = g_pusher as BodyId[] + dynamics.body_set_target_transform(pusher[0], target, TIME_STEP, false) +} + +// --- large world: a million static boxes, spheres dropped onto them ------------------------------------- + +const FLOOR_CELL = 10.0 +const FLOOR_GRID = 1000 +const FLOOR_SPHERES = 100 +const FLOOR_DROP_INTERVAL = 5 + +large_world() { + floors = FLOOR_GRID * FLOOR_GRID + world_id = world_with(floors, FLOOR_SPHERES, floors, FLOOR_SPHERES, math.max_int(1024, 8 * FLOOR_SPHERES)) + g_spheres_dropped = 0 + half_span = 0.5 * FLOOR_CELL * (FLOOR_GRID as float) + box = keep_hull(hull.make_box_hull(0.5 * FLOOR_CELL, 0.25, 0.5 * FLOOR_CELL)) + bdef = dynamics.default_body_def() + sdef = dynamics.default_shape_def() + // Every static shape goes into the move set as it is made. + sdef.invoke_contact_creation = true + i = 0 + while i < FLOOR_GRID { + x = 0.0 - half_span + ((i as float) + 0.5) * FLOOR_CELL + j = 0 + while j < FLOOR_GRID { + z = 0.0 - half_span + ((j as float) + 0.5) * FLOOR_CELL + bdef.position = math.vec3(x, 0.0, z) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_hull_shape(body, &sdef, box) + j = j + 1 + } + i = i + 1 + } + run("large_world", world_id, 500, SCENE_LARGE_WORLD) +} + +// A sphere every fifth step, in a coarse grid over the inner floor. +step_large_world(world_id: WorldId, step: int) { + if g_spheres_dropped >= FLOOR_SPHERES { return } + if step == 0 { return } + if (step % FLOOR_DROP_INTERVAL) != 0 { return } + side = 1 + while side * side < FLOOR_SPHERES { side = side + 1 } + gi = g_spheres_dropped % side + gj = g_spheres_dropped / side + half_span = 0.5 * FLOOR_CELL * (FLOOR_GRID as float) + inset = 0.1 * 2.0 * half_span + usable = 2.0 * half_span - 2.0 * inset + x = 0.0 - half_span + inset + ((gi as float) + 0.5) * (usable / (side as float)) + z = 0.0 - half_span + inset + ((gj as float) + 0.5) * (usable / (side as float)) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.position = math.vec3(x, 1.5, z) + sdef = dynamics.default_shape_def() + body = dynamics.create_body(world_id, &bdef) + dynamics.create_sphere_shape(body, &sdef, manifold.sphere(math.vec3_zero(), 0.5)) + g_spheres_dropped = g_spheres_dropped + 1 +} + +// --- sleep: ten pyramids, one woken again each step ---------------------------------------------------- + +const SLEEP_PYRAMIDS = 10 + +sleep_pyramid(world_id: WorldId, base_count: int, extent: float, center_x: float, index: int, box: *HullData) { + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.sleep_threshold = 1.0 + sdef = dynamics.default_shape_def() + a = g_sleep_a as BodyId[] + b = g_sleep_b as BodyId[] + i = 0 + while i < base_count { + y = (2.0 * (i as float) + 1.0) * extent + j = i + while j < base_count { + x = ((i as float) + 1.0) * extent + 2.0 * ((j - i) as float) * extent + center_x + bdef.position = math.vec3(x, y, 0.0) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_hull_shape(body, &sdef, box) + if i == 0 && j == 0 { a[index] = body } + else if i == 1 && j == 1 { b[index] = body } + j = j + 1 + } + i = i + 1 + } +} + +sleep_scene() { + world_id = world_with(4, 2200, 4, 2200, 12000) + base_count = 20 + extent = 0.5 + base_width = 2.0 * extent * (base_count as float) + pitch = base_width + 8.0 * extent + span = pitch * ((SLEEP_PYRAMIDS - 1) as float) + ground_box(world_id, 0.0 - 1.0, 0.5 * span + base_width, 1.0, 4.0) + box = keep_hull(hull.make_box_hull(extent, extent, extent)) + i = 0 + while i < SLEEP_PYRAMIDS { + center_x = 0.0 - 0.5 * span + (i as float) * pitch - 0.5 * base_width + sleep_pyramid(world_id, base_count, extent, center_x, i, box) + i = i + 1 + } + run("sleep", world_id, 300, SCENE_SLEEP) +} + +// The first pyramid found asleep is woken by a filter joint made and +// destroyed between two of its touching bodies (the island splitter at work). +step_sleep(world_id: WorldId) { + a = g_sleep_a as BodyId[] + b = g_sleep_b as BodyId[] + i = 0 + while i < SLEEP_PYRAMIDS { + if dynamics.body_is_awake(a[i]) == false { + jdef = dynamics.default_filter_joint_def() + jdef.base.body_id_a = a[i] + jdef.base.body_id_b = b[i] + joint = dynamics.create_filter_joint(world_id, &jdef) + dynamics.destroy_joint(joint, true) + return + } + i = i + 1 + } +} + +// --- spinner: a motored paddle churning spheres in a closed cylinder --------------------------------- + +const SPINNER_SEGMENTS = 90 + +// An inward cylinder about z, capped at both ends, every triangle wound +// to face the axis. +inward_cylinder_mesh(radius: float, half_depth: float, y_center: float, segments: int) -> *MeshData { + vertex_count = 2 * segments + 2 + triangle_count = 4 * segments + vertex_block = basics.alloc(vertex_count * sizeof(Vec3)) + index_block = basics.alloc(3 * triangle_count * 4) + vertices = vertex_block as Vec3[] + indices = index_block as int[] + i = 0 + while i < segments { + cs = math.compute_cos_sin(2.0 * math.PI * (i as float) / (segments as float)) + x = radius * cs.cosine + y = radius * cs.sine + y_center + vertices[2 * i] = math.vec3(x, y, 0.0 - half_depth) + vertices[2 * i + 1] = math.vec3(x, y, half_depth) + i = i + 1 + } + near = 2 * segments + far = 2 * segments + 1 + vertices[near] = math.vec3(0.0, y_center, 0.0 - half_depth) + vertices[far] = math.vec3(0.0, y_center, half_depth) + k = 0 + i = 0 + while i < segments { + j = (i + 1) % segments + // The wall, its normal toward the axis. + indices[k] = 2 * i + indices[k + 1] = 2 * i + 1 + indices[k + 2] = 2 * j + 1 + indices[k + 3] = 2 * i + indices[k + 4] = 2 * j + 1 + indices[k + 5] = 2 * j + // The near cap toward +z, the far one toward -z. + indices[k + 6] = near + indices[k + 7] = 2 * i + indices[k + 8] = 2 * j + indices[k + 9] = far + indices[k + 10] = 2 * j + 1 + indices[k + 11] = 2 * i + 1 + k = k + 12 + i = i + 1 + } + mdef = MeshDef { vertices: vertex_block, stride: 0, indices: index_block, material_indices: null, weld_tolerance: 0.0, + vertex_count: vertex_count, triangle_count: triangle_count, weld_vertices: false, use_median_split: false, + identify_edges: true, clockwise_winding: false } + m = mesh.create_mesh(&mdef, null, 0) + basics.free_bytes(vertex_block, vertex_count * sizeof(Vec3)) + basics.free_bytes(index_block, 3 * triangle_count * 4) + return m +} + +spinner() { + world_id = world_with(4, 2000, 4, 2000, 20000) + radius = 12.0 + half_depth = 1.0 + y_center = radius + gdef = dynamics.default_body_def() + ground = dynamics.create_body(world_id, &gdef) + msdef = dynamics.default_shape_def() + msdef.base_material.friction = 0.1 + dynamics.create_mesh_shape(ground, &msdef, keep_mesh(inward_cylinder_mesh(radius, half_depth, y_center, SPINNER_SEGMENTS)), math.vec3_one()) + half_length = 0.5 * radius + pdef = dynamics.default_body_def() + pdef.kind = dynamics.BODY_DYNAMIC + pdef.position = math.vec3(0.0, y_center - half_length, 0.0) + pdef.enable_sleep = false + paddle = dynamics.create_body(world_id, &pdef) + psdef = dynamics.default_shape_def() + psdef.base_material.friction = 0.0 + dynamics.create_hull_shape(paddle, &psdef, keep_hull(hull.make_box_hull(0.3, half_length + 0.1, half_depth + 0.1))) + jdef = dynamics.default_revolute_joint_def() + jdef.base.body_id_a = ground + jdef.base.body_id_b = paddle + jdef.base.local_frame_a.p = math.vec3(0.0, y_center - half_length, 0.0) + jdef.enable_motor = true + jdef.motor_speed = 8.0 + jdef.max_motor_torque = math.MAX_FLOAT + spinner_joint = g_spinner as JointId[] + spinner_joint[0] = dynamics.create_revolute_joint(world_id, &jdef) + sphere_radius = 0.2 + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + sdef = dynamics.default_shape_def() + sdef.base_material.friction = 0.1 + sdef.base_material.restitution = 0.1 + sdef.density = 0.25 + body_count = 1500 + // The grid starts low, where the cylinder is narrow: the chord there bounds it. + spacing = 0.55 + y_start = 2.0 + chord = sqrt(radius * radius - (y_center - y_start) * (y_center - y_start)) + x_limit = chord - 2.0 * spacing + z_limit = half_depth - 2.0 * sphere_radius + x = 0.0 - x_limit + y = y_start + z = 0.0 - z_limit + i = 0 + while i < body_count { + bdef.position = math.vec3(x, y, z) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_sphere_shape(body, &sdef, manifold.sphere(math.vec3_zero(), sphere_radius)) + z = z + spacing + if z > z_limit { + z = 0.0 - z_limit + x = x + spacing + if x > x_limit { + x = 0.0 - x_limit + y = y + spacing + } + } + i = i + 1 + } + run("spinner", world_id, 800, SCENE_NONE) +} + +// --- trees: fifty tall stacks of cylinders rolling over a wave mesh ----------------------------------- + +trees(name: string, scale: int) { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + gdef = dynamics.default_body_def() + ground = dynamics.create_body(world_id, &gdef) + x_count = scale * 150 + z_count = scale * 200 + cell_width = 1.0 / (scale as float) + wave = keep_mesh(mesh.create_wave_mesh(x_count, z_count, cell_width, 0.4, 0.05, 0.1)) + sdef = dynamics.default_shape_def() + dynamics.create_mesh_shape(ground, &sdef, wave, math.vec3_one()) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.sleep_threshold = 0.2 + body_count = 50 + sdef.base_material.friction = 0.9 + sdef.base_material.rolling_resistance = 0.05 + sdef.update_body_mass = false + sdef.density = 1.0 + hull_count = 22 + hulls_block = calloc(hull_count, 8) + hulls = hulls_block as ptr[] + y = 1.0 + r = 0.75 + l = 1.5 + i = 0 + while i < hull_count { + hulls[i] = keep_hull(hull.create_cylinder(l + 2.0 * r, r, y - r, 6)) as ptr + y = y + l + 2.0 * r + r = 0.95 * r + i = i + 1 + } + angular_velocity = 0.0 - 0.5 + z = 0.0 - 70.0 + b = 0 + while b < body_count { + bdef.position = math.vec3(0.0, 1.0, z) + body = dynamics.create_body(world_id, &bdef) + k = 0 + while k < hull_count { + dynamics.create_hull_shape(body, &sdef, hulls[k] as *HullData) + k = k + 1 + } + velocity_scale = 0.5 + (0.5 * (b as float)) / (body_count as float) + dynamics.body_apply_mass_from_shapes(body) + center = dynamics.body_get_world_center(body) + omega = math.vec3(0.0, 0.0, velocity_scale * angular_velocity) + v = math.cross(omega, math.sub(center, bdef.position)) + dynamics.body_set_angular_velocity(body, omega) + dynamics.body_set_linear_velocity(body, v) + z = z + 3.0 + angular_velocity = 0.0 - angular_velocity + b = b + 1 + } + free(hulls_block) + run(name, world_id, 500, SCENE_NONE) +} + +// --- washer: a kinematic drum tumbling eight thousand cubes ------------------------------------------- + +drum_segment(body: BodyId, sdef: *ShapeDef, p: Vec3[]) { + block = basics.alloc(8 * sizeof(Vec3)) + points = block as Vec3[] + i = 0 + while i < 8 { + points[i] = p[i] + i = i + 1 + } + dynamics.create_hull_shape(body, sdef, keep_hull(hull.create_hull(points, 8, 8))) + basics.free_bytes(block, 8 * sizeof(Vec3)) +} + +washer() { + world_id = world_with(16, 10000, 16, 10000, 60000) + ground_box(world_id, 0.0 - 1.0, 60.0, 1.0, 60.0) + motor_speed = 25.0 + ddef = dynamics.default_body_def() + ddef.position = math.vec3(0.0, 21.0, 0.0) + ddef.kind = dynamics.BODY_KINEMATIC + ddef.angular_velocity = math.vec3(0.0, 0.0, (math.PI / 180.0) * motor_speed) + ddef.linear_velocity = math.vec3(0.001, 0.0 - 0.002, 0.0) + drum = dynamics.create_body(world_id, &ddef) + sdef = dynamics.default_shape_def() + r0 = 14.0 + r1 = 16.0 + r2 = 18.0 + nd = math.vec3(0.0, 0.0, 0.0 - 10.0) + pd = math.vec3(0.0, 0.0, 10.0) + angle = math.PI / 18.0 + q = math.make_quat_from_axis_angle(math.vec3_axis_z(), angle) + qo = math.make_quat_from_axis_angle(math.vec3_axis_z(), 0.1 * angle) + u1 = math.vec3(1.0, 0.0, 0.0) + corner_block = basics.alloc(8 * sizeof(Vec3)) + p = corner_block as Vec3[] + i = 0 + while i < 36 { + u2 = math.rotate_vector(q, u1) + if i == 35 { u2 = math.vec3(1.0, 0.0, 0.0) } + a1 = math.inv_rotate_vector(qo, u1) + a2 = math.rotate_vector(qo, u2) + p[0] = math.mul_add(nd, r1, a1) + p[1] = math.mul_add(nd, r2, a1) + p[2] = math.mul_add(nd, r1, a2) + p[3] = math.mul_add(nd, r2, a2) + p[4] = math.mul_add(pd, r1, a1) + p[5] = math.mul_add(pd, r2, a1) + p[6] = math.mul_add(pd, r1, a2) + p[7] = math.mul_add(pd, r2, a2) + drum_segment(drum, &sdef, p) + if i % 9 == 0 { + p[0] = math.mul_add(nd, r0, u1) + p[1] = math.mul_add(nd, r1, u1) + p[2] = math.mul_add(nd, r0, u2) + p[3] = math.mul_add(nd, r1, u2) + p[4] = math.mul_add(pd, r0, u1) + p[5] = math.mul_add(pd, r1, u1) + p[6] = math.mul_add(pd, r0, u2) + p[7] = math.mul_add(pd, r1, u2) + drum_segment(drum, &sdef, p) + } + u1 = u2 + i = i + 1 + } + basics.free_bytes(corner_block, 8 * sizeof(Vec3)) + grid = 20 + a = 0.2 + cube = keep_hull(hull.make_box_hull(a, a, a)) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + csdef = dynamics.default_shape_def() + x = 0.0 - 2.0 * a * (grid as float) + gi = 0 + while gi < grid { + y = 0.0 - 2.0 * a * (grid as float) + 21.0 + gj = 0 + while gj < grid { + z = 0.0 - 2.0 * a * (grid as float) + gk = 0 + while gk < grid { + bdef.position = math.vec3(x, y, z) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_hull_shape(body, &csdef, cube) + z = z + 4.0 * a + gk = gk + 1 + } + y = y + 4.0 * a + gj = gj + 1 + } + x = x + 4.0 * a + gi = gi + 1 + } + run("washer", world_id, 1000, SCENE_NONE) +} + +// --- the SAT runs (shared/sat_benchmark.c): GJK and the separating axis on placed hull pairs --------------- + +const SAT_PAIRS = 1000 +const SAT_REPEATS = 20 +const SAT_RUNS = 4 +const RAND_LIMIT = 32767 +const MASK32: long = 4294967295 + +var g_random_seed: long = 1 + +// The reference's XorShift32 (shared/utils.h), its range and Shoemake's rotation. +random_int() -> int { + x = g_random_seed + x = (x ^ (x << 13)) & MASK32 + x = x ^ (x >> 17) + x = (x ^ (x << 5)) & MASK32 + g_random_seed = x + return (x % ((RAND_LIMIT + 1) as long)) as int +} + +random_float_range(lo: float, hi: float) -> float { + r = ((random_int() & RAND_LIMIT) as float) / (RAND_LIMIT as float) + return (hi - lo) * r + lo +} + +random_quat() -> Quat { + u1 = random_float_range(0.0, 1.0) + u2 = random_float_range(0.0, 2.0 * math.PI) + u3 = random_float_range(0.0, 2.0 * math.PI) + cs2 = math.compute_cos_sin(u2) + cs3 = math.compute_cos_sin(u3) + return math.quat_vs(math.vec3(sqrt(1.0 - u1) * cs2.sine, sqrt(1.0 - u1) * cs2.cosine, sqrt(u1) * cs3.sine), sqrt(u1) * cs3.cosine) +} + +sat_hull(kind: int) -> *HullData { + if kind == 0 { return hull.create_complex_hull(0.5) } + if kind == 1 { return hull.create_rock(0.5) } + return hull.create_cylinder(1.0, 0.1, 0.0 - 0.5, 32) +} + +sat_name(kind: int) -> string { + if kind == 0 { return "complex" } + if kind == 1 { return "rock" } + return "cylinder" +} + +bounding_radius(h: *HullData) -> float { return math.length(math.aabb_center(h.aabb)) + math.length(math.aabb_extents(h.aabb)) } + +zero_cache() -> SimplexCache { + return SimplexCache { metric: 0.0, count: 0, index_a0: 0, index_a1: 0, index_a2: 0, index_a3: 0, index_b0: 0, index_b1: 0, index_b2: 0, index_b3: 0 } +} + +// One timed pass over the placed pairs: the distance (cold or warm from +// the placement's caches), then the separating axis with its early return. +// The milliseconds of each, and the early returns, which the placement +// keeps at zero. +struct SatPass { + distance_ms: float + sat_ms: float + early_returns: int +} + +sat_pass(a: *HullData, b: *HullData, transforms: Transform[], caches: SimplexCache[], warm: bool) -> SatPass { + input = DistanceInput { proxy_a: ShapeProxy { points: hull.hull_points(a) as ptr, count: a.vertex_count, radius: 0.0 }, + proxy_b: ShapeProxy { points: hull.hull_points(b) as ptr, count: b.vertex_count, radius: 0.0 }, + transform: math.transform_identity(), use_radii: false } + sum = 0.0 + t0 = clock() + r = 0 + while r < SAT_REPEATS { + i = 0 + while i < SAT_PAIRS { + input.transform = transforms[i] + cache = zero_cache() + if warm { cache = caches[i] } + out = distance.shape_distance(&input, &cache, null, 0) + sum = sum + out.distance + i = i + 1 + } + r = r + 1 + } + t1 = clock() + early = 0 + r = 0 + while r < SAT_REPEATS { + i = 0 + while i < SAT_PAIRS { + query = manifold.compute_separating_axis(a, b, transforms[i], true) + axis = manifold.get_best_axis(&query) + sum = sum + axis.separation + if query.separated_feature != manifold.AXIS_INVALID { early = early + 1 } + i = i + 1 + } + r = r + 1 + } + t2 = clock() + if sum == 12345.678 { println("") } + return SatPass { distance_ms: ms(t1 - t0), sat_ms: ms(t2 - t1), early_returns: early } +} + +sat_pair(kind_a: int, kind_b: int) { + a = sat_hull(kind_a) + b = sat_hull(kind_b) + inner_a = a.inner_radius + inner_b = b.inner_radius + transforms_block = calloc(SAT_PAIRS, sizeof(Transform)) + caches_block = calloc(SAT_PAIRS, sizeof(SimplexCache)) + transforms = transforms_block as Transform[] + caches = caches_block as SimplexCache[] + input = DistanceInput { proxy_a: ShapeProxy { points: hull.hull_points(a) as ptr, count: a.vertex_count, radius: 0.0 }, + proxy_b: ShapeProxy { points: hull.hull_points(b) as ptr, count: b.vertex_count, radius: 0.0 }, + transform: math.transform_identity(), use_radii: false } + // Each pair placed half the speculative distance apart, as the + // reference places them: from far out along a random direction, moved + // in along the GJK normal until the distance is within 1% of it. + target = 0.5 * dynamics.SPECULATIVE_DISTANCE + tolerance = 0.01 * target + reach = bounding_radius(a) + bounding_radius(b) + 0.5 + worst = 0.0 + g_random_seed = 1234 + i = 0 + while i < SAT_PAIRS { + q = random_quat() + direction = math.rotate_vector(random_quat(), math.vec3_axis_x()) + t = Transform { p: math.mul_sv(reach, direction), q: q } + cache = zero_cache() + error = 0.0 + k = 0 + while k < 20 { + input.transform = t + out = distance.shape_distance(&input, &cache, null, 0) + error = out.distance - target + if math.abs_float(error) < tolerance { break } + t.p = math.mul_sub(t.p, error, out.normal) + k = k + 1 + } + transforms[i] = t + caches[i] = cache + worst = math.max_float(worst, math.abs_float(error)) + i = i + 1 + } + cold_ms = math.MAX_FLOAT + warm_ms = math.MAX_FLOAT + sat_ms = math.MAX_FLOAT + full_ms = math.MAX_FLOAT + early = 0 + run = 0 + while run < SAT_RUNS { + a.inner_radius = inner_a + b.inner_radius = inner_b + cold = sat_pass(a, b, transforms, caches, false) + warm = sat_pass(a, b, transforms, caches, true) + // "sat full": the inscribed sphere's bound turned off. + a.inner_radius = 0.0 - math.HUGE + b.inner_radius = 0.0 - math.HUGE + full = sat_pass(a, b, transforms, caches, false) + cold_ms = math.min_float(cold_ms, cold.distance_ms) + warm_ms = math.min_float(warm_ms, warm.distance_ms) + sat_ms = math.min_float(sat_ms, math.min_float(cold.sat_ms, warm.sat_ms)) + full_ms = math.min_float(full_ms, full.sat_ms) + early = early + cold.early_returns + full.early_returns + run = run + 1 + } + a.inner_radius = inner_a + b.inner_radius = inner_b + scale = 1000.0 / ((SAT_REPEATS * SAT_PAIRS) as float) + println("aephysics sat: ${sat_name(kind_a)}/${sat_name(kind_b)}: gjk cold ${scale * cold_ms} us, gjk warm ${scale * warm_ms}, sat ${scale * sat_ms}, sat full ${scale * full_ms} (early returns ${early}, worst placement error ${worst})") + free(transforms_block) + free(caches_block) + hull.destroy_hull(a) + hull.destroy_hull(b) +} + +sat_runs() { + kind_a = 0 + while kind_a < 3 { + kind_b = kind_a + while kind_b < 3 { + sat_pair(kind_a, kind_b) + kind_b = kind_b + 1 + } + kind_a = kind_a + 1 + } +} + +wanted(scene: string) -> bool { + asked = os.getenv("AEPHYSICS_BENCH_SCENE") + if asked == null { return true } + return string.equals(asked, scene) == 1 +} + +main() { + g_hulls = calloc(256, 8) + g_meshes = calloc(8, 8) + g_sleep_a = calloc(SLEEP_PYRAMIDS, sizeof(BodyId)) + g_sleep_b = calloc(SLEEP_PYRAMIDS, sizeof(BodyId)) + g_pusher = calloc(1, sizeof(BodyId)) + g_spinner = calloc(1, sizeof(JointId)) + if wanted("convex_pile") { convex_pile() } + if wanted("junkyard") { junkyard() } + if wanted("large_world") { large_world() } + if wanted("sleep") { sleep_scene() } + if wanted("spinner") { spinner() } + if wanted("trees25") { trees("trees25", 4) } + if wanted("trees50") { trees("trees50", 2) } + if wanted("trees100") { trees("trees100", 1) } + if wanted("washer") { washer() } + if wanted("sat") { sat_runs() } + free(g_hulls) + free(g_meshes) + free(g_sleep_a) + free(g_sleep_b) + free(g_pusher) + free(g_spinner) +} diff --git a/bench/suite_box3d.c b/bench/suite_box3d.c new file mode 100644 index 0000000..0c9dd37 --- /dev/null +++ b/bench/suite_box3d.c @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// The rest of the reference's benchmark suite (#141): its own scene +// functions (shared/benchmarks.c, e77352c) run as benchmark/main.c runs +// them, one thread: the scene's own step before every step, the first +// step untimed, the rest timed. Prints the line bench/suite.ae prints, so +// the two are read side by side. +// +// Then the SAT runs, the reference's own (shared/sat_benchmark.c). +// +// AEPHYSICS_BENCH_SCENE=|sat runs one; unset, all of them. + +#include "benchmarks.h" +#include "sat_benchmark.h" + +#include "box3d/box3d.h" + +#include +#include +#include +#include + +static double now_ms( void ) +{ + struct timespec ts; + timespec_get( &ts, TIME_UTC ); + return ts.tv_sec * 1000.0 + ts.tv_nsec / 1.0e6; +} + +typedef struct Scene +{ + const char* name; + void ( *capacity )( b3Capacity* capacity ); + void ( *create )( b3WorldId worldId ); + void ( *destroy )( void ); + void ( *step )( b3WorldId worldId, int stepIndex ); + int steps; +} Scene; + +// The dynamic bodies' heights summed in id order. These scenes destroy no +// bodies, so the ids are 1..count, each of its first generation. +static double HeightSum( b3WorldId worldId ) +{ + b3Counters counters = b3World_GetCounters( worldId ); + double sum = 0.0; + for ( int i = 1; i <= counters.bodyCount; ++i ) + { + b3BodyId id = { i, (uint16_t)( worldId.index1 - 1 ), 1 }; + if ( b3Body_IsValid( id ) && b3Body_GetType( id ) == b3_dynamicBody ) + { + sum += b3Body_GetPosition( id ).y; + } + } + return sum; +} + +static void Run( const Scene* scene ) +{ + b3WorldDef worldDef = b3DefaultWorldDef(); + worldDef.workerCount = 1; + if ( scene->capacity != NULL ) + { + scene->capacity( &worldDef.capacity ); + } + + b3WorldId worldId = b3CreateWorld( &worldDef ); + scene->create( worldId ); + + float timeStep = 1.0f / 60.0f; + int subStepCount = 4; + + if ( scene->step != NULL ) + { + scene->step( worldId, 0 ); + } + b3World_Step( worldId, timeStep, subStepCount ); + + double pairs = 0.0, collide = 0.0; + double t0 = now_ms(); + for ( int stepIndex = 1; stepIndex < scene->steps; ++stepIndex ) + { + if ( scene->step != NULL ) + { + scene->step( worldId, stepIndex ); + } + b3World_Step( worldId, timeStep, subStepCount ); + b3Profile p = b3World_GetProfile( worldId ); + pairs += p.pairs; + collide += p.collide; + } + double t1 = now_ms(); + + double timed = scene->steps - 1; + b3Counters counters = b3World_GetCounters( worldId ); + printf( "box3d suite: %s: %d steps of %d bodies %.1f ms, %.3f ms per step (height sum %.3f, %d contacts, %d joints, %d awake)\n", + scene->name, scene->steps, counters.bodyCount, t1 - t0, ( t1 - t0 ) / timed, HeightSum( worldId ), counters.contactCount, + counters.jointCount, b3World_GetAwakeBodyCount( worldId ) ); + printf( "box3d phases ms/step: pairs %.6f collide %.6f\n", pairs / timed, collide / timed ); + if ( strcmp( scene->name, "spinner" ) == 0 ) + { + printf( "box3d spinner angle %.6f\n", GetSpinnerAngle() ); + } + + b3DestroyWorld( worldId ); + if ( scene->destroy != NULL ) + { + scene->destroy(); + } + ResetGroundShapeId(); +} + +// benchmark/main.c's RunSatBenchmarks: each hull pair's GJK cold and warm +// and SAT with and without the inscribed-sphere bound, the best of four +// runs of twenty repeats, in microseconds a query. +static void RunSat( void ) +{ + int runCount = 4; + int repeatCount = 20; + for ( int typeA = 0; typeA < satHull_count; ++typeA ) + { + for ( int typeB = typeA; typeB < satHull_count; ++typeB ) + { + SatBenchmarkData* data = CreateSatBenchmark( (SatHullType)typeA, (SatHullType)typeB ); + float coldMs = 1e30f, warmMs = 1e30f, satMs = 1e30f, fullMs = 1e30f; + int queryCount = 0, earlyReturnCount = 0; + for ( int runIndex = 0; runIndex < runCount; ++runIndex ) + { + EnableSatInscribedSphere( data, true ); + SatBenchmarkResult cold = RunSatBenchmark( data, repeatCount, false ); + SatBenchmarkResult warm = RunSatBenchmark( data, repeatCount, true ); + EnableSatInscribedSphere( data, false ); + SatBenchmarkResult full = RunSatBenchmark( data, repeatCount, false ); + coldMs = cold.distanceMs < coldMs ? cold.distanceMs : coldMs; + warmMs = warm.distanceMs < warmMs ? warm.distanceMs : warmMs; + float sat = cold.satMs < warm.satMs ? cold.satMs : warm.satMs; + satMs = sat < satMs ? sat : satMs; + fullMs = full.satMs < fullMs ? full.satMs : fullMs; + queryCount = cold.queryCount; + earlyReturnCount += cold.earlyReturnCount + full.earlyReturnCount; + } + float scale = 1000.0f / queryCount; + printf( "box3d sat: %s/%s: gjk cold %.3f us, gjk warm %.3f, sat %.3f, sat full %.3f (early returns %d, worst placement error %g)\n", + GetSatHullName( (SatHullType)typeA ), GetSatHullName( (SatHullType)typeB ), scale * coldMs, scale * warmMs, scale * satMs, + scale * fullMs, earlyReturnCount, data->maxPlacementError ); + DestroySatBenchmark( data ); + } + } +} + +int main( void ) +{ + Scene scenes[] = { + { "convex_pile", GetConvexPileCapacity, CreateConvexPile, NULL, NULL, 500 }, + { "junkyard", GetJunkyardCapacity, CreateJunkyard, NULL, StepJunkyard, 500 }, + { "large_world", GetLargeWorldCapacity, CreateLargeWorld, NULL, StepLargeWorld, 500 }, + { "sleep", GetSleepCapacity, CreateSleep, NULL, StepSleep, 300 }, + { "spinner", GetSpinnerCapacity, CreateSpinner, DestroySpinner, NULL, 800 }, + { "trees25", NULL, CreateTrees25, DestroyTrees, NULL, 500 }, + { "trees50", NULL, CreateTrees50, DestroyTrees, NULL, 500 }, + { "trees100", NULL, CreateTrees100, DestroyTrees, NULL, 500 }, + { "washer", GetWasherCapacity, CreateWasher, NULL, NULL, 1000 }, + }; + + const char* asked = getenv( "AEPHYSICS_BENCH_SCENE" ); + for ( int i = 0; i < (int)( sizeof( scenes ) / sizeof( scenes[0] ) ); ++i ) + { + if ( asked == NULL || strcmp( asked, scenes[i].name ) == 0 ) + { + Run( scenes + i ); + } + } + if ( asked == NULL || strcmp( asked, "sat" ) == 0 ) + { + RunSat(); + } + return 0; +} diff --git a/design.md b/design.md index cd52728..ff19b98 100644 --- a/design.md +++ b/design.md @@ -363,8 +363,12 @@ started until its tests pass. not zero; poisoning the memory shows nothing reads it first). 15. **recording and replay**, `world_snapshot`: last, since they are the tooling and not the engine. -16. **benchmarks**: `reference/benchmark/main.c`'s nine scenes ported, run - against the C build on the same machine, recorded under `benchmark/`. +16. **benchmarks** (done, #141): `reference/benchmark/main.c`'s thirteen + scenes and its SAT runs, against the reference's own scene functions + on the same machine: the large pyramid, the many pyramids and the + joint grid in `bench/physics_world.ae`, the rain in `bench/human.ae`, + the other nine scenes and the SAT runs in `bench/suite.ae` beside + `bench/suite_box3d.c`; recorded in `bench/RESULTS.md`. ## Measures diff --git a/tests/test_basics.ae b/tests/test_basics.ae index 333a436..73c5492 100644 --- a/tests/test_basics.ae +++ b/tests/test_basics.ae @@ -5,6 +5,7 @@ // the arena. Every allocation the module counts is freed by the end. import std.string +import std.mem import aephysics.basics extern calloc(count: int, size: int) -> ptr @@ -27,6 +28,10 @@ ensure(name: string, ok: bool) { const BIT_COUNT = 169 const SET_SPAN = 317 +struct Holder { + values: Ints +} + struct Thing { id: int weight: float @@ -198,6 +203,91 @@ main() { ensure("remove-swap on a buffer moves the last in", items[3].id == 49 && basics.buffer_count(&things) == 49) basics.buffer_destroy(&things) + // test_container.c's TestArrayRemove: reserved to the count, then + // drained by remove-swaps from the front, summing as it goes. + draining = basics.ints_create(0) + basics.ints_reserve(&draining, 100) + ensure("reserve: the capacity and nothing in it", basics.ints_capacity(&draining) == 100 && basics.ints_count(&draining) == 0) + i = 0 + while i < 100 { + basics.ints_push(&draining, i) + i = i + 1 + } + drained = 0 + while basics.ints_count(&draining) > 0 { + drained = drained + basics.ints_get(&draining, 0) + basics.ints_remove_swap(&draining, 0) + } + ensure("remove-swap from the front drains every value (${drained})", drained == 100 * 99 / 2) + basics.ints_destroy(&draining) + // TestArrayPop: popped to empty. + popping = basics.ints_create(10) + i = 0 + while i < 10 { + basics.ints_push(&popping, i) + i = i + 1 + } + popped = 0 + while basics.ints_count(&popping) > 0 { popped = popped + basics.ints_pop(&popping) } + ensure("pop to empty gives every value (${popped})", popped == 10 * 9 / 2 && basics.ints_capacity(&popping) == 10) + basics.ints_destroy(&popping) + // TestStructWithArray: an array held in a struct. + holder = Holder { values: basics.ints_create(0) } + basics.ints_push(&holder.values, 1) + basics.ints_push(&holder.values, 2) + basics.ints_push(&holder.values, 3) + held = 0 + i = 0 + while i < basics.ints_count(&holder.values) { + held = held + basics.ints_get(&holder.values, i) + i = i + 1 + } + ensure("an array in a struct holds what was pushed", held == 6) + basics.ints_destroy(&holder.values) + + // test_allocator.c: two elements allocated, freed, the allocator + // destroyed (TestBlockAllocate, TestBlockClear); then, for elements of + // 12 bytes, a manifold and three, more than a block's worth, every one + // 16-byte aligned, freed, allocated again from the free list, still + // aligned (TestBlockAlignment). + blocks = basics.block_allocator_create(8) + first = basics.block_allocate(&blocks) + second = basics.block_allocate(&blocks) + ensure("block allocator: two elements", first != null && second != null && first != second) + basics.block_free(&blocks, first) + basics.block_free(&blocks, second) + ensure("block allocator: both freed", blocks.allocation_count == 0) + basics.block_allocator_destroy(&blocks) + sizes = [ 12, 268, 3 * 268 ] + element_count = basics.BLOCK_SIZE + 8 + elements_block = calloc(element_count, 8) + elements = elements_block as ptr[] + k = 0 + while k < 3 { + sized = basics.block_allocator_create(sizes[k]) + round = 0 + while round < 2 { + aligned = true + j = 0 + while j < element_count { + elements[j] = basics.block_allocate(&sized) + if elements[j] == null || (mem.ptr_to_long(elements[j]) & (15 as long)) != (0 as long) { aligned = false } + j = j + 1 + } + ensure("block allocator: ${element_count} elements of ${sizes[k]} bytes, 16-byte aligned (round ${round})", aligned) + j = 0 + while j < element_count { + basics.block_free(&sized, elements[j]) + j = j + 1 + } + round = round + 1 + } + ensure("block allocator: the second round reused the blocks (${sized.blocks.count})", sized.blocks.count == 2 && sized.allocation_count == 0) + basics.block_allocator_destroy(&sized) + k = k + 1 + } + free(elements_block) + // The stack: LIFO, with the heap past its capacity. stack = basics.stack_create(256) first = basics.stack_alloc(&stack, 100) diff --git a/tests/test_collision.ae b/tests/test_collision.ae new file mode 100644 index 0000000..a1b98bc --- /dev/null +++ b/tests/test_collision.ae @@ -0,0 +1,186 @@ +// The reference's test_collision.c (e77352c), the four subtests: the box +// validity, overlap and containment; the ray against a box in all its +// cases (through, from and to inside, wholly inside, missing, parallel +// within and outside the slab, a point inside and outside, pointing away, +// through a corner, along a face); a hull manifold a ten million metres +// out against the same one at the origin; and a hull's tight and fat +// world bounds there still holding its extent and margin. The reference +// asks the last two only of its double precision build; positions are +// double here, so they always hold. + +import std.string +import aephysics.math +import aephysics.basics +import aephysics.hull +import aephysics.manifold +import aephysics.shape + +extern calloc(count: int, size: int) -> ptr +extern exit(code: int) +extern free(p: ptr) + +const FLT_EPSILON = 0.00000011920928955078125 + +var failures = 0 +var checks = 0 + +ensure(name: string, ok: bool) { + checks = checks + 1 + if !ok { + println("collision: FAIL ${name}") + failures = failures + 1 + } +} + +small(name: string, value: float, tolerance: float) { + ensure("${name} (${value})", math.abs_float(value) < tolerance) +} + +box(lx: float, ly: float, lz: float, ux: float, uy: float, uz: float) -> AABB { + return math.aabb(math.vec3(lx, ly, lz), math.vec3(ux, uy, uz)) +} + +test_aabb() { + a = box(0.0 - 1.0, 0.0 - 1.0, 0.0 - 1.0, 0.0 - 2.0, 0.0 - 2.0, 0.0 - 2.0) + ensure("aabb: lower above upper is not valid", math.is_valid_aabb(a) == false) + // The reference's {1, 1} leaves z at 0, which keeps the box valid. + a.upper = math.vec3(1.0, 1.0, 0.0) + ensure("aabb: valid", math.is_valid_aabb(a)) + b = box(2.0, 2.0, 0.0, 4.0, 4.0, 0.0) + ensure("aabb: apart, no overlap", math.aabb_overlaps(a, b) == false) + ensure("aabb: apart, not contained", math.aabb_contains(a, b) == false) +} + +// One ray: whether it hits, and where it enters and leaves when it does. +ray(name: string, a: AABB, p1: Vec3, p2: Vec3, hit: bool, enter: float, leave: float, exact: bool) { + block = calloc(2, 8) + f = block as float[] + got = math.ray_cast_aabb(a, p1, p2, f) + ensure("ray ${name}: hit ${got}", got == hit) + if hit && got { + if exact { + ensure("ray ${name}: enters at ${f[0]}", f[0] == enter) + ensure("ray ${name}: leaves at ${f[1]}", f[1] == leave) + } else { + small("ray ${name}: enters", f[0] - enter, 0.001) + small("ray ${name}: leaves", f[1] - leave, 0.001) + } + } + free(block) +} + +test_ray_aabb() { + unit = box(0.0 - 1.0, 0.0 - 1.0, 0.0 - 1.0, 1.0, 1.0, 1.0) + cube = box(0.0, 0.0, 0.0, 1.0, 1.0, 1.0) + ray("through the centre", unit, math.vec3(0.0 - 2.0, 0.0, 0.0), math.vec3(2.0, 0.0, 0.0), true, 0.25, 0.75, false) + // From inside: entering at 0 exactly, leaving half way. + f = calloc(2, 8) + fr = f as float[] + got = math.ray_cast_aabb(unit, math.vec3_zero(), math.vec3(2.0, 0.0, 0.0), fr) + ensure("ray from inside: hit", got) + ensure("ray from inside: enters at 0", fr[0] == 0.0) + small("ray from inside: leaves half way", fr[1] - 0.5, 0.001) + // To inside: entering half way, leaving at 1 exactly. + got = math.ray_cast_aabb(unit, math.vec3(0.0 - 2.0, 0.0, 0.0), math.vec3_zero(), fr) + ensure("ray to inside: hit", got) + small("ray to inside: enters half way", fr[0] - 0.5, 0.001) + ensure("ray to inside: leaves at 1", fr[1] == 1.0) + free(f) + ray("wholly inside", box(0.0 - 2.0, 0.0 - 2.0, 0.0 - 2.0, 2.0, 2.0, 2.0), math.vec3(0.0 - 1.0, 0.0, 0.0), math.vec3(1.0, 0.0, 0.0), true, 0.0, 1.0, true) + ray("missing", cube, math.vec3(0.0 - 1.0, 2.0, 0.5), math.vec3(2.0, 2.0, 0.5), false, 0.0, 0.0, false) + ray("parallel to a face, outside", cube, math.vec3(0.0 - 1.0, 2.0, 0.5), math.vec3(2.0, 2.0, 0.5), false, 0.0, 0.0, false) + ray("parallel to a face, within", cube, math.vec3(0.0 - 1.0, 0.5, 0.5), math.vec3(2.0, 0.5, 0.5), true, 1.0 / 3.0, 2.0 / 3.0, false) + ray("a point inside", cube, math.vec3(0.5, 0.5, 0.5), math.vec3(0.5, 0.5, 0.5), true, 0.0, 0.0, true) + ray("a point outside", cube, math.vec3(2.0, 2.0, 2.0), math.vec3(2.0, 2.0, 2.0), false, 0.0, 0.0, false) + ray("pointing away", cube, math.vec3(0.0 - 1.0, 0.5, 0.5), math.vec3(0.0 - 2.0, 0.5, 0.5), false, 0.0, 0.0, false) + ray("through a corner", cube, math.vec3(0.0 - 1.0, 0.0 - 1.0, 0.0 - 1.0), math.vec3(2.0, 2.0, 2.0), true, 1.0 / 3.0, 2.0 / 3.0, false) + ray("along a face", cube, math.vec3(0.0 - 1.0, 0.0, 0.5), math.vec3(2.0, 0.0, 0.5), true, 1.0 / 3.0, 2.0 / 3.0, false) +} + +// Two unit cubes overlapping by a tenth along x: four points a tenth deep, +// and the same manifold a ten million metres out, the relative pose +// taken in double from the two world transforms. +test_large_world_manifold() { + a = hull.make_box_hull(0.5, 0.5, 0.5) + b = hull.make_box_hull(0.5, 0.5, 0.5) + sep = math.vec3(0.9, 0.0, 0.0) + origin_points = calloc(8, sizeof(LocalManifoldPoint)) + origin = manifold.local_manifold(origin_points) + cache = manifold.empty_sat_cache() + xf_a = math.transform_identity() + xf_b = Transform { p: sep, q: math.quat_identity() } + manifold.collide_hulls(&origin, 8, a, b, math.inv_mul_transforms(xf_a, xf_b), &cache) + ensure("large world manifold: four points at the origin", origin.point_count == 4) + i = 0 + while i < origin.point_count { + small("large world manifold: a tenth deep", manifold.manifold_point(&origin, i).separation + 0.1, 0.01) + i = i + 1 + } + base = math.vec3(10000000.0, 10000000.0, 10000000.0) + far_points = calloc(8, sizeof(LocalManifoldPoint)) + far = manifold.local_manifold(far_points) + far_cache = manifold.empty_sat_cache() + xf_a = Transform { p: base, q: math.quat_identity() } + xf_b = Transform { p: math.add(base, sep), q: math.quat_identity() } + manifold.collide_hulls(&far, 8, a, b, math.inv_mul_transforms(xf_a, xf_b), &far_cache) + ensure("large world manifold: as many points far out", far.point_count == origin.point_count) + small("large world manifold: the normal", math.length(math.sub(far.normal, origin.normal)), 0.0001) + i = 0 + while i < far.point_count { + p = manifold.manifold_point(&far, i) + q = manifold.manifold_point(&origin, i) + small("large world manifold: the separation", p.separation - q.separation, 0.0001) + small("large world manifold: the point", math.length(math.sub(p.point, q.point)), 0.0001) + i = i + 1 + } + free(origin_points) + free(far_points) + hull.destroy_hull(a) + hull.destroy_hull(b) +} + +// A unit cube's world bounds: 0.5 each way at the origin, and still +// holding the extent, and a margin smaller than a float's step there, a +// ten million metres out. +test_large_world_aabb() { + cube = hull.make_box_hull(0.5, 0.5, 0.5) + sh = shape.hull_shape(cube, 1.0) + at_origin = shape.compute_fat_shape_aabb(&sh, math.transform_identity(), 0.0) + small("large world aabb: lower x at the origin", at_origin.lower.x + 0.5, FLT_EPSILON) + small("large world aabb: lower y at the origin", at_origin.lower.y + 0.5, FLT_EPSILON) + small("large world aabb: lower z at the origin", at_origin.lower.z + 0.5, FLT_EPSILON) + small("large world aabb: upper x at the origin", at_origin.upper.x - 0.5, FLT_EPSILON) + small("large world aabb: upper y at the origin", at_origin.upper.y - 0.5, FLT_EPSILON) + small("large world aabb: upper z at the origin", at_origin.upper.z - 0.5, FLT_EPSILON) + d = 10000000.0 + far = Transform { p: math.vec3(d, d, d), q: math.quat_identity() } + tight = shape.compute_fat_shape_aabb(&sh, far, 0.0) + ensure("large world aabb: the tight box holds the extent", + tight.lower.x <= d - 0.5 && tight.lower.y <= d - 0.5 && tight.lower.z <= d - 0.5 && + tight.upper.x >= d + 0.5 && tight.upper.y >= d + 0.5 && tight.upper.z >= d + 0.5) + extra = 0.05 + fat = shape.compute_fat_shape_aabb(&sh, far, extra) + ensure("large world aabb: the fat box holds the margin", + fat.lower.x <= d - 0.5 - extra && fat.lower.y <= d - 0.5 - extra && fat.lower.z <= d - 0.5 - extra && + fat.upper.x >= d + 0.5 + extra && fat.upper.y >= d + 0.5 + extra && fat.upper.z >= d + 0.5 + extra) + hull.destroy_hull(cube) +} + +main() { + before = basics.alloc_count() + test_aabb() + test_ray_aabb() + test_large_world_manifold() + test_large_world_aabb() + // The manifold module's scratch, four blocks made on its first use and + // kept, is all that stays. + ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 4) + + println("collision: ${checks} checks") + if failures == 0 { + println("collision: all checks passed") + } else { + println("collision: ${failures} failure(s)") + exit(1) + } +} diff --git a/tests/test_dynamics.ae b/tests/test_dynamics.ae index e277178..f8db184 100644 --- a/tests/test_dynamics.ae +++ b/tests/test_dynamics.ae @@ -1218,6 +1218,36 @@ test_shape_filter() { // changed in place drops its contact and pairs under its new size and // kind; its friction and restitution, events and mass read and set; the // closest point on a shape and on a body to a target. +// test_shape.c's ShapeFlagsTest: the def carries all four event enables +// to the shape, and flipping one leaves the others as they were. +flags_are(id: ShapeId, sensor: bool, contact: bool, hit: bool, pre_solve: bool) -> bool { + return dynamics.shape_are_sensor_events_enabled(id) == sensor && dynamics.shape_are_contact_events_enabled(id) == contact && + dynamics.shape_are_hit_events_enabled(id) == hit && dynamics.shape_are_pre_solve_events_enabled(id) == pre_solve +} + +test_shape_flags() { + world_id = new_world() + bdef = dynamic_body_def() + body = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + sdef.density = 1.0 + sdef.enable_sensor_events = true + sdef.enable_contact_events = true + sdef.enable_hit_events = true + sdef.enable_pre_solve_events = true + id = dynamics.create_sphere_shape(body, &sdef, manifold.sphere(math.vec3_zero(), 0.5)) + ensure("shape flags: the def's four enables", flags_are(id, true, true, true, true)) + dynamics.shape_enable_sensor_events(id, false) + ensure("shape flags: sensor events off, the rest on", flags_are(id, false, true, true, true)) + dynamics.shape_enable_sensor_events(id, true) + dynamics.shape_enable_hit_events(id, false) + ensure("shape flags: sensor back, hit events off", flags_are(id, true, true, false, true)) + dynamics.shape_enable_contact_events(id, false) + dynamics.shape_enable_pre_solve_events(id, false) + ensure("shape flags: only sensor events left", flags_are(id, true, false, false, false)) + dynamics.destroy_world(world_id) +} + test_shape_api() { world_id = slab_world() w = dynamics.get_world(world_id.index1 - 1) @@ -1699,6 +1729,7 @@ main() { test_body_setters() test_shape_filter() test_shape_api() + test_shape_flags() test_sensors() hull.destroy_hull(g_cube as *HullData) hull.destroy_hull(g_slab as *HullData) diff --git a/tests/test_edge_cases.ae b/tests/test_edge_cases.ae index dd6ec92..714e7e9 100644 --- a/tests/test_edge_cases.ae +++ b/tests/test_edge_cases.ae @@ -1122,6 +1122,54 @@ slab_at(world_id: WorldId, x: float, h: ptr) -> BodyId { return body } +// test_joint.c's TestNonDynamicJointDisable: a weld between two dynamic +// bodies made static and kinematic, the kinematic one disabled and enabled +// (the joint survives), then made dynamic again (the joint wakes); and a +// revolute made directly between a static and a kinematic body, disabled +// and enabled, stays asleep (no dynamic body to wake it). +test_non_dynamic_joint_disable() { + fixtures_ready() + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + a = dynamics.create_body(world_id, &bdef) + bdef.position = math.vec3(2.0, 0.0, 0.0) + b = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(a, &sdef, g_box as *HullData) + dynamics.create_hull_shape(b, &sdef, g_box as *HullData) + wjdef = dynamics.default_weld_joint_def() + wjdef.base.body_id_a = a + wjdef.base.body_id_b = b + weld = dynamics.create_weld_joint(world_id, &wjdef) + dynamics.body_set_type(a, dynamics.BODY_STATIC) + dynamics.body_set_type(b, dynamics.BODY_KINEMATIC) + steps(world_id, 1) + dynamics.body_disable(b) + steps(world_id, 1) + dynamics.body_enable(b) + steps(world_id, 1) + ensure("non-dynamic joint: the weld survives a disable and enable", dynamics.joint_is_valid(weld) && valid_world(world_id)) + dynamics.body_set_type(b, dynamics.BODY_DYNAMIC) + steps(world_id, 1) + ensure("non-dynamic joint: dynamic again, the weld is awake", dynamics.joint_is_awake(weld)) + bdef.kind = dynamics.BODY_KINEMATIC + bdef.position = math.vec3(4.0, 0.0, 0.0) + c = dynamics.create_body(world_id, &bdef) + dynamics.create_hull_shape(c, &sdef, g_box as *HullData) + rjdef = dynamics.default_revolute_joint_def() + rjdef.base.body_id_a = a + rjdef.base.body_id_b = c + revolute = dynamics.create_revolute_joint(world_id, &rjdef) + dynamics.body_disable(c) + dynamics.body_enable(c) + steps(world_id, 1) + ensure("non-dynamic joint: static to kinematic, disabled and enabled, asleep", dynamics.joint_is_awake(revolute) == false && valid_world(world_id)) + dynamics.destroy_world(world_id) +} + test_encoded_sims() { fixtures_ready() slab = hull.make_box_hull(2.5, 0.5, 2.5) @@ -1249,6 +1297,7 @@ main() { test_kinematic_softness() test_rocking_hull() test_encoded_sims() + test_non_dynamic_joint_disable() println("edge_cases: ${checks} checks") if failures == 0 { println("edge_cases: all checks passed") diff --git a/tests/test_hash.ae b/tests/test_hash.ae new file mode 100644 index 0000000..f82ff1f --- /dev/null +++ b/tests/test_hash.ae @@ -0,0 +1,389 @@ +// The reference's test_hash.c (e77352c, #145) on this engine's content +// hash, basics.hash_bytes. The reference's is rapidhash; ours is its own, +// so these are the properties the reference holds its hash to, not its +// values: every input bit and the length reach the digest, sign bits and +// one-ULP steps separate blobs, families of real baked geometry (boxes, +// turned boxes, cylinders and cones, grids and waves, grid meshes and +// tori) never collide, the low bits spread, the same input bakes the same +// hash, and an empty blob takes the reserved value. A probe tells a true +// collision (two different blobs, one digest) from a duplicate (the same +// bytes baked twice), as the reference's does. The hull database's bucket +// test waits for the database (#147). + +import std.string +import aephysics.math +import aephysics.basics +import aephysics.hull +import aephysics.mesh +import aephysics.height_field + +extern calloc(count: int, size: int) -> ptr +extern exit(code: int) +extern free(p: ptr) +extern memcmp(a: ptr, b: ptr, n: int) -> int +extern memcpy(dst: ptr, src: ptr, n: int) -> ptr + +var failures = 0 +var checks = 0 + +ensure(name: string, ok: bool) { + checks = checks + 1 + if !ok { + println("hash: FAIL ${name}") + failures = failures + 1 + } +} + +// The digests seen, each with a copy of its blob when given one. +struct Probe { + map: LongMap + blobs: ptr // ptr[] of copies, by the order first seen + sizes: ptr // int[] + count: int + capacity: int + duplicates: int + collisions: int + saw_zero: bool +} + +probe_begin(capacity: int) -> Probe { + return Probe { map: basics.map_create(capacity), blobs: calloc(capacity, 8), sizes: calloc(capacity, 4), count: 0, + capacity: capacity, duplicates: 0, collisions: 0, saw_zero: false } +} + +probe_add(p: *Probe, hash: long, blob: ptr, size: int) { + if hash == (0 as long) { p.saw_zero = true } + blobs = p.blobs as ptr[] + sizes = p.sizes as int[] + seen = basics.map_get(&p.map, hash, 0 - 1) + if seen >= 0 { + if blob != null && sizes[seen] == size && memcmp(blobs[seen], blob, size) == 0 { + p.duplicates = p.duplicates + 1 + } else { + p.collisions = p.collisions + 1 + } + return + } + if p.count == p.capacity { return } + copy = null + if blob != null && size > 0 { + copy = calloc(size, 1) + memcpy(copy, blob, size) + } + blobs[p.count] = copy + sizes[p.count] = size + basics.map_set(&p.map, hash, p.count) + p.count = p.count + 1 +} + +// The family's verdict: no zero digest, no collision, and as many distinct +// digests as expected (or any, for -1). +probe_end(p: *Probe, name: string, expected: int) { + ensure("${name}: never zero", p.saw_zero == false) + ensure("${name}: no collision (${p.collisions})", p.collisions == 0) + if expected >= 0 { ensure("${name}: every blob its own digest (${p.count} of ${expected})", p.count == expected) } + else { ensure("${name}: digests (${p.count}, ${p.duplicates} duplicates)", p.count > 0) } + blobs = p.blobs as ptr[] + i = 0 + while i < p.count { + if blobs[i] != null { free(blobs[i]) } + i = i + 1 + } + free(p.blobs) + free(p.sizes) + basics.map_destroy(&p.map) +} + +hash_of(block: ptr, size: int) -> long { return basics.hash_bytes(block, size) } + +// HashWordFamily: two bytes, each the top of its word, through all 65,536 pairs. +test_word_family() { + blob = calloc(16, 1) + b = blob as byte[] + i = 0 + while i < 16 { + b[i] = (0x5A + i) as byte + i = i + 1 + } + p = probe_begin(256 * 256) + x = 0 + while x < 256 { + y = 0 + while y < 256 { + b[7] = x as byte + b[15] = y as byte + probe_add(&p, hash_of(blob, 16), null, 0) + y = y + 1 + } + x = x + 1 + } + probe_end(&p, "word family", 256 * 256) + free(blob) +} + +// HashBitSweep: a 32-byte blob and each of its 256 bits flipped alone. +test_bit_sweep() { + blob = calloc(32, 1) + b = blob as byte[] + i = 0 + while i < 32 { + b[i] = (0xA5 ^ (i * 17)) as byte + i = i + 1 + } + p = probe_begin(257) + probe_add(&p, hash_of(blob, 32), null, 0) + bit = 0 + while bit < 256 { + mask = 1 << (bit & 7) + b[bit >> 3] = ((b[bit >> 3] as int) ^ mask) as byte + probe_add(&p, hash_of(blob, 32), null, 0) + b[bit >> 3] = ((b[bit >> 3] as int) ^ mask) as byte + bit = bit + 1 + } + probe_end(&p, "bit sweep", 257) + free(blob) +} + +// HashZeroLengths: all-zero blobs of every length from 0 to 4095. +test_zero_lengths() { + zeros = calloc(4096, 1) + p = probe_begin(4096) + n = 0 + while n < 4096 { + probe_add(&p, hash_of(zeros, n), null, 0) + n = n + 1 + } + probe_end(&p, "zero lengths", 4096) + free(zeros) +} + +// HashFloatSigns: twelve floats under every combination of sign flips. +test_float_signs() { + values = calloc(12, 4) + v = values as f32[] + p = probe_begin(4096) + mask = 0 + while mask < 4096 { + i = 0 + while i < 12 { + magnitude = (1.0 + 0.25 * (i as float)) as f32 + if (mask & (1 << i)) != 0 { v[i] = (0.0 as f32) - magnitude } else { v[i] = magnitude } + i = i + 1 + } + probe_add(&p, hash_of(values, 48), null, 0) + mask = mask + 1 + } + probe_end(&p, "float signs", 4096) + free(values) +} + +// HashFloatUlp: sixty-four floats, each in turn one ULP up. +test_float_ulp() { + values = calloc(64, 4) + v = values as f32[] + bits = values as int[] + i = 0 + while i < 64 { + v[i] = (100.0 + (i as float)) as f32 + i = i + 1 + } + p = probe_begin(65) + probe_add(&p, hash_of(values, 256), null, 0) + i = 0 + while i < 64 { + bits[i] = bits[i] + 1 + probe_add(&p, hash_of(values, 256), null, 0) + bits[i] = bits[i] - 1 + i = i + 1 + } + probe_end(&p, "float ulp", 65) + free(values) +} + +// HashBoxHulls: 16^3 boxes of different extents. +test_box_hulls() { + p = probe_begin(16 * 16 * 16) + i = 0 + while i < 16 { + j = 0 + while j < 16 { + k = 0 + while k < 16 { + h = hull.make_box_hull(0.5 + 0.25 * (i as float), 0.5 + 0.25 * (j as float), 0.5 + 0.25 * (k as float)) + probe_add(&p, h.hash, h as ptr, h.byte_count) + hull.destroy_hull(h) + k = k + 1 + } + j = j + 1 + } + i = i + 1 + } + probe_end(&p, "box hulls", 16 * 16 * 16) +} + +// HashTransformedBoxHulls: one box, moved and turned 144 ways. +test_transformed_box_hulls() { + p = probe_begin(144) + i = 0 + while i < 12 { + j = 0 + while j < 12 { + axis = math.normalize(math.vec3(1.0, 0.5 + 0.1 * (j as float), 0.25)) + t = Transform { p: math.vec3(0.125 * (i as float), 0.0 - 0.25 * (j as float), 0.5 * ((i + j) as float)), + q: math.make_quat_from_axis_angle(axis, 0.05 * ((i * 12 + j) as float)) } + h = hull.make_transformed_box_hull(1.0, 2.0, 3.0, t) + probe_add(&p, h.hash, h as ptr, h.byte_count) + hull.destroy_hull(h) + j = j + 1 + } + i = i + 1 + } + probe_end(&p, "transformed box hulls", 144) +} + +// HashProceduralHulls: cylinders and cones over sides, radii and heights. +test_procedural_hulls() { + p = probe_begin(1024) + sides = 3 + while sides <= 18 { + r = 1 + while r <= 5 { + h = 1 + while h <= 4 { + cylinder = hull.create_cylinder(0.5 * (h as float), 0.25 * (r as float), 0.0, sides) + if cylinder != null { + probe_add(&p, cylinder.hash, cylinder as ptr, cylinder.byte_count) + hull.destroy_hull(cylinder) + } + if sides >= 4 { + cone = hull.create_cone(0.5 * (h as float), 0.25 * (r as float), 0.1 * (r as float), sides) + if cone != null { + probe_add(&p, cone.hash, cone as ptr, cone.byte_count) + hull.destroy_hull(cone) + } + } + h = h + 1 + } + r = r + 1 + } + sides = sides + 1 + } + probe_end(&p, "procedural hulls", 0 - 1) +} + +// HashHeightFields: grids and waves of nearby sizes. +test_height_fields() { + p = probe_begin(512) + scale = math.vec3_one() + rows = 2 + while rows <= 14 { + cols = 2 + while cols <= 14 { + grid = height_field.create_grid(rows, cols, scale, false) + probe_add(&p, grid.hash, grid as ptr, grid.byte_count) + height_field.destroy_height_field(grid) + wave = height_field.create_wave(rows, cols, scale, 0.25 * (rows as float), 0.125 * (cols as float), false) + probe_add(&p, wave.hash, wave as ptr, wave.byte_count) + height_field.destroy_height_field(wave) + cols = cols + 1 + } + rows = rows + 1 + } + probe_end(&p, "height fields", 0 - 1) +} + +// HashMeshes: grid meshes and tori. +test_meshes() { + p = probe_begin(256) + x = 2 + while x <= 10 { + z = 2 + while z <= 10 { + grid = mesh.create_grid_mesh(x, z, 1.0, 1, false) + probe_add(&p, grid.hash, grid as ptr, grid.byte_count) + mesh.destroy_mesh(grid) + z = z + 1 + } + x = x + 1 + } + radial = 3 + while radial <= 12 { + tubular = 3 + while tubular <= 12 { + torus = mesh.create_torus_mesh(radial, tubular, 1.0, 0.25) + probe_add(&p, torus.hash, torus as ptr, torus.byte_count) + mesh.destroy_mesh(torus) + tubular = tubular + 1 + } + radial = radial + 1 + } + probe_end(&p, "meshes", 0 - 1) +} + +// HashVoxelDispersion: 3,000 small boxes on a voxel grid; the low 13 bits +// of their digests land in more than 1,500 of the 8,192 slots. +test_voxel_dispersion() { + seen_block = calloc(8192, 1) + seen = seen_block as byte[] + distinct = 0 + cell = 0.25 + i = 0 + while i < 3000 { + offset = math.vec3(((i % 15) as float) * cell, (((i / 15) % 20) as float) * cell, ((i / 300) as float) * cell) + h = hull.make_offset_box_hull(0.5 * cell, 0.5 * cell, 0.5 * cell, offset) + low = (h.hash & (8191 as long)) as int + if (seen[low] as int) == 0 { + seen[low] = 1 as byte + distinct = distinct + 1 + } + hull.destroy_hull(h) + i = i + 1 + } + ensure("voxel dispersion: the low bits spread (${distinct} of 8192 slots for 3000 hulls)", distinct > 1500) + free(seen_block) +} + +// HashStability: the same input bakes the same hash and the same bytes; an +// empty blob is the reserved 1. +test_stability() { + box1 = hull.make_box_hull(1.0, 2.0, 3.0) + box2 = hull.make_box_hull(1.0, 2.0, 3.0) + ensure("stability: two equal boxes, one hash", box1.hash == box2.hash) + hull.destroy_hull(box1) + hull.destroy_hull(box2) + cylinder1 = hull.create_cylinder(1.0, 0.5, 0.0, 12) + cylinder2 = hull.create_cylinder(1.0, 0.5, 0.0, 12) + ensure("stability: two equal cylinders, one hash", cylinder1.hash == cylinder2.hash) + ensure("stability: and the same bytes", cylinder1.byte_count == cylinder2.byte_count && + memcmp(cylinder1 as ptr, cylinder2 as ptr, cylinder1.byte_count) == 0) + hull.destroy_hull(cylinder1) + hull.destroy_hull(cylinder2) + ensure("stability: an empty blob is the reserved value", basics.hash_bytes(null, 0) == (1 as long)) +} + +main() { + before = basics.alloc_count() + test_word_family() + test_bit_sweep() + test_zero_lengths() + test_float_signs() + test_float_ulp() + test_box_hulls() + test_transformed_box_hulls() + test_procedural_hulls() + test_height_fields() + test_meshes() + test_voxel_dispersion() + test_stability() + // The mesh module's scratch, three blocks made on its first build and + // kept, is all that stays. + ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 3) + + println("hash: ${checks} checks") + if failures == 0 { + println("hash: all checks passed") + } else { + println("hash: ${failures} failure(s)") + exit(1) + } +} diff --git a/tests/test_hull.ae b/tests/test_hull.ae index ae35fcc..c7d6958 100644 --- a/tests/test_hull.ae +++ b/tests/test_hull.ae @@ -298,6 +298,20 @@ test_cylinder() { ensure("rock: 10 vertices", rock.vertex_count == 10) hull.destroy_hull(rock) } + // PEEL's convex, the reference's b3CreateComplexHull(2): the same 32 + // points from the same generator, so the same hull, counts, mass and + // centre as the reference's own (printed by its library). + complex = hull.create_complex_hull(2.0) + ensure("complex: built", complex != null) + if complex != null { + ensure("complex: valid", hull.is_valid_hull(complex)) + ensure("complex: the reference's counts (${complex.vertex_count} ${complex.edge_count} ${complex.face_count})", + complex.vertex_count == 32 && complex.edge_count == 178 && complex.face_count == 59) + md = hull.compute_hull_mass(complex, 1.0) + small("complex: the reference's mass (${md.mass})", md.mass - 23.4043102, 0.00001) + small("complex: the reference's centre", math.length(math.sub(md.center, math.vec3(0.0712011755, 0.0 - 0.0683457255, 0.0723172426))), 0.000001) + hull.destroy_hull(complex) + } } // XorShift32 + Shoemake's unit-vector recipe, the reference's. diff --git a/tests/test_math.ae b/tests/test_math.ae index 67bf474..dae24d8 100644 --- a/tests/test_math.ae +++ b/tests/test_math.ae @@ -226,6 +226,53 @@ main() { ensure_small("invert_t is the transposed inverse", mt.cx.y - tt.cx.y, 100.0 * math.EPSILON) singular = math.matrix3(math.vec3(1.0, 2.0, 3.0), math.vec3(2.0, 4.0, 6.0), math.vec3(0.0, 1.0, 0.0)) ensure("a singular matrix inverts to nothing", math.det(math.invert_matrix(singular)) == 0.0) + // test_math.c's MathTest, the rest: every component of m inv(m), the + // vector through m and back, inv(m) v against solve3. + ensure_small("m * inv(m), cx.y", id.cx.y, math.EPSILON) + ensure_small("m * inv(m), cx.z", id.cx.z, math.EPSILON) + ensure_small("m * inv(m), cy.y", id.cy.y - 1.0, math.EPSILON) + ensure_small("m * inv(m), cy.z", id.cy.z, math.EPSILON) + ensure_small("m * inv(m), cz.x", id.cz.x, math.EPSILON) + ensure_small("m * inv(m), cz.y", id.cz.y, math.EPSILON) + v3 = math.vec3(1.0, 0.0 - 2.0, 3.0) + round_trip = math.mul_mv(mi, math.mul_mv(m, v3)) + ensure_small("inv(m) m v is v", math.length(math.sub(round_trip, v3)), math.EPSILON) + ensure_small("inv(m) v is solve3(m, v)", math.length(math.sub(math.mul_mv(mi, v3), math.solve3(m, v3))), math.EPSILON) + // The 2x2: m inv(m), the vector there and back, inv(m) v against solve2. + m2 = math.matrix2(math.vec2(3.0, 1.0), math.vec2(0.0 - 1.0, 3.0)) + mi2 = math.invert2(m2) + c0 = math.mul_mv2(m2, mi2.cx) + c1 = math.mul_mv2(m2, mi2.cy) + ensure_small("2x2: m * inv(m), cx.x", c0.x - 1.0, math.EPSILON) + ensure_small("2x2: m * inv(m), cx.y", c0.y, math.EPSILON) + ensure_small("2x2: m * inv(m), cy.x", c1.x, math.EPSILON) + ensure_small("2x2: m * inv(m), cy.y", c1.y - 1.0, math.EPSILON) + v2 = math.vec2(1.0, 0.0 - 2.0) + back2 = math.mul_mv2(mi2, math.mul_mv2(m2, v2)) + ensure_small("2x2: inv(m) m v is v, x", back2.x - v2.x, math.EPSILON) + ensure_small("2x2: inv(m) m v is v, y", back2.y - v2.y, math.EPSILON) + w2 = math.mul_mv2(mi2, v2) + u2 = math.solve2(m2, v2) + ensure_small("2x2: inv(m) v is solve2, x", w2.x - u2.x, math.EPSILON) + ensure_small("2x2: inv(m) v is solve2, y", w2.y - u2.y, math.EPSILON) + w2 = math.mul_mv2(m2, u2) + ensure_small("2x2: m solve2(m, v) is v, x", w2.x - v2.x, 10.0 * math.EPSILON) + ensure_small("2x2: m solve2(m, v) is v, y", w2.y - v2.y, 10.0 * math.EPSILON) + ensure("2x2: a singular matrix inverts to zero", math.det2(math.invert2(math.matrix2(math.vec2(1.0, 2.0), math.vec2(2.0, 4.0)))) == 0.0) + // The normalised lerp from the identity to a quarter turn about z: the + // twist follows alpha to within a degree. + q_end = math.make_quat_from_axis_angle(math.vec3_axis_z(), 0.5 * math.PI) + i = 0 + while i <= 100 { + alpha = (i as float) / 100.0 + twist_now = math.get_twist_angle(math.nlerp(math.quat_identity(), q_end, alpha)) + ensure_small("nlerp: the twist at ${alpha}", alpha * 0.5 * math.PI - twist_now, math.DEG_TO_RAD) + i = i + 1 + } + // The arbitrary perpendicular of the reference's unit normal. + normal = math.vec3(0.504055440, 0.621548057, 0.599671543) + ensure_small("arbitrary_perp is perpendicular", math.dot(normal, math.arbitrary_perp(normal)), 2.0 * math.EPSILON) + ensure_small("arbitrary_perp is unit", math.length(math.arbitrary_perp(normal)) - 1.0, math.EPSILON) // A rotation as a matrix and back. q = math.normalize_quat(math.quat(0.3, 0.0 - 0.4, 0.5, 0.7)) diff --git a/tests/test_physics_world.ae b/tests/test_physics_world.ae index 7baf7a3..2d788cc 100644 --- a/tests/test_physics_world.ae +++ b/tests/test_physics_world.ae @@ -11,10 +11,12 @@ import std.string import aephysics.math import aephysics.basics +import aephysics.dynamic_tree import aephysics.hull import aephysics.manifold import aephysics.mesh import aephysics.compound +import aephysics.material import aephysics.shape import aephysics.distance import aephysics.broad_phase @@ -282,6 +284,328 @@ test_rebuilt_static_pairs() { hull.destroy_hull(slab) } +// test_world.c's EmptyWorld: an empty world steps 60 times and is gone +// once destroyed. +test_empty_world() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + ensure("empty world: valid", dynamics.world_is_valid(world_id)) + i = 0 + while i < 60 { + physics_world.step(world_id, 1.0 / 60.0, 1) + i = i + 1 + } + dynamics.destroy_world(world_id) + ensure("empty world: gone", dynamics.world_is_valid(world_id) == false) +} + +// DestroyAllBodiesWorld: ten bodies made one a step, then destroyed one a +// step, stepping all along; none left by the counters. +test_destroy_all_bodies_world() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + ids_block = calloc(10, sizeof(BodyId)) + ids = ids_block as BodyId[] + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + cube = hull.make_cube_hull(0.5) + count = 0 + creating = true + i = 0 + while i < 2 * 10 + 10 { + if creating { + if count < 10 { + ids[count] = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(ids[count], &sdef, cube) + count = count + 1 + } else { + creating = false + } + } else if count > 0 { + dynamics.destroy_body(ids[count - 1]) + count = count - 1 + } + physics_world.step(world_id, 1.0 / 60.0, 3) + i = i + 1 + } + ensure("destroy all bodies: none left (${physics_world.get_counters(world_id).body_count})", physics_world.get_counters(world_id).body_count == 0) + dynamics.destroy_world(world_id) + ensure("destroy all bodies: the world gone", dynamics.world_is_valid(world_id) == false) + hull.destroy_hull(cube) + free(ids_block) +} + +// TestWorldRecycle: half the worlds, each with a body and ten steps, made +// and destroyed a hundred times over. +test_world_recycle() { + count = dynamics.MAX_WORLDS / 2 + ids_block = calloc(count, sizeof(WorldId)) + ids = ids_block as WorldId[] + all_valid = true + all_gone = true + round = 0 + while round < 100 { + wdef = dynamics.default_world_def() + j = 0 + while j < count { + ids[j] = dynamics.create_world(&wdef) + if dynamics.world_is_valid(ids[j]) == false { all_valid = false } + bdef = dynamics.default_body_def() + dynamics.create_body(ids[j], &bdef) + j = j + 1 + } + j = 0 + while j < count { + k = 0 + while k < 10 { + physics_world.step(ids[j], 1.0 / 60.0, 1) + k = k + 1 + } + j = j + 1 + } + j = count - 1 + while j >= 0 { + dynamics.destroy_world(ids[j]) + if dynamics.world_is_valid(ids[j]) { all_gone = false } + j = j - 1 + } + round = round + 1 + } + ensure("world recycle: every world valid when made", all_valid) + ensure("world recycle: every world gone when destroyed", all_gone) + free(ids_block) +} + +var g_filter_context_null = true + +filter_all(a: ShapeId, b: ShapeId, context: ptr) -> bool { + if context != null { g_filter_context_null = false } + return true +} + +pre_solve_none(a: ShapeId, b: ShapeId, point: Vec3, normal: Vec3, context: ptr) -> bool { + if context != null { g_filter_context_null = false } + return false +} + +// TestWorldCoverage: every world setting set and read back, the callbacks +// set, an explosion over an empty world, and a step of a whole second. +test_world_coverage() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + physics_world.enable_sleeping(world_id, true) + physics_world.enable_sleeping(world_id, false) + ensure("coverage: sleeping off", physics_world.is_sleeping_enabled(world_id) == false) + physics_world.enable_continuous(world_id, false) + physics_world.enable_continuous(world_id, true) + ensure("coverage: continuous on", physics_world.is_continuous_enabled(world_id)) + physics_world.set_restitution_threshold(world_id, 0.0) + physics_world.set_restitution_threshold(world_id, 2.0) + ensure("coverage: restitution threshold", physics_world.get_restitution_threshold(world_id) == 2.0) + physics_world.set_hit_event_threshold(world_id, 0.0) + physics_world.set_hit_event_threshold(world_id, 100.0) + ensure("coverage: hit event threshold", physics_world.get_hit_event_threshold(world_id) == 100.0) + physics_world.set_custom_filter_callback(world_id, filter_all, null) + physics_world.set_pre_solve_callback(world_id, pre_solve_none, null) + g = math.vec3(1.0, 2.0, 0.0) + physics_world.set_gravity(world_id, g) + v = physics_world.get_gravity(world_id) + ensure("coverage: gravity", v.x == g.x && v.y == g.y) + edef = physics_world.default_explosion_def() + physics_world.explode(world_id, &edef) + physics_world.set_contact_tuning(world_id, 10.0, 2.0, 4.0) + physics_world.set_maximum_linear_speed(world_id, 10.0) + ensure("coverage: maximum linear speed", physics_world.get_maximum_linear_speed(world_id) == 10.0) + physics_world.enable_warm_starting(world_id, true) + ensure("coverage: warm starting on", physics_world.is_warm_starting_enabled(world_id)) + ensure("coverage: nothing awake", physics_world.get_awake_body_count(world_id) == 0) + marker = calloc(1, 8) + physics_world.set_user_data(world_id, marker) + ensure("coverage: user data", physics_world.get_user_data(world_id) == marker) + physics_world.step(world_id, 1.0, 1) + ensure("coverage: the callbacks saw no context", g_filter_context_null) + dynamics.destroy_world(world_id) + free(marker) +} + +// SetBulletDriftTest: the bullet flag set or cleared after creation holds +// through set_motion_locks, which syncs the body's flags to its sim. +test_set_bullet_drift() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.is_bullet = false + a = dynamics.create_body(world_id, &bdef) + ensure("bullet drift: made plain", dynamics.body_is_bullet(a) == false) + dynamics.body_set_bullet(a, true) + ensure("bullet drift: set", dynamics.body_is_bullet(a)) + locks = MotionLocks { linear_x: true, linear_y: false, linear_z: false, angular_x: false, angular_y: false, angular_z: false } + dynamics.body_set_motion_locks(a, locks) + ensure("bullet drift: still a bullet after the motion locks", dynamics.body_is_bullet(a)) + bdef.is_bullet = true + b = dynamics.create_body(world_id, &bdef) + ensure("bullet drift: made a bullet", dynamics.body_is_bullet(b)) + dynamics.body_set_bullet(b, false) + ensure("bullet drift: cleared", dynamics.body_is_bullet(b) == false) + dynamics.body_set_motion_locks(b, locks) + ensure("bullet drift: still plain after the motion locks", dynamics.body_is_bullet(b) == false) + dynamics.destroy_world(world_id) +} + +// EnableSleepFlagSyncTest and EnableContactRecyclingTest: flags made off +// in the def, turned on, read back, and a step taken. +test_def_flags() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.enable_sleep = false + sleeper = dynamics.create_body(world_id, &bdef) + ensure("sleep flag: off from the def", dynamics.body_is_sleep_enabled(sleeper) == false) + dynamics.body_enable_sleep(sleeper, true) + ensure("sleep flag: on", dynamics.body_is_sleep_enabled(sleeper)) + bdef.enable_sleep = true + recycler = dynamics.create_body(world_id, &bdef) + ensure("recycling: on by default", dynamics.body_is_contact_recycling_enabled(recycler)) + dynamics.body_enable_contact_recycling(recycler, false) + ensure("recycling: off", dynamics.body_is_contact_recycling_enabled(recycler) == false) + dynamics.body_enable_contact_recycling(recycler, true) + ensure("recycling: on again", dynamics.body_is_contact_recycling_enabled(recycler)) + bdef.enable_contact_recycling = false + opted_out = dynamics.create_body(world_id, &bdef) + ensure("recycling: off from the def", dynamics.body_is_contact_recycling_enabled(opted_out) == false) + physics_world.step(world_id, 1.0 / 60.0, 4) + ensure("def flags: valid after a step", dynamics.validate_solver_sets(dynamics.get_world(world_id.index1 - 1))) + dynamics.destroy_world(world_id) +} + +// TestCompoundShapeCount: the world's compound count back to zero both +// when the shape is destroyed and when its body takes it away. +test_compound_shape_count() { + wdef = dynamics.default_world_def() + world_id = dynamics.create_world(&wdef) + w = dynamics.get_world(world_id.index1 - 1) + ensure("compound count: none at first", w.compound_shape_count == 0) + box = hull.make_box_hull(1.0, 1.0, 1.0) + hulls_block = calloc(1, sizeof(CompoundHullDef)) + hulls = hulls_block as CompoundHullDef[] + hulls[0] = compound.compound_hull_def(box, math.transform_identity(), material.default_surface_material()) + cdef = compound.compound_def() + cdef.hulls = hulls_block + cdef.hull_count = 1 + c = compound.create_compound(&cdef) + ensure("compound count: the compound made", c != null) + bdef = dynamics.default_body_def() + sdef = dynamics.default_shape_def() + body = dynamics.create_body(world_id, &bdef) + shape_id = dynamics.create_compound_shape(body, &sdef, c) + ensure("compound count: one", w.compound_shape_count == 1) + dynamics.destroy_shape(shape_id, true) + ensure("compound count: none after the shape is destroyed", w.compound_shape_count == 0) + dynamics.destroy_body(body) + body = dynamics.create_body(world_id, &bdef) + dynamics.create_compound_shape(body, &sdef, c) + ensure("compound count: one again", w.compound_shape_count == 1) + dynamics.destroy_body(body) + ensure("compound count: none after its body is destroyed", w.compound_shape_count == 0) + dynamics.destroy_world(world_id) + compound.destroy_compound(c) + hull.destroy_hull(box) + free(hulls_block) +} + +moved_node_count(t: *DynamicTree) -> int { + nodes = t.nodes as DynamicTreeNode[] + count = 0 + i = 0 + while i < t.node_end { + flag = nodes[i].flag_index + if dynamic_tree.is_empty_node(flag) == false && dynamic_tree.is_node_moved(flag) { count = count + 1 } + i = i + 1 + } + return count +} + +// TestEnlargedProxyDestroyed: a proxy enlarged in one step whose body is +// destroyed before the next leaves no node marked moved, and the next +// step leaves the tree in depth-first order (the reference's issue 149). +test_enlarged_proxy_destroyed() { + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + world_id = dynamics.create_world(&wdef) + w = dynamics.get_world(world_id.index1 - 1) + trees = w.broad_phase.trees as DynamicTree[] + tree = &trees[dynamics.BODY_DYNAMIC] + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + sdef = dynamics.default_shape_def() + ball = manifold.sphere(math.vec3_zero(), 0.25) + // Resting bodies keep the tree deep enough that the mover has an + // ancestor that outlives it. + i = 0 + while i < 8 { + bdef.position = math.vec3(2.0 * (i as float), 0.0, 0.0) + resting = dynamics.create_body(world_id, &bdef) + dynamics.create_sphere_shape(resting, &sdef, ball) + i = i + 1 + } + // This one outruns its margin every step. + bdef.position = math.vec3(7.0, 1.0, 0.0) + bdef.linear_velocity = math.vec3(6.0, 0.0, 0.0) + mover = dynamics.create_body(world_id, &bdef) + dynamics.create_sphere_shape(mover, &sdef, ball) + physics_world.step(world_id, 1.0 / 60.0, 4) + ensure("enlarged proxy: the step marked nodes moved (${moved_node_count(tree)})", moved_node_count(tree) > 0) + dynamics.destroy_body(mover) + ensure("enlarged proxy: the tree valid with nothing marked after the destroy", dynamic_tree.tree_validate(tree) && moved_node_count(tree) == 0) + physics_world.step(world_id, 1.0 / 60.0, 4) + ensure("enlarged proxy: nothing marked after the next step", moved_node_count(tree) == 0) + ensure("enlarged proxy: depth-first order after the next step", tree.dfs_ordered) + dynamics.destroy_world(world_id) +} + +// test_broad_phase.c's StaticRebuildKeepsMarks: the broad phase keeps a +// static proxy's pending pair mark in the static tree, so a full rebuild +// between a static change and the next step must not wipe it. Both ways a +// static proxy is marked: a static body teleported under a resting sphere, +// and a shape made there with invoke_contact_creation. +static_rebuild_keeps_marks(use_invoke: bool) { + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.position = math.vec3(0.0, 1.0, 0.0) + sphere_id = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_sphere_shape(sphere_id, &sdef, manifold.sphere(math.vec3_zero(), 0.5)) + bdef.kind = dynamics.BODY_STATIC + bdef.position = math.vec3(100.0, 0.0, 0.0) + ground = dynamics.create_body(world_id, &bdef) + slab = hull.make_box_hull(2.0, 0.5, 2.0) + if use_invoke == false { dynamics.create_hull_shape(ground, &sdef, slab) } + // Settled, so the resting sphere carries no mark of its own. + i = 0 + while i < 3 { + physics_world.step(world_id, 1.0 / 60.0, 4) + i = i + 1 + } + dynamics.body_set_transform(ground, math.vec3_zero(), math.quat_identity()) + if use_invoke { + idef = dynamics.default_shape_def() + idef.invoke_contact_creation = true + dynamics.create_hull_shape(ground, &idef, slab) + } + physics_world.rebuild_static_tree(world_id) + physics_world.step(world_id, 1.0 / 60.0, 4) + ensure("static rebuild keeps the marks (invoke ${use_invoke}): the sphere's contact (${dynamics.body_get_contact_capacity(sphere_id)})", + dynamics.body_get_contact_capacity(sphere_id) == 1) + dynamics.destroy_world(world_id) + hull.destroy_hull(slab) +} + // A ball of the given restitution dropped from 2 m onto a slab under the // world's restitution settings: the highest it rises after its first bounce. bounce_peak(restitution: float, iterations: int) -> float { @@ -1024,6 +1348,16 @@ main() { test_mesh_scale() test_fell_asleep() test_rebuilt_static_pairs() + static_rebuild_keeps_marks(false) + static_rebuild_keeps_marks(true) + test_empty_world() + test_destroy_all_bodies_world() + test_world_recycle() + test_world_coverage() + test_set_bullet_drift() + test_def_flags() + test_compound_shape_count() + test_enlarged_proxy_destroyed() test_restitution() test_joint_springs_and_locks() test_parallel_bookkeeping()