diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fd9a7f2..a937c32 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,13 +15,13 @@ jobs: - name: Checkout aephysics uses: actions/checkout@v4 - # Pin: 0.797.0, the latest release (f32x8 arrived in 0.788.0, aether#2428, + # Pin: 0.798.0, the latest release (f32x8 arrived in 0.788.0, aether#2428, # which the wide contact solver is written in). A reproducible bootstrap # version: the floor this code needs, verified on Linux by this job. - name: Install the released Aether toolchain and aeb run: | curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.797.0 AEB_REF=v0.325 sh + | AE_PIN=0.798.0 AEB_REF=v0.325 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run the tests with ae @@ -54,7 +54,7 @@ jobs: - name: Install the released Aether toolchain and aeb run: | curl -fsSL https://raw.githubusercontent.com/aether-lang-dev/aeb/main/get.sh \ - | AE_PIN=0.797.0 AEB_REF=v0.325 sh + | AE_PIN=0.798.0 AEB_REF=v0.325 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run the tests with ae diff --git a/README.md b/README.md index e289593..8cd2844 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ so a test written against the reference reads the same here. | `aephysics.contact_solver_wide` | a colour's convex contacts eight at a time: the constraint of eight contacts as one single-precision record in lanes (`f32x8`, std.lanes -- the reference's SIMD path; one AVX2 register with `AETHER_AEPHYSICS_CFLAGS=-mavx2`, two four-lane halves without), the same prepare, warm start, solve, restitution and store, the bodies gathered into lanes and scattered back | `test_contact_solver_wide.ae` (one scene two ways -- the stack, the bounce with hit events, rolling resistance, a conveyor, a slide -- the lanes within a millimetre of the scalar solve, and the same lanes twice bit for bit) | [the falling grid: scalar 241 ms, lanes 142, the reference 155](bench/RESULTS.md#solver) | | `aephysics.joint_solver` | the seven joints solved: each kind's prepare (frames relative to the centres of mass, effective masses, spring softness), warm start and solve (rigid or soft constraints, speculative limits, springs, motors), the kinds' accessors (limits, springs, motors, current angles and translations, forces and torques), joint.c's dispatch with the constraint hertz clamp; beyond the reference, a spherical joint's cone may be centred off frame A's z and may be an ellipse of two half-angles (the circle stays the default, bit for bit) | `test_joint_solver.ae` (181 checks: test_joint.c's accessors on every kind, each kind solved by hand) | [ours alone](bench/RESULTS.md#joint_solver) | | `aephysics.solver` | the Soft Step over the workers, the reference's stages: the work in blocks (the bodies', each colour's joints, wide and scalar constraints) that workers claim by compare-and-swap, the stages published as sync bits by worker 0 -- the constraints prepared, per sub-step the velocities integrated (gravity, damping, the gyroscopic torque), warm start, solve, positions, relax, colour by colour with the overflow first on worker 0; restitution, the impulses stored; the bodies finalised in parallel (sleep velocities, move events, fast bodies swept for the time of impact, bounds and proxies), the joint and hit events, the trees refit, bullets, the sensors' hits, islands put to sleep | `test_solver.ae` (45 checks: free fall against the closed form, resting and sleeping, a stack, a bounce and its hit event, a joint event, a pendulum, a fast sphere stopped by a thin wall, a bullet, a sensor swept, two worlds bit for bit alike) | [1.2-1.3x the reference's step, stage by stage](bench/RESULTS.md#stage-by-stage) | -| `aephysics.physics_world` | the world's face: the step (pairs, narrow phase, solve, sensors, events), the events read back, the settings and counters (the reference's whole `b3Counters`: byte count, tasks, manifold counts, time of impact iterations), the maximum capacity kept at each step's start, the memory part by part to the host's log, the world counts, version and CPU queries, queries over every shape (overlap of a box or a proxy, the mover's planes, ray, shape and mover casts, the closest ray hit) and against one body at a transform of the caller's, explosions | `test_physics_world.ae` (259 checks: test_world.c whole, among it the hull database (equal hulls one world-owned copy, a caller's hull freed at once, transformed hulls baked once, every holder's release), empty and recycled worlds, every setting, the worker count set between steps, contact, hit, move and sensor events, a compound child's hit events and materials, the overflow colour pile, the explosion near and far, the bullet and sleep flags, the compound count, an enlarged proxy destroyed; test_body_query.c whole: casts, overlaps of hull proxies and turned compounds, mover planes, and the mover's time of impact against 2,000 random sweeps; test_broad_phase.c's static rebuild and its stress test, a swarm through every proxy change with the pair set checked by brute force every step; test_id.c; the world queries; a wave pile stepping alike twice) | [the reference's own benchmark scenes with the same checksums: 1.2x its AVX2 build on the large pyramid, 1.15x on the many pyramids, 1.05x on the joint grid, ours at eight lanes in AVX2 with float records (#121)](bench/RESULTS.md#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121); [the rest of its suite, nine scenes and the SAT runs, at 1.04-1.21x](bench/RESULTS.md#suite-the-references-benchmark-suite) | +| `aephysics.physics_world` | the world's face: the step (pairs, narrow phase, solve, sensors, events), the events read back, the settings and counters (the reference's whole `b3Counters`: byte count, tasks, manifold counts, time of impact iterations), the maximum capacity kept at each step's start, kinematic speculation (off by default, #167: a fast kinematic body's contacts reach out over its travel, so what it strikes is not found inside it), the memory part by part to the host's log, the world counts, version and CPU queries, queries over every shape (overlap of a box or a proxy, the mover's planes, ray, shape and mover casts, the closest ray hit) and against one body at a transform of the caller's, explosions | `test_physics_world.ae` (259 checks: test_world.c whole, among it the hull database (equal hulls one world-owned copy, a caller's hull freed at once, transformed hulls baked once, every holder's release), empty and recycled worlds, every setting, the worker count set between steps, contact, hit, move and sensor events, a compound child's hit events and materials, the overflow colour pile, the explosion near and far, the bullet and sleep flags, the compound count, an enlarged proxy destroyed; test_body_query.c whole: casts, overlaps of hull proxies and turned compounds, mover planes, and the mover's time of impact against 2,000 random sweeps; test_broad_phase.c's static rebuild and its stress test, a swarm through every proxy change with the pair set checked by brute force every step; test_id.c; the world queries; a wave pile stepping alike twice) | [the reference's own benchmark scenes with the same checksums: 1.2x its AVX2 build on the large pyramid, 1.15x on the many pyramids, 1.05x on the joint grid, ours at eight lanes in AVX2 with float records (#121)](bench/RESULTS.md#the-collide-pass-as-the-reference-keeps-it-2026-10-09-121); [the rest of its suite, nine scenes and the SAT runs, at 1.04-1.21x](bench/RESULTS.md#suite-the-references-benchmark-suite) | | `aephysics.debug_draw` | what a host draws a world with (the reference's b3DebugDraw): callbacks for a user shape, segments, transforms, points, spheres, capsules, bounds, boxes and strings, the options, the reference's 145 named colours, material presets and graph colours; `physics_world.world_draw` walks the shapes in the drawing bounds (each coloured by its body's state as the reference picks it, or by its material's own colour) and draws what the options ask of their bodies (names, mass, sleep, each joint kind once, contacts by state or graph colour with normals, forces or features, islands); the world def's debug shape callbacks make the host's drawable for a shape when it is first drawn and give it back when the shape goes, its geometry changes (the reference keeps the stale one) or the world goes (the reference leaves those to the host), and an in-place restore keeps the drawables of the shapes still there; `recording.player_draw_frame_queries` draws a replayed frame's queries, the selected one labelled with its caller | done, `test_debug_draw.ae` (41 checks: every shape once with its colour, a drawable each made once and kept, every option counted against the world (a point per manifold point, a weld's two boxes, a label per joint), the drawing bounds and the mask culling, the drawables' lives through destroy, geometry change, restore and the world's end, a player's world and its queries drawn, the labels' number formats) | | `aephysics.snapshot` | a world as bytes and back (the reference's world_snapshot.c): `save_world` and `load_world` keep everything a step reads (the settings, the id pools, the solver sets, bodies, shapes and their geometry once each, contacts with their manifolds and mesh caches, joints, sensors, islands, the trees and pair set, the graph's colours), so a loaded world steps on as the one it was taken from, bit for bit; `restore_world` loads one into an existing world in place, its id, workers and callbacks kept (what a replay rewinds with); a snapshot of another layout or any damage to its bytes loads no world | done, `test_snapshot.ae` (63 checks: a world of every kind of thing loaded at four moments and stepped 150 steps beside its original, the same hash and events every step; a loaded world saves to the same bytes; one worker's world loaded with four steps alike; a restore in place stepping alike and saving to the same bytes, a refused restore leaving the world empty and whole; every truncation and corruption refused, nothing leaked) | | `aephysics.recording` | recording and replay (the reference's recording.c and recording_replay.c): a world records every call that changes it, 160 kinds, field by field, a create's record with the id it made, a state hash after every step and the geometry the calls name once each, and every world query with each visit and the caller's answer (a caller named by the query filter's id and name gets a key a viewer follows it by); a player replays it into a world of its own, restored in place so its id stays, checking every step's hash and every created id, asking every recorded query again with the recorded answers and comparing each visit, with keyframes under a memory budget for seeking, bodies in the order they were made, the queries of the frame last played with their hits, a pause before a step that makes bodies, and the recording's info; records it does not know are skipped, as a newer minor version may add some | `test_recording.ae` (152 checks: the snapshot test's scene recorded from an empty world and from mid stream with the host changing it between steps, played on one worker and several, every frame's hash as recorded; every one of the 160 calls recorded and replayed (the reference's AllOps); geometry kept once from two copies; seeking back and forth with and without keyframes and under a tight budget; the reference's QueryReplay and TaggedQuery, every query kind with answers that stop and clip them, a changed hit caught at its frame; the reference's NameRoundTrip, RollbackNames, GeometryHashCollision and ReservedHeaderBytes; a file round trip; every truncation, a skipped call, a changed id, a short record and a changed hash each caught, nothing leaked); `test_threads.ae` (four threads querying a recording world, every record whole and replayed alike) | diff --git a/aephysics/dynamics/module.ae b/aephysics/dynamics/module.ae index 57eb1fb..1a76bec 100644 --- a/aephysics/dynamics/module.ae +++ b/aephysics/dynamics/module.ae @@ -94,7 +94,7 @@ exports ( intern_tag, clear_tags, intern_geometry, rec_commit_query, rec_aabb, rec_query_filter, rec_shape_proxy, rec_plane_result, rec_capsule_value, rec_float, rec_bool, rec_vec3, rec_quat, rec_transform, rec_id, rec_string, rec_raw, rec_geometry, finish_recording, recorded_geometry_at, OP_STATE_HASH, OP_BOUNDS, OP_END, OP_QUERY_TAG, OP_QUERY_OVERLAP_AABB, OP_QUERY_OVERLAP_SHAPE, OP_QUERY_CAST_RAY, - OP_QUERY_CAST_SHAPE, OP_QUERY_CAST_RAY_CLOSEST, OP_QUERY_CAST_MOVER, OP_QUERY_COLLIDE_MOVER, OP_CREATE_BODY, OP_DESTROY_BODY, OP_BODY_SET_TRANSFORM, OP_BODY_SET_LINEAR_VELOCITY, OP_BODY_SET_ANGULAR_VELOCITY, OP_BODY_SET_TARGET_TRANSFORM, OP_BODY_APPLY_FORCE, OP_BODY_APPLY_FORCE_TO_CENTER, OP_BODY_APPLY_TORQUE, OP_BODY_APPLY_LINEAR_IMPULSE, OP_BODY_APPLY_LINEAR_IMPULSE_TO_CENTER, OP_BODY_APPLY_ANGULAR_IMPULSE, OP_BODY_SET_BULLET, OP_BODY_SET_MASS_DATA, OP_BODY_APPLY_MASS_FROM_SHAPES, OP_BODY_SET_LINEAR_DAMPING, OP_BODY_SET_ANGULAR_DAMPING, OP_BODY_SET_GRAVITY_SCALE, OP_BODY_SET_AWAKE, OP_BODY_ENABLE_SLEEP, OP_BODY_SET_SLEEP_THRESHOLD, OP_BODY_SET_MOTION_LOCKS, OP_CREATE_SPHERE_SHAPE, OP_CREATE_CAPSULE_SHAPE, OP_CREATE_HULL_SHAPE, OP_CREATE_TRANSFORMED_HULL_SHAPE, OP_CREATE_MESH_SHAPE, OP_CREATE_HEIGHT_FIELD_SHAPE, OP_CREATE_COMPOUND_SHAPE, OP_DESTROY_SHAPE, OP_SHAPE_SET_DENSITY, OP_SHAPE_SET_FILTER, OP_SHAPE_SET_SURFACE_MATERIAL, OP_SHAPE_SET_SPHERE, OP_SHAPE_SET_CAPSULE, OP_SHAPE_SET_HULL, OP_SHAPE_SET_MESH, OP_SHAPE_SET_FRICTION, OP_SHAPE_SET_RESTITUTION, OP_SHAPE_SET_MESH_MATERIAL, OP_SHAPE_ENABLE_PRE_SOLVE_EVENTS, OP_BODY_ENABLE_HIT_EVENTS, OP_BODY_ALLOW_FAST_ROTATION, OP_BODY_ENABLE_CONTACT_RECYCLING, OP_BODY_SET_SAFETY_FACTOR, OP_SHAPE_APPLY_WIND, OP_BODY_SET_NAME, OP_SHAPE_SET_NAME, OP_CREATE_DISTANCE_JOINT, OP_CREATE_MOTOR_JOINT, OP_CREATE_FILTER_JOINT, OP_CREATE_PARALLEL_JOINT, OP_CREATE_PRISMATIC_JOINT, OP_CREATE_REVOLUTE_JOINT, OP_CREATE_SPHERICAL_JOINT, OP_CREATE_WELD_JOINT, OP_CREATE_WHEEL_JOINT, OP_DESTROY_JOINT, OP_JOINT_SET_LOCAL_FRAME_A, OP_JOINT_SET_LOCAL_FRAME_B, OP_JOINT_SET_COLLIDE_CONNECTED, OP_JOINT_WAKE_BODIES, OP_JOINT_SET_CONSTRAINT_TUNING, OP_JOINT_SET_FORCE_THRESHOLD, OP_JOINT_SET_TORQUE_THRESHOLD, OP_BODY_SET_TYPE, OP_BODY_DISABLE, OP_BODY_ENABLE, OP_SHAPE_ENABLE_SENSOR_EVENTS, OP_SHAPE_ENABLE_CONTACT_EVENTS, OP_SHAPE_ENABLE_HIT_EVENTS, OP_STEP, OP_ENABLE_SLEEPING, OP_ENABLE_WARM_STARTING, OP_ENABLE_CONTINUOUS, OP_ENABLE_SPECULATIVE, OP_SET_RESTITUTION_THRESHOLD, OP_SET_RESTITUTION_ITERATIONS, OP_ENABLE_RESTITUTION_PROPAGATION, OP_ENABLE_KINEMATIC_STATIC_SOFTNESS, OP_SET_HIT_EVENT_THRESHOLD, OP_SET_CONTACT_TUNING, OP_SET_CONTACT_RECYCLE_DISTANCE, OP_SET_MAXIMUM_LINEAR_SPEED, OP_SET_GRAVITY, OP_REBUILD_STATIC_TREE, OP_EXPLODE, OP_DISTANCE_JOINT_SET_LENGTH, OP_DISTANCE_JOINT_ENABLE_LIMIT, OP_DISTANCE_JOINT_SET_LENGTH_RANGE, OP_DISTANCE_JOINT_ENABLE_SPRING, OP_DISTANCE_JOINT_SET_SPRING_FORCE_RANGE, OP_DISTANCE_JOINT_SET_SPRING_HERTZ, OP_DISTANCE_JOINT_SET_SPRING_DAMPING_RATIO, OP_DISTANCE_JOINT_ENABLE_MOTOR, OP_DISTANCE_JOINT_SET_MOTOR_SPEED, OP_DISTANCE_JOINT_SET_MAX_MOTOR_FORCE, OP_MOTOR_JOINT_SET_LINEAR_VELOCITY, OP_MOTOR_JOINT_SET_ANGULAR_VELOCITY, OP_MOTOR_JOINT_SET_MAX_VELOCITY_TORQUE, OP_MOTOR_JOINT_SET_MAX_VELOCITY_FORCE, OP_MOTOR_JOINT_SET_LINEAR_HERTZ, OP_MOTOR_JOINT_SET_LINEAR_DAMPING_RATIO, OP_MOTOR_JOINT_SET_ANGULAR_HERTZ, OP_MOTOR_JOINT_SET_ANGULAR_DAMPING_RATIO, OP_MOTOR_JOINT_SET_MAX_SPRING_FORCE, OP_MOTOR_JOINT_SET_MAX_SPRING_TORQUE, OP_PARALLEL_JOINT_SET_SPRING_HERTZ, OP_PARALLEL_JOINT_SET_SPRING_DAMPING_RATIO, OP_PARALLEL_JOINT_SET_MAX_TORQUE, OP_PRISMATIC_JOINT_ENABLE_SPRING, OP_PRISMATIC_JOINT_SET_SPRING_HERTZ, OP_PRISMATIC_JOINT_SET_SPRING_DAMPING_RATIO, OP_PRISMATIC_JOINT_SET_TARGET_TRANSLATION, OP_PRISMATIC_JOINT_ENABLE_LIMIT, OP_PRISMATIC_JOINT_SET_LIMITS, OP_PRISMATIC_JOINT_ENABLE_MOTOR, OP_PRISMATIC_JOINT_SET_MOTOR_SPEED, OP_PRISMATIC_JOINT_SET_MAX_MOTOR_FORCE, OP_REVOLUTE_JOINT_ENABLE_LIMIT, OP_REVOLUTE_JOINT_SET_LIMITS, OP_REVOLUTE_JOINT_ENABLE_SPRING, OP_REVOLUTE_JOINT_SET_TARGET_ANGLE, OP_REVOLUTE_JOINT_SET_SPRING_HERTZ, OP_REVOLUTE_JOINT_SET_SPRING_DAMPING_RATIO, OP_REVOLUTE_JOINT_ENABLE_MOTOR, OP_REVOLUTE_JOINT_SET_MOTOR_SPEED, OP_REVOLUTE_JOINT_SET_MAX_MOTOR_TORQUE, OP_SPHERICAL_JOINT_ENABLE_CONE_LIMIT, OP_SPHERICAL_JOINT_SET_CONE_LIMIT, OP_SPHERICAL_JOINT_SET_CONE_AXIS, OP_SPHERICAL_JOINT_SET_CONE_ELLIPSE, OP_SPHERICAL_JOINT_ENABLE_TWIST_LIMIT, OP_SPHERICAL_JOINT_SET_TWIST_LIMITS, OP_SPHERICAL_JOINT_ENABLE_SPRING, OP_SPHERICAL_JOINT_SET_TARGET_ROTATION, OP_SPHERICAL_JOINT_SET_SPRING_HERTZ, OP_SPHERICAL_JOINT_SET_SPRING_DAMPING_RATIO, OP_SPHERICAL_JOINT_ENABLE_MOTOR, OP_SPHERICAL_JOINT_SET_MOTOR_VELOCITY, OP_SPHERICAL_JOINT_SET_MAX_MOTOR_TORQUE, OP_WELD_JOINT_SET_LINEAR_HERTZ, OP_WELD_JOINT_SET_LINEAR_DAMPING_RATIO, OP_WELD_JOINT_SET_ANGULAR_HERTZ, OP_WELD_JOINT_SET_ANGULAR_DAMPING_RATIO, OP_WHEEL_JOINT_ENABLE_SUSPENSION, OP_WHEEL_JOINT_SET_SUSPENSION_HERTZ, OP_WHEEL_JOINT_SET_SUSPENSION_DAMPING_RATIO, OP_WHEEL_JOINT_ENABLE_SUSPENSION_LIMIT, OP_WHEEL_JOINT_SET_SUSPENSION_LIMITS, OP_WHEEL_JOINT_ENABLE_SPIN_MOTOR, OP_WHEEL_JOINT_SET_SPIN_MOTOR_SPEED, OP_WHEEL_JOINT_SET_MAX_SPIN_TORQUE, OP_WHEEL_JOINT_ENABLE_STEERING, OP_WHEEL_JOINT_SET_STEERING_HERTZ, OP_WHEEL_JOINT_SET_STEERING_DAMPING_RATIO, OP_WHEEL_JOINT_SET_MAX_STEERING_TORQUE, OP_WHEEL_JOINT_ENABLE_STEERING_LIMIT, OP_WHEEL_JOINT_SET_STEERING_LIMITS, OP_WHEEL_JOINT_SET_TARGET_STEERING_ANGLE, + OP_QUERY_CAST_SHAPE, OP_QUERY_CAST_RAY_CLOSEST, OP_QUERY_CAST_MOVER, OP_QUERY_COLLIDE_MOVER, OP_CREATE_BODY, OP_DESTROY_BODY, OP_BODY_SET_TRANSFORM, OP_BODY_SET_LINEAR_VELOCITY, OP_BODY_SET_ANGULAR_VELOCITY, OP_BODY_SET_TARGET_TRANSFORM, OP_BODY_APPLY_FORCE, OP_BODY_APPLY_FORCE_TO_CENTER, OP_BODY_APPLY_TORQUE, OP_BODY_APPLY_LINEAR_IMPULSE, OP_BODY_APPLY_LINEAR_IMPULSE_TO_CENTER, OP_BODY_APPLY_ANGULAR_IMPULSE, OP_BODY_SET_BULLET, OP_BODY_SET_MASS_DATA, OP_BODY_APPLY_MASS_FROM_SHAPES, OP_BODY_SET_LINEAR_DAMPING, OP_BODY_SET_ANGULAR_DAMPING, OP_BODY_SET_GRAVITY_SCALE, OP_BODY_SET_AWAKE, OP_BODY_ENABLE_SLEEP, OP_BODY_SET_SLEEP_THRESHOLD, OP_BODY_SET_MOTION_LOCKS, OP_CREATE_SPHERE_SHAPE, OP_CREATE_CAPSULE_SHAPE, OP_CREATE_HULL_SHAPE, OP_CREATE_TRANSFORMED_HULL_SHAPE, OP_CREATE_MESH_SHAPE, OP_CREATE_HEIGHT_FIELD_SHAPE, OP_CREATE_COMPOUND_SHAPE, OP_DESTROY_SHAPE, OP_SHAPE_SET_DENSITY, OP_SHAPE_SET_FILTER, OP_SHAPE_SET_SURFACE_MATERIAL, OP_SHAPE_SET_SPHERE, OP_SHAPE_SET_CAPSULE, OP_SHAPE_SET_HULL, OP_SHAPE_SET_MESH, OP_SHAPE_SET_FRICTION, OP_SHAPE_SET_RESTITUTION, OP_SHAPE_SET_MESH_MATERIAL, OP_SHAPE_ENABLE_PRE_SOLVE_EVENTS, OP_BODY_ENABLE_HIT_EVENTS, OP_BODY_ALLOW_FAST_ROTATION, OP_BODY_ENABLE_CONTACT_RECYCLING, OP_BODY_SET_SAFETY_FACTOR, OP_SHAPE_APPLY_WIND, OP_BODY_SET_NAME, OP_SHAPE_SET_NAME, OP_CREATE_DISTANCE_JOINT, OP_CREATE_MOTOR_JOINT, OP_CREATE_FILTER_JOINT, OP_CREATE_PARALLEL_JOINT, OP_CREATE_PRISMATIC_JOINT, OP_CREATE_REVOLUTE_JOINT, OP_CREATE_SPHERICAL_JOINT, OP_CREATE_WELD_JOINT, OP_CREATE_WHEEL_JOINT, OP_DESTROY_JOINT, OP_JOINT_SET_LOCAL_FRAME_A, OP_JOINT_SET_LOCAL_FRAME_B, OP_JOINT_SET_COLLIDE_CONNECTED, OP_JOINT_WAKE_BODIES, OP_JOINT_SET_CONSTRAINT_TUNING, OP_JOINT_SET_FORCE_THRESHOLD, OP_JOINT_SET_TORQUE_THRESHOLD, OP_BODY_SET_TYPE, OP_BODY_DISABLE, OP_BODY_ENABLE, OP_SHAPE_ENABLE_SENSOR_EVENTS, OP_SHAPE_ENABLE_CONTACT_EVENTS, OP_SHAPE_ENABLE_HIT_EVENTS, OP_STEP, OP_ENABLE_SLEEPING, OP_ENABLE_WARM_STARTING, OP_ENABLE_CONTINUOUS, OP_ENABLE_SPECULATIVE, OP_SET_RESTITUTION_THRESHOLD, OP_SET_RESTITUTION_ITERATIONS, OP_ENABLE_RESTITUTION_PROPAGATION, OP_ENABLE_KINEMATIC_STATIC_SOFTNESS, OP_SET_HIT_EVENT_THRESHOLD, OP_SET_CONTACT_TUNING, OP_SET_CONTACT_RECYCLE_DISTANCE, OP_SET_MAXIMUM_LINEAR_SPEED, OP_SET_GRAVITY, OP_REBUILD_STATIC_TREE, OP_EXPLODE, OP_DISTANCE_JOINT_SET_LENGTH, OP_DISTANCE_JOINT_ENABLE_LIMIT, OP_DISTANCE_JOINT_SET_LENGTH_RANGE, OP_DISTANCE_JOINT_ENABLE_SPRING, OP_DISTANCE_JOINT_SET_SPRING_FORCE_RANGE, OP_DISTANCE_JOINT_SET_SPRING_HERTZ, OP_DISTANCE_JOINT_SET_SPRING_DAMPING_RATIO, OP_DISTANCE_JOINT_ENABLE_MOTOR, OP_DISTANCE_JOINT_SET_MOTOR_SPEED, OP_DISTANCE_JOINT_SET_MAX_MOTOR_FORCE, OP_MOTOR_JOINT_SET_LINEAR_VELOCITY, OP_MOTOR_JOINT_SET_ANGULAR_VELOCITY, OP_MOTOR_JOINT_SET_MAX_VELOCITY_TORQUE, OP_MOTOR_JOINT_SET_MAX_VELOCITY_FORCE, OP_MOTOR_JOINT_SET_LINEAR_HERTZ, OP_MOTOR_JOINT_SET_LINEAR_DAMPING_RATIO, OP_MOTOR_JOINT_SET_ANGULAR_HERTZ, OP_MOTOR_JOINT_SET_ANGULAR_DAMPING_RATIO, OP_MOTOR_JOINT_SET_MAX_SPRING_FORCE, OP_MOTOR_JOINT_SET_MAX_SPRING_TORQUE, OP_PARALLEL_JOINT_SET_SPRING_HERTZ, OP_PARALLEL_JOINT_SET_SPRING_DAMPING_RATIO, OP_PARALLEL_JOINT_SET_MAX_TORQUE, OP_PRISMATIC_JOINT_ENABLE_SPRING, OP_PRISMATIC_JOINT_SET_SPRING_HERTZ, OP_PRISMATIC_JOINT_SET_SPRING_DAMPING_RATIO, OP_PRISMATIC_JOINT_SET_TARGET_TRANSLATION, OP_PRISMATIC_JOINT_ENABLE_LIMIT, OP_PRISMATIC_JOINT_SET_LIMITS, OP_PRISMATIC_JOINT_ENABLE_MOTOR, OP_PRISMATIC_JOINT_SET_MOTOR_SPEED, OP_PRISMATIC_JOINT_SET_MAX_MOTOR_FORCE, OP_REVOLUTE_JOINT_ENABLE_LIMIT, OP_REVOLUTE_JOINT_SET_LIMITS, OP_REVOLUTE_JOINT_ENABLE_SPRING, OP_REVOLUTE_JOINT_SET_TARGET_ANGLE, OP_REVOLUTE_JOINT_SET_SPRING_HERTZ, OP_REVOLUTE_JOINT_SET_SPRING_DAMPING_RATIO, OP_REVOLUTE_JOINT_ENABLE_MOTOR, OP_REVOLUTE_JOINT_SET_MOTOR_SPEED, OP_REVOLUTE_JOINT_SET_MAX_MOTOR_TORQUE, OP_SPHERICAL_JOINT_ENABLE_CONE_LIMIT, OP_SPHERICAL_JOINT_SET_CONE_LIMIT, OP_SPHERICAL_JOINT_SET_CONE_AXIS, OP_SPHERICAL_JOINT_SET_CONE_ELLIPSE, OP_SPHERICAL_JOINT_ENABLE_TWIST_LIMIT, OP_SPHERICAL_JOINT_SET_TWIST_LIMITS, OP_SPHERICAL_JOINT_ENABLE_SPRING, OP_SPHERICAL_JOINT_SET_TARGET_ROTATION, OP_SPHERICAL_JOINT_SET_SPRING_HERTZ, OP_SPHERICAL_JOINT_SET_SPRING_DAMPING_RATIO, OP_SPHERICAL_JOINT_ENABLE_MOTOR, OP_SPHERICAL_JOINT_SET_MOTOR_VELOCITY, OP_SPHERICAL_JOINT_SET_MAX_MOTOR_TORQUE, OP_WELD_JOINT_SET_LINEAR_HERTZ, OP_WELD_JOINT_SET_LINEAR_DAMPING_RATIO, OP_WELD_JOINT_SET_ANGULAR_HERTZ, OP_WELD_JOINT_SET_ANGULAR_DAMPING_RATIO, OP_WHEEL_JOINT_ENABLE_SUSPENSION, OP_WHEEL_JOINT_SET_SUSPENSION_HERTZ, OP_WHEEL_JOINT_SET_SUSPENSION_DAMPING_RATIO, OP_WHEEL_JOINT_ENABLE_SUSPENSION_LIMIT, OP_WHEEL_JOINT_SET_SUSPENSION_LIMITS, OP_WHEEL_JOINT_ENABLE_SPIN_MOTOR, OP_WHEEL_JOINT_SET_SPIN_MOTOR_SPEED, OP_WHEEL_JOINT_SET_MAX_SPIN_TORQUE, OP_WHEEL_JOINT_ENABLE_STEERING, OP_WHEEL_JOINT_SET_STEERING_HERTZ, OP_WHEEL_JOINT_SET_STEERING_DAMPING_RATIO, OP_WHEEL_JOINT_SET_MAX_STEERING_TORQUE, OP_WHEEL_JOINT_ENABLE_STEERING_LIMIT, OP_WHEEL_JOINT_SET_STEERING_LIMITS, OP_WHEEL_JOINT_SET_TARGET_STEERING_ANGLE, OP_ENABLE_KINEMATIC_SPECULATION, create_compound_shape, destroy_shape, shape_is_valid, get_shape_full_id, get_shape, shape_get_body, shape_get_aabb, shape_get_type, shape_get_density, shape_set_density, shape_get_filter, shape_set_filter, shape_get_surface_material, shape_set_surface_material, shape_get_material_count, shape_is_sensor, @@ -159,7 +159,8 @@ exports ( reset_world, get_world_count, get_max_world_count, track_capacity, contact_get_data, joint_get_constraint_tuning, shape_get_mesh_material_count, MANIFOLD_COUNT_BUCKETS, hull_entry_at, HullEntry, OwnedGeometry, - DebugShape, release_debug_shape, release_debug_shapes, set_debug_shape_callbacks + DebugShape, release_debug_shape, release_debug_shapes, set_debug_shape_callbacks, + kinematic_reach ) extern memset(block: ptr, value: int, size: int) -> ptr @@ -263,6 +264,13 @@ struct WorldDef { // drives hard does not sink into what it meets. Off, the reference's // choice (a kinematic body's contacts are soft). enable_kinematic_static_softness: bool + // A contact with a kinematic body keeps speculative points out to the + // pair's travel over the step, and a kinematic body's fat bounds cover + // its next step, so a slow body a fast kinematic one meets is brought + // up to speed in the step the gap closes instead of being found inside + // it on the next (#167). Its begin events come as early. Off, the + // reference's choice (2 cm of speculation whatever the speed). + enable_kinematic_speculation: bool contact_speed: float // the push-out speed contact_hertz: float // the contact spring contact_damping_ratio: float @@ -379,7 +387,7 @@ default_restitution(restitution_a: float, user_a: long, restitution_b: float, us default_world_def() -> WorldDef { return WorldDef { gravity: math.vec3(0.0, 0.0 - 10.0, 0.0), hit_event_threshold: 1.0, restitution_threshold: 1.0, contact_speed: 3.0, - restitution_iterations: 2, enable_restitution_propagation: false, enable_kinematic_static_softness: false, + restitution_iterations: 2, enable_restitution_propagation: false, enable_kinematic_static_softness: false, enable_kinematic_speculation: false, contact_hertz: 30.0, contact_damping_ratio: 10.0, maximum_linear_speed: 400.0, friction_callback: default_friction, restitution_callback: default_restitution, enable_sleep: true, enable_continuous: true, static_body_count: 0, dynamic_body_count: 0, @@ -1314,6 +1322,7 @@ struct World { restitution_iterations: int enable_restitution_propagation: bool enable_kinematic_static_softness: bool + enable_kinematic_speculation: bool max_linear_speed: float contact_speed: float contact_hertz: float @@ -1607,6 +1616,7 @@ init_world_contents(w: *World, def: *WorldDef) { w.restitution_iterations = math.clamp_int(def.restitution_iterations, 0, MAX_RESTITUTION_ITERATIONS) w.enable_restitution_propagation = def.enable_restitution_propagation w.enable_kinematic_static_softness = def.enable_kinematic_static_softness + w.enable_kinematic_speculation = def.enable_kinematic_speculation w.max_linear_speed = def.maximum_linear_speed w.contact_speed = def.contact_speed w.contact_hertz = def.contact_hertz @@ -2394,7 +2404,7 @@ struct RecordedGeometry { // which older players skip. const RECORDING_MAGIC = 0x43525041 // "APRC", little-endian const RECORDING_MAJOR_VERSION = 1 -const RECORDING_MINOR_VERSION = 1 +const RECORDING_MINOR_VERSION = 2 const RECORDING_HEADER_BYTES = 32 const OP_STATE_HASH = 2 @@ -2568,6 +2578,7 @@ const OP_WHEEL_JOINT_SET_MAX_STEERING_TORQUE = 172 const OP_WHEEL_JOINT_ENABLE_STEERING_LIMIT = 173 const OP_WHEEL_JOINT_SET_STEERING_LIMITS = 174 const OP_WHEEL_JOINT_SET_TARGET_STEERING_ANGLE = 175 +const OP_ENABLE_KINEMATIC_SPECULATION = 176 // Whether the world of this index is recording: the hooks' test, false // for an index that is not a world's. @@ -6014,6 +6025,33 @@ try_recycle_contact(w: *World, c: *Contact, sim_a: *BodySim, sim_b: *BodySim, st return true } +// How far a contact with a kinematic body keeps speculative points +// (enable_kinematic_speculation): the speculative distance plus what the +// pair can close over the coming step, the relative speed and each body's +// angular speed times its extent, a bound on any two points' approach. +// Any other contact keeps the speculative distance. +kinematic_reach(w: *World, c: *Contact, sim_a: *BodySim, sim_b: *BodySim) -> float { + body_a = body_at(w, c.edge_a.body_id) + body_b = body_at(w, c.edge_b.body_id) + if body_a.kind != BODY_KINEMATIC && body_b.kind != BODY_KINEMATIC { return SPECULATIVE_DISTANCE } + v_a = math.vec3_zero() + w_a = math.vec3_zero() + v_b = math.vec3_zero() + w_b = math.vec3_zero() + state_a = get_body_state(w, body_a) + if state_a != null { + v_a = math.widen3(state_a.v) + w_a = math.widen3(state_a.w) + } + state_b = get_body_state(w, body_b) + if state_b != null { + v_b = math.widen3(state_b.v) + w_b = math.widen3(state_b.w) + } + speed = math.length(math.sub(v_a, v_b)) + math.length(w_a) * math.length(math.widen3(sim_a.max_extent)) + math.length(w_b) * math.length(math.widen3(sim_b.max_extent)) + return SPECULATIVE_DISTANCE + speed / w.inv_dt +} + // One awake contact's pass: destroyed when its proxies no longer // overlap, recycled when its bodies barely moved, else its manifolds // recomputed; a change of touching is noted for the state pass. @@ -6048,7 +6086,11 @@ collide_contact(w: *World, contact_id: int) { c.cached_relative_p = math.narrow3(relative.p) c.cached_relative_q = math.narrow_quat(relative.q) c.flags = c.flags | RELATIVE_TRANSFORM_VALID + reach = SPECULATIVE_DISTANCE + if w.enable_kinematic_speculation && w.inv_dt > 0.0 { reach = kinematic_reach(w, c, sim_a, sim_b) } + if reach != SPECULATIVE_DISTANCE { manifold.set_speculative_distance(reach) } touching = update_contact(w, c, shape_a, math.widen3(sim_a.local_center), sim_a.transform, shape_b, math.widen3(sim_b.local_center), sim_b.transform, is_fast) + if reach != SPECULATIVE_DISTANCE { manifold.set_speculative_distance(SPECULATIVE_DISTANCE) } count_manifolds(task_context(w), c.manifold_count) if touching && was_touching && (c.flags & SIM_MESH_CONTACT) != 0 { contact_spec_at(graph_color(w, c.color_index), c.local_index).manifold_count = c.manifold_count diff --git a/aephysics/manifold/module.ae b/aephysics/manifold/module.ae index 84cf382..c9714c7 100644 --- a/aephysics/manifold/module.ae +++ b/aephysics/manifold/module.ae @@ -32,7 +32,7 @@ exports ( reserve_scratch, Sphere, Capsule, FeaturePair, LocalManifoldPoint, LocalManifold, SATCache, SeparatingAxis, AxisQuery, ClipVertex, - MAX_MANIFOLD_POINTS, MAX_CLIP_POINTS, SPECULATIVE_DISTANCE, PARALLEL_EDGE_TOL, NULL_INDEX, + MAX_MANIFOLD_POINTS, MAX_CLIP_POINTS, SPECULATIVE_DISTANCE, speculative_distance, set_speculative_distance, PARALLEL_EDGE_TOL, NULL_INDEX, AXIS_INVALID, AXIS_BACKSIDE, AXIS_FACE_A, AXIS_FACE_B, AXIS_EDGE_PAIR, AXIS_CLOSEST_POINTS, AXIS_MANUAL_FACE_A, AXIS_MANUAL_FACE_B, AXIS_MANUAL_EDGE_PAIR, FEATURE_SHAPE_A, FEATURE_SHAPE_B, @@ -236,7 +236,34 @@ var g_lane_scratch: ptr = null lane_scratch() -> ptr { return native.scratch(g_lane_scratch, 64) } b_frame_scratch() -> ptr { return edge_axes() + AXIS_FIELDS * AXIS_SLOTS * 4 } +// Each worker's speculative distance: how far apart two shapes keep +// points, SPECULATIVE_DISTANCE but while the collide pass reaches out for +// a contact with a kinematic body (WorldDef.enable_kinematic_speculation, +// #167). Every function here reads it once on entry. +var g_speculative: ptr = null // float[MAX_WORKERS] + +speculative_distance() -> float { + if g_speculative == null { return SPECULATIVE_DISTANCE } + return (native.scratch(g_speculative, 8) as float[])[0] +} + +// The calling worker's speculative distance, until it is set back. +set_speculative_distance(distance: float) { + scratch_ready() + (native.scratch(g_speculative, 8) as float[])[0] = distance +} + scratch_ready() { + if g_speculative == null { + block = basics.alloc(native.MAX_WORKERS * 8) + each = block as float[] + k = 0 + while k < native.MAX_WORKERS { + each[k] = SPECULATIVE_DISTANCE + k = k + 1 + } + g_speculative = block + } if g_clip1 == null { g_clip1 = basics.alloc(native.MAX_WORKERS * (CLIP_CAPACITY * sizeof(ClipVertex))) g_clip2 = basics.alloc(native.MAX_WORKERS * (CLIP_CAPACITY * sizeof(ClipVertex))) @@ -454,6 +481,7 @@ clip_segment_to_hull_face(segment: ClipVertex[], h: *HullData, ref_face: int) -> // the point adding the most area outside it. The 0.95 bias keeps the // choice stable across steps. Consumes the point array. reduce_manifold_points(m: *LocalManifold, capacity: int, points: LocalManifoldPoint[], count: int) { + reach = speculative_distance() if capacity < 4 { return } if count <= 4 { i = 0 @@ -474,7 +502,7 @@ reduce_manifold_points(m: *LocalManifold, capacity: int, points: LocalManifoldPo search = math.arbitrary_perp(normal) i = 0 while i < count { - if points[i].separation <= SPECULATIVE_DISTANCE { + if points[i].separation <= reach { score = 0.0 - points[i].separation + math.dot(search, points[i].point) if bias * score > best_score { best_index = i @@ -614,6 +642,7 @@ collide_capsule_and_sphere(m: *LocalManifold, capacity: int, capsule_a: Capsule, // the deep one. collide_hull_and_sphere(m: *LocalManifold, capacity: int, hull_a: *HullData, sphere_b: Sphere, transform_b_to_a: Transform, cache: *SimplexCache) { + reach = speculative_distance() m.point_count = 0 if capacity == 0 { return } center = math.transform_point(transform_b_to_a, sphere_b.center) @@ -624,7 +653,7 @@ collide_hull_and_sphere(m: *LocalManifold, capacity: int, hull_a: *HullData, sph radius = radius_a + radius_b output = distance.shape_distance(&input, cache, null, 0) - if output.distance > radius + SPECULATIVE_DISTANCE { + if output.distance > radius + reach { clear_simplex_cache(cache) return } @@ -658,6 +687,7 @@ collide_hull_and_sphere(m: *LocalManifold, capacity: int, hull_a: *HullData, sph // Two points when the capsules are nearly parallel and B's clipped ends // both touch A; otherwise the closest points of the segments. collide_capsules(m: *LocalManifold, capacity: int, capsule_a: Capsule, capsule_b: Capsule, transform_b_to_a: Transform) { + reach = speculative_distance() m.point_count = 0 if capacity < 2 { return } center_a1 = capsule_a.center1 @@ -665,7 +695,7 @@ collide_capsules(m: *LocalManifold, capacity: int, capsule_a: Capsule, capsule_b center_b1 = math.transform_point(transform_b_to_a, capsule_b.center1) center_b2 = math.transform_point(transform_b_to_a, capsule_b.center2) radius = capsule_a.radius + capsule_b.radius - max_distance = radius + SPECULATIVE_DISTANCE + max_distance = radius + reach result = math.segment_distance(center_a1, center_a2, center_b1, center_b2) offset = math.sub(result.point2, result.point1) distance_squared = math.length_squared(offset) @@ -793,6 +823,7 @@ query_edge_direction_hull_and_capsule(h: *HullData, c: Capsule, capsule_transfor build_hull_face_and_capsule_contact(m: *LocalManifold, hull_a: *HullData, capsule_b: Capsule, transform_b_to_a: Transform, query: SeparatingAxis) -> bool { + reach = speculative_distance() planes = hull.hull_planes(hull_a) ref_face = query.index_a ref_plane = planes[ref_face] @@ -805,7 +836,7 @@ build_hull_face_and_capsule_contact(m: *LocalManifold, hull_a: *HullData, capsul if clip_segment_to_hull_face(segment, hull_a, ref_face) < 2 { return false } distance1 = math.plane_separation(ref_plane, segment[0].position) distance2 = math.plane_separation(ref_plane, segment[1].position) - if distance1 <= SPECULATIVE_DISTANCE || distance2 <= SPECULATIVE_DISTANCE { + if distance1 <= reach || distance2 <= reach { normal = ref_plane.normal point1 = math.mul_sub(segment[0].position, 0.5 * (distance1 + capsule_b.radius), normal) point2 = math.mul_sub(segment[1].position, 0.5 * (distance2 + capsule_b.radius), normal) @@ -844,12 +875,13 @@ build_hull_and_capsule_edge_contact(m: *LocalManifold, capacity: int, hull_a: *H // it lies along it; the face and edge axes for the deep case. collide_hull_and_capsule(m: *LocalManifold, capacity: int, hull_a: *HullData, capsule_b: Capsule, transform_b_to_a: Transform, cache: *SimplexCache) { + reach = speculative_distance() m.point_count = 0 if capacity < 2 { return } input = DistanceInput { proxy_a: hull.hull_proxy(hull_a), proxy_b: distance.shape_proxy((&capsule_b) as ptr, 2, 0.0), transform: transform_b_to_a, use_radii: false } output = distance.shape_distance(&input, cache, null, 0) - if output.distance > capsule_b.radius + SPECULATIVE_DISTANCE { + if output.distance > capsule_b.radius + reach { clear_simplex_cache(cache) return } @@ -871,7 +903,7 @@ collide_hull_and_capsule(m: *LocalManifold, capacity: int, hull_a: *HullData, ca if clip_segment_to_hull_face(vertices_b, hull_a, ref_face) == 2 { distance1 = math.plane_separation(ref_plane, vertices_b[0].position) distance2 = math.plane_separation(ref_plane, vertices_b[1].position) - if distance1 <= capsule_b.radius + SPECULATIVE_DISTANCE || distance2 <= capsule_b.radius + SPECULATIVE_DISTANCE { + if distance1 <= capsule_b.radius + reach || distance2 <= capsule_b.radius + reach { normal = ref_plane.normal point1 = math.mul_sub(vertices_b[0].position, 0.5 * (capsule_b.radius + distance1), normal) point2 = math.mul_sub(vertices_b[1].position, 0.5 * (capsule_b.radius + distance2), normal) @@ -913,6 +945,7 @@ collide_hull_and_capsule(m: *LocalManifold, capacity: int, hull_a: *HullData, ca // frame A. With early_return the scan stops at the first axis past the // speculative distance. The reference's scalar path, exactly. compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, early_return: bool) -> AxisQuery { + reach = speculative_distance() r = math.make_matrix_from_quat(xf_b.q) inv_r = math.transpose(r) res = AxisQuery { @@ -960,7 +993,7 @@ compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, e direction = math.neg(math.mul_mv(inv_r, plane.normal)) seed_vertex = support_index_wide(hull_b, direction) seed_separation = math.dot(plane.normal, xf_b.p) - plane.offset - math.dot(direction, points_b[seed_vertex]) - floor_a = math.min_float(seed_separation, SPECULATIVE_DISTANCE) + floor_a = math.min_float(seed_separation, reach) } i = 0 while i < hull_a.face_count { @@ -979,7 +1012,7 @@ compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, e res.face_a.separation = separation res.face_a.index_a = i res.face_a.index_b = vertex_index - if separation > SPECULATIVE_DISTANCE && early_return { + if separation > reach && early_return { res.separated_feature = AXIS_FACE_A return res } @@ -1011,7 +1044,7 @@ compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, e seed_vertex = support_index_wide(hull_a, direction) seed_separation = math.dot(direction, xf_b.p) - plane.offset - math.dot(direction, points_a[seed_vertex]) // A's faces have set a floor of their own. - floor_b = math.min_float(math.max_float(seed_separation, res.face_a.separation), SPECULATIVE_DISTANCE) + floor_b = math.min_float(math.max_float(seed_separation, res.face_a.separation), reach) } i = 0 while i < hull_b.face_count { @@ -1030,7 +1063,7 @@ compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, e res.face_b.separation = separation res.face_b.index_a = vertex_index res.face_b.index_b = i - if separation > SPECULATIVE_DISTANCE && early_return { + if separation > reach && early_return { res.separated_feature = AXIS_FACE_B return res } @@ -1253,7 +1286,7 @@ compute_separating_axis(hull_a: *HullData, hull_b: *HullData, xf_b: Transform, e res.edge.separation = separation res.edge.index_a = slots[AXIS_EDGE + at] as int res.edge.index_b = j - if separation > SPECULATIVE_DISTANCE && early_return { + if separation > reach && early_return { res.separated_feature = AXIS_EDGE_PAIR return res } @@ -1421,6 +1454,7 @@ build_polygon(out: ClipVertex[], transform: Transform, h: *HullData, inc_face: i // the reference's stacks are untouched. add_near_vertices(out: ClipVertex[], count: int, hull_a: *HullData, ref_face: int, ref_plane: Plane, hull_b: *HullData, inc_face: int, transform_b_to_a: Transform) -> int { + reach = speculative_distance() faces_a = hull.hull_faces(hull_a) edges_a = hull.hull_edges(hull_a) points_a = hull.hull_points(hull_a) @@ -1433,7 +1467,7 @@ add_near_vertices(out: ClipVertex[], count: int, hull_a: *HullData, ref_face: in while v < hull_b.vertex_count && out_count < MAX_CLIP_POINTS { position = math.add(math.mul_mv(m, points_b[v]), transform_b_to_a.p) separation = math.plane_separation(ref_plane, position) - if separation < SPECULATIVE_DISTANCE { + if separation < reach { // Not one of the incident face's own (those were clipped already). on_face = false start_b = faces_b[inc_face] @@ -1472,6 +1506,7 @@ add_near_vertices(out: ClipVertex[], count: int, hull_a: *HullData, ref_face: in // reduced to four. build_face_a_contact(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b: *HullData, transform_b_to_a: Transform, query: SeparatingAxis, cache: *SATCache) -> bool { + reach = speculative_distance() faces_a = hull.hull_faces(hull_a) edges_a = hull.hull_edges(hull_a) planes_a = hull.hull_planes(hull_a) @@ -1523,7 +1558,7 @@ build_face_a_contact(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b min_separation = math.min_float(min_separation, cp.separation) i = i + 1 } - if min_separation >= SPECULATIVE_DISTANCE { + if min_separation >= reach { clear_sat_cache(cache) return false } @@ -1599,6 +1634,7 @@ build_edge_contact(m: *LocalManifold, hull_a: *HullData, hull_b: *HullData, tran // edge contact is weak for stacking), the edge contact when the face // finds nothing or the edge axis is clearly better. collide_hulls(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b: *HullData, transform_b_to_a: Transform, cache: *SATCache) { + reach = speculative_distance() m.point_count = 0 if capacity < 4 { return } edges_a = hull.hull_edges(hull_a) @@ -1615,7 +1651,7 @@ collide_hulls(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b: *Hull vertex_index = hull.find_hull_support_vertex(hull_b, search_in_b) support = math.transform_point(transform_b_to_a, points_b[vertex_index]) separation = math.plane_separation(plane, support) - if separation >= SPECULATIVE_DISTANCE { + if separation >= reach { cache.hit = 1 return } @@ -1632,7 +1668,7 @@ collide_hulls(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b: *Hull vertex_index = hull.find_hull_support_vertex(hull_a, search_in_a) support = math.inv_transform_point(transform_b_to_a, points_a[vertex_index]) separation = math.plane_separation(plane, support) - if separation >= SPECULATIVE_DISTANCE { + if separation >= reach { cache.hit = 1 return } @@ -1666,7 +1702,7 @@ collide_hulls(m: *LocalManifold, capacity: int, hull_a: *HullData, hull_b: *Hull t = cba / (cba - dba) axis = math.normalize(math.lerp(u_b, v_b, t)) separation = math.dot(axis, math.sub(q_a, q_b)) - if separation > SPECULATIVE_DISTANCE { + if separation > reach { cache.hit = 1 return } diff --git a/aephysics/mesh_contact/module.ae b/aephysics/mesh_contact/module.ae index 00275a8..8d5a86b 100644 --- a/aephysics/mesh_contact/module.ae +++ b/aephysics/mesh_contact/module.ae @@ -61,7 +61,6 @@ const MAX_VERTEX_COUNT = 64 // The gap left after a time of impact (the "rest offset"). const MESH_REST_OFFSET = 0.005 const MAX_AABB_MARGIN = 0.05 -const SPECULATIVE_DISTANCE = 0.02 // A triangle's manifold and one accepted for the reduction: the // triangle_manifold module's features and the manifold module's axes, // restated (a module constant does not reach every expression). @@ -176,9 +175,10 @@ scratch_ready() { // by the margin and the speculative distance); a refresh keeps each // surviving triangle's warm start. refresh_cache(cache: *MeshContactCache, mesh_shape: *Shape, xf_a: Transform, bounds: AABB) { + reach = manifold.speculative_distance() if math.aabb_contains(cache.query_bounds, bounds) { return } scratch_ready() - radius = MAX_AABB_MARGIN + SPECULATIVE_DISTANCE + radius = MAX_AABB_MARGIN + reach extension = math.vec3(radius, radius, radius) cache.query_bounds = AABB { lower: math.sub(bounds.lower, extension), upper: math.add(bounds.upper, extension) } local_bounds = math.aabb_transform(math.invert_transform(xf_a), cache.query_bounds) diff --git a/aephysics/physics_world/module.ae b/aephysics/physics_world/module.ae index f31cc72..2e65d27 100644 --- a/aephysics/physics_world/module.ae +++ b/aephysics/physics_world/module.ae @@ -40,7 +40,7 @@ exports ( joint_event_count, joint_event, enable_sleeping, is_sleeping_enabled, enable_warm_starting, is_warm_starting_enabled, enable_continuous, is_continuous_enabled, enable_speculative, set_restitution_threshold, get_restitution_threshold, set_restitution_iterations, get_restitution_iterations, - enable_restitution_propagation, is_restitution_propagation_enabled, enable_kinematic_static_softness, is_kinematic_static_softness_enabled, set_hit_event_threshold, get_hit_event_threshold, + enable_restitution_propagation, is_restitution_propagation_enabled, enable_kinematic_static_softness, is_kinematic_static_softness_enabled, enable_kinematic_speculation, is_kinematic_speculation_enabled, set_hit_event_threshold, get_hit_event_threshold, set_contact_tuning, set_contact_recycle_distance, get_contact_recycle_distance, set_maximum_linear_speed, get_maximum_linear_speed, set_gravity, get_gravity, set_user_data, get_user_data, set_friction_callback, set_restitution_callback, set_custom_filter_callback, set_pre_solve_callback, get_awake_body_count, get_counters, get_bounds, rebuild_static_tree, @@ -390,6 +390,16 @@ enable_kinematic_static_softness(id: WorldId, flag: bool) { if w != null { w.enable_kinematic_static_softness = flag } } is_kinematic_static_softness_enabled(id: WorldId) -> bool { return dynamics.get_world(id.index1 - 1).enable_kinematic_static_softness } +// Whether a contact with a kinematic body keeps speculative points out to +// the pair's travel over the step, so a slow body a fast kinematic one +// meets is brought up to speed as the gap closes instead of being found +// inside it (off: the reference's 2 cm whatever the speed; #167). +enable_kinematic_speculation(id: WorldId, flag: bool) { + if dynamics.recording_on(id.index1 - 1) { recorder = dynamics.rec_begin(id.index1 - 1, dynamics.OP_ENABLE_KINEMATIC_SPECULATION); dynamics.rec_bool(recorder, flag); dynamics.rec_end(recorder) } + w = world_of(id) + if w != null { w.enable_kinematic_speculation = flag } +} +is_kinematic_speculation_enabled(id: WorldId) -> bool { return dynamics.get_world(id.index1 - 1).enable_kinematic_speculation } set_hit_event_threshold(id: WorldId, value: float) { if dynamics.recording_on(id.index1 - 1) { recorder = dynamics.rec_begin(id.index1 - 1, dynamics.OP_SET_HIT_EVENT_THRESHOLD); dynamics.rec_float(recorder, value); dynamics.rec_end(recorder) } if math.is_valid_float(value) == false { return } diff --git a/aephysics/recording/module.ae b/aephysics/recording/module.ae index ed08ce4..8b77bb3 100644 --- a/aephysics/recording/module.ae +++ b/aephysics/recording/module.ae @@ -2554,6 +2554,12 @@ dispatch_call(p: *Player, op: int) -> bool { if p.ok { physics_world.enable_kinematic_static_softness(a_world_id_id, a_bool_flag) } return true } + if op == dynamics.OP_ENABLE_KINEMATIC_SPECULATION { + a_world_id_id = p.world_id + a_bool_flag = read_bool(p) + if p.ok { physics_world.enable_kinematic_speculation(a_world_id_id, a_bool_flag) } + return true + } if op == dynamics.OP_SET_HIT_EVENT_THRESHOLD { a_world_id_id = p.world_id a_float_value = read_float(p) diff --git a/aephysics/snapshot/module.ae b/aephysics/snapshot/module.ae index 377c383..8d04159 100644 --- a/aephysics/snapshot/module.ae +++ b/aephysics/snapshot/module.ae @@ -42,7 +42,7 @@ extern memcpy(dst: ptr, src: ptr, size: int) -> ptr extern memset(block: ptr, value: int, size: int) -> ptr const SNAPSHOT_MAGIC = 0x4E535041 // "APSN", little-endian -const SNAPSHOT_VERSION = 3 +const SNAPSHOT_VERSION = 4 const NULL_INDEX = 0 - 1 // --- the bytes written ------------------------------------------------------------------------------- @@ -359,6 +359,7 @@ save_world(world_id: WorldId) -> (ptr, int) { w_int(&wr, w.restitution_iterations) w_bool(&wr, w.enable_restitution_propagation) w_bool(&wr, w.enable_kinematic_static_softness) + w_bool(&wr, w.enable_kinematic_speculation) w_float(&wr, w.max_linear_speed) w_float(&wr, w.contact_speed) w_float(&wr, w.contact_hertz) @@ -678,6 +679,7 @@ fill_world(w: *World, rd: *SnapReader) -> bool { w.restitution_iterations = r_int(rd) w.enable_restitution_propagation = r_bool(rd) w.enable_kinematic_static_softness = r_bool(rd) + w.enable_kinematic_speculation = r_bool(rd) w.max_linear_speed = r_float(rd) w.contact_speed = r_float(rd) w.contact_hertz = r_float(rd) diff --git a/aephysics/solver/module.ae b/aephysics/solver/module.ae index 9f168b4..5a21a9a 100644 --- a/aephysics/solver/module.ae +++ b/aephysics/solver/module.ae @@ -528,12 +528,25 @@ finalize_range(start: int, end: int, worker: int, context: ptr) { if (sim.flags & dynamics.IS_FAST) == 0 { t = sim.transform shape_id = body.head_shape_id + // A kinematic body's fat bounds cover its next step's sweep when + // its contacts reach out over it (enable_kinematic_speculation), + // so a pair it will meet exists in time; its shapes' own bounds + // stay where they are. + sweeps = w.enable_kinematic_speculation && body.kind == dynamics.BODY_KINEMATIC && time_step > 0.0 + travel = math.mul_sv(time_step, v) + turn = time_step * math.length(wv) * math.length(math.widen3(sim.max_extent)) while shape_id != NULL_INDEX { s = dynamics.world_shape_at(w, shape_id) s.aabb = shape.compute_fat_shape_aabb(&s.geometry, t, dynamics.SPECULATIVE_DISTANCE) - if math.aabbf_holds(dynamics.shape_fat_aabb(w, shape_id), s.aabb) == false { + covered = s.aabb + if sweeps { + ahead = math.offset_aabb(s.aabb, travel) + swept = math.aabb_union(s.aabb, ahead) + covered = AABB { lower: math.sub(swept.lower, math.vec3(turn, turn, turn)), upper: math.add(swept.upper, math.vec3(turn, turn, turn)) } + } + if math.aabbf_holds(dynamics.shape_fat_aabb(w, shape_id), covered) == false { margin = math.vec3(s.aabb_margin, s.aabb_margin, s.aabb_margin) - fat = math.aabb_round_out(AABB { lower: math.sub(s.aabb.lower, margin), upper: math.add(s.aabb.upper, margin) }) + fat = math.aabb_round_out(AABB { lower: math.sub(covered.lower, margin), upper: math.add(covered.upper, margin) }) dynamics.set_shape_fat_aabb(w, shape_id, fat) broad_phase.broad_phase_mark_proxy_moved(&w.broad_phase, s.proxy_key, fat) } diff --git a/aephysics/triangle_manifold/module.ae b/aephysics/triangle_manifold/module.ae index da8491b..7911f61 100644 --- a/aephysics/triangle_manifold/module.ae +++ b/aephysics/triangle_manifold/module.ae @@ -49,7 +49,6 @@ const AXIS_MANUAL_FACE_B = 7 const AXIS_MANUAL_EDGE_PAIR = 8 const FEATURE_SHAPE_A = 0 const FEATURE_SHAPE_B = 1 -const SPECULATIVE_DISTANCE = 0.02 const MAX_CLIP_POINTS = 64 const FLOAT_EPSILON = 0.00000011920929 const FLOAT_MIN = 0.000000000000000000000000000000000000011754944 @@ -222,6 +221,7 @@ set_point(m: *LocalManifold, i: int, point: Vec3, separation: float, pair: Featu // The sphere against the triangle's closest point; the back side culled. collide_triangle_and_sphere(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec3, v3: Vec3, sphere_b: Sphere) { + reach = manifold.speculative_distance() m.point_count = 0 if capacity == 0 { return } center = sphere_b.center @@ -231,7 +231,7 @@ collide_triangle_and_sphere(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec3 radius = sphere_b.radius closest = closest_point_on_triangle(v1, v2, v3, center) squared_distance = math.distance_squared(closest.point, center) - max_distance = radius + SPECULATIVE_DISTANCE + max_distance = radius + reach if squared_distance > max_distance * max_distance { return } d = math.sqrt_float(squared_distance) @@ -351,12 +351,13 @@ capsule_segment(c: Capsule) -> ClipVertex[] { } build_triangle_and_capsule_face_contact(m: *LocalManifold, v1: Vec3, v2: Vec3, v3: Vec3, plane: Plane, c: Capsule) { + reach = manifold.speculative_distance() segment = capsule_segment(c) if clip_segment_to_triangle_face(segment, v1, v2, v3, plane) == false { return } radius = c.radius distance1 = math.plane_separation(plane, segment[0].position) distance2 = math.plane_separation(plane, segment[1].position) - if distance1 > SPECULATIVE_DISTANCE + radius && distance2 > SPECULATIVE_DISTANCE + radius { return } + if distance1 > reach + radius && distance2 > reach + radius { return } point1 = math.mul_sub(segment[0].position, 0.5 * (distance1 + radius), plane.normal) point2 = math.mul_sub(segment[1].position, 0.5 * (distance2 + radius), plane.normal) m.normal = plane.normal @@ -397,6 +398,7 @@ build_triangle_and_capsule_edge_contact(m: *LocalManifold, v1: Vec3, v2: Vec3, v // GJK for the shallow case, the segment clipped to the face when the // closest direction is near the normal; face and edge axes deep. collide_triangle_and_capsule(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec3, v3: Vec3, capsule_b: Capsule, cache: *SimplexCache) { + reach = manifold.speculative_distance() m.point_count = 0 if capacity < 2 { return } plane = math.make_plane_from_points(v1, v2, v3) @@ -407,7 +409,7 @@ collide_triangle_and_capsule(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec transform: math.transform_identity(), use_radii: false } output = distance.shape_distance(&input, cache, null, 0) radius = capsule_b.radius - if output.distance > radius + SPECULATIVE_DISTANCE { return } + if output.distance > radius + reach { return } if output.distance > 100.0 * FLOAT_EPSILON { delta = math.normalize(math.sub(output.point_b, output.point_a)) @@ -546,6 +548,7 @@ query_triangle_and_hull_edges(t: *TriangleData, h: *HullData) -> SeparatingAxis // the hull. Returns the clipped separation. collide_hull_face(m: *LocalManifold, point_capacity: int, t: *TriangleData, h: *HullData, query: SeparatingAxis, cache: *SATCache, enable_speculative: bool) -> float { + reach = manifold.speculative_distance() m.point_count = 0 faces = hull.hull_faces(h) edges = hull.hull_edges(h) @@ -597,7 +600,7 @@ collide_hull_face(m: *LocalManifold, point_capacity: int, t: *TriangleData, h: * i = i + 1 } speculative = 0.0 - if enable_speculative { speculative = SPECULATIVE_DISTANCE } + if enable_speculative { speculative = reach } if min_separation > speculative { m.point_count = 0 manifold.clear_sat_cache(cache) @@ -618,6 +621,7 @@ collide_hull_face(m: *LocalManifold, point_capacity: int, t: *TriangleData, h: * // separation, or MAX_FLOAT when everything clipped away. collide_triangle_face(m: *LocalManifold, point_capacity: int, t: *TriangleData, h: *HullData, query: SeparatingAxis, cache: *SATCache, enable_speculative: bool) -> float { + reach = manifold.speculative_distance() faces = hull.hull_faces(h) edges = hull.hull_edges(h) points = hull.hull_points(h) @@ -668,7 +672,7 @@ collide_triangle_face(m: *LocalManifold, point_capacity: int, t: *TriangleData, i = i + 1 } speculative = 0.0 - if enable_speculative { speculative = SPECULATIVE_DISTANCE } + if enable_speculative { speculative = reach } if min_separation >= speculative { manifold.clear_sat_cache(cache) return min_separation @@ -715,6 +719,7 @@ collide_triangle_and_hull_edges(m: *LocalManifold, capacity: int, triangle_point // contact when the face finds nothing or is clearly worse; GJK last. collide_triangle_and_hull(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec3, v3: Vec3, triangle_flags: int, hull_b: *HullData, cache: *SATCache, enable_speculative: bool) { + reach = manifold.speculative_distance() m.point_count = 0 m.feature = FEATURE_NONE if capacity < 4 { return } @@ -738,7 +743,7 @@ collide_triangle_and_hull(m: *LocalManifold, capacity: int, v1: Vec3, v2: Vec3, planes = hull.hull_planes(hull_b) points = hull.hull_points(hull_b) speculative = 0.0 - if enable_speculative { speculative = SPECULATIVE_DISTANCE } + if enable_speculative { speculative = reach } cache.hit = 1 if cache.kind == AXIS_FACE_A { diff --git a/design.md b/design.md index 340c19b..67db12b 100644 --- a/design.md +++ b/design.md @@ -385,7 +385,12 @@ started until its tests pass. reference's b3World_Draw with its colours, its joints' drawings and the host's debug shapes, whose lives here also end with a geometry change or the world's end, where the reference keeps a stale one or leaves them - to the host. Two + to the host. A fast kinematic body's first frame (#167) is an + extension the reference does not have, off by default: with + `enable_kinematic_speculation`, a contact with a kinematic body keeps + speculative points out to the pair's travel over the step (the manifold + functions read a per-worker speculative distance the collide pass sets + per contact) and the kinematic body's fat bounds cover its next step. Two functions no test called hid type errors (`body_apply_force`, `body_get_local_point_velocity`) until the replay dispatcher called every recorded one: `ae build` checks only what a program reaches diff --git a/tests/test_collision.ae b/tests/test_collision.ae index 0dfec39..067aab8 100644 --- a/tests/test_collision.ae +++ b/tests/test_collision.ae @@ -172,10 +172,11 @@ main() { test_ray_aabb() test_large_world_manifold() test_large_world_aabb() - // The manifold module's scratch, six blocks made on its first use and - // kept (the clip polygons, the points, the edge rows and lists, the - // support search's lanes), is all that stays. - ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 6) + // The manifold module's scratch, seven blocks made on its first use and + // kept (each worker's speculative distance, the clip polygons, the + // points, the edge rows and lists, the support search's lanes), is all + // that stays. + ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 7) println("collision: ${checks} checks") if failures == 0 { diff --git a/tests/test_edge_cases.ae b/tests/test_edge_cases.ae index 9d33539..fd3aed9 100644 --- a/tests/test_edge_cases.ae +++ b/tests/test_edge_cases.ae @@ -1039,6 +1039,200 @@ test_kinematic_softness() { dynamics.destroy_world(world_id) } +// A fast kinematic body's strike (#167): with its contacts reaching out +// over the step's travel, a slow body it meets is brought up to speed in +// the step the gap closes, and the strike's first frame is under a +// centimetre deep, where the reference's 2 cm of speculation leaves the +// travel less 2 cm (8.3 cm at 7 m/s). A ball, a box and a capsule; at +// 20 m/s, past the fat bounds' margin, where only the kinematic body's swept +// bounds make the pair in time; a ball a spinning paddle swats; a ball a +// kinematic wave mesh sweeps into. The deepest of the strike's frames is +// the measure. A world without kinematic bodies steps the same bits either +// way. +const STRIKE_BALL = 0 +const STRIKE_BOX = 1 +const STRIKE_CAPSULE = 2 + +kinematic_strike(speculate: bool, speed: float, kind: int) -> float { + fixtures_ready() + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + wdef.enable_kinematic_speculation = speculate + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_KINEMATIC + bdef.position = math.vec3(0.0 - 2.0, 0.5, 0.0) + bdef.linear_velocity = math.vec3(speed, 0.0, 0.0) + car = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_hull_shape(car, &sdef, g_box as *HullData) + target_def = dynamics.default_body_def() + target_def.kind = dynamics.BODY_DYNAMIC + target_def.position = math.vec3(0.0, 0.5, 0.0) + target = dynamics.create_body(world_id, &target_def) + // Half its width along the strike: what the gap is measured from. + half = 0.3 + if kind == STRIKE_BALL { dynamics.create_sphere_shape(target, &sdef, manifold.sphere(math.vec3_zero(), 0.3)) } + else if kind == STRIKE_BOX { + small = hull.make_box_hull(0.2, 0.2, 0.2) + dynamics.create_hull_shape(target, &sdef, small) + hull.destroy_hull(small) + half = 0.2 + } else { + // Across the strike, its rounded side to the car's face. + dynamics.create_capsule_shape(target, &sdef, Capsule { center1: math.vec3(0.0, 0.0, 0.0 - 0.3), center2: math.vec3(0.0, 0.0, 0.3), radius: 0.15 }) + half = 0.15 + } + deepest = 0.0 + s = 0 + while s < 40 { + physics_world.step(world_id, 1.0 / 60.0, 4) + // The target against the car's front face: its half width out when just touching. + gap = dynamics.body_get_position(target).x - (dynamics.body_get_position(car).x + 0.5) - half + if gap < deepest { deepest = gap } + s = s + 1 + } + struck = dynamics.body_get_linear_velocity(target).x + dynamics.destroy_world(world_id) + // A target never struck has nothing to say: flagged as deep. + if struck < 0.5 * speed { return 0.0 - 1.0 } + return deepest +} + +// A ball in the path of a kinematic paddle spinning about its middle at +// 12 rad/s (a kinematic body turns about its centre of mass): at the +// ball's 0.5 m the blade sweeps 10 cm a step. The ball sits where the +// blade's face is 3 cm short of it one step, past the speculative +// distance, and would be 6.6 cm into it the next. +spinning_strike(speculate: bool) -> float { + fixtures_ready() + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + wdef.enable_kinematic_speculation = speculate + world_id = dynamics.create_world(&wdef) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_KINEMATIC + bdef.angular_velocity = math.vec3(0.0, 12.0, 0.0) + paddle = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + blade = hull.make_box_hull(0.6, 0.1, 0.05) + dynamics.create_hull_shape(paddle, &sdef, blade) + hull.destroy_hull(blade) + ball_def = dynamics.default_body_def() + ball_def.kind = dynamics.BODY_DYNAMIC + // At 1.3683 rad round from +x, the way +y spin turns it (toward -z). + angle = 1.3683 + turn = math.compute_cos_sin(angle) + ball_def.position = math.vec3(0.5 * turn.cosine, 0.0, 0.0 - 0.5 * turn.sine) + ball = dynamics.create_body(world_id, &ball_def) + dynamics.create_sphere_shape(ball, &sdef, manifold.sphere(math.vec3_zero(), 0.1)) + deepest = 0.0 + s = 0 + while s < 12 { + physics_world.step(world_id, 1.0 / 60.0, 4) + // The ball's centre against the blade's face, in the paddle's frame. + local = math.inv_transform_point(dynamics.body_get_transform(paddle), dynamics.body_get_position(ball)) + if math.abs_float(local.x) < 0.6 && math.abs_float(local.y) < 0.2 { + depth = math.abs_float(local.z) - 0.05 - 0.1 + if depth < deepest { deepest = depth } + } + s = s + 1 + } + dynamics.destroy_world(world_id) + return deepest +} + +// A ball a kinematic wave mesh rises into at 7 m/s: the mesh contact's +// triangles reach out too. +mesh_strike(speculate: bool) -> float { + fixtures_ready() + wdef = dynamics.default_world_def() + wdef.gravity = math.vec3_zero() + wdef.enable_kinematic_speculation = speculate + world_id = dynamics.create_world(&wdef) + flat = mesh.create_wave_mesh(6, 6, 1.0, 0.0, 0.0, 0.0) + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_KINEMATIC + bdef.position = math.vec3(0.0 - 3.0, 0.0 - 1.0, 0.0 - 3.0) + bdef.linear_velocity = math.vec3(0.0, 7.0, 0.0) + floor = dynamics.create_body(world_id, &bdef) + sdef = dynamics.default_shape_def() + dynamics.create_mesh_shape(floor, &sdef, flat, math.vec3_one()) + ball_def = dynamics.default_body_def() + ball_def.kind = dynamics.BODY_DYNAMIC + ball_def.position = math.vec3(0.0, 0.5, 0.0) + ball = dynamics.create_body(world_id, &ball_def) + dynamics.create_sphere_shape(ball, &sdef, manifold.sphere(math.vec3_zero(), 0.3)) + deepest = 0.0 + s = 0 + while s < 30 { + physics_world.step(world_id, 1.0 / 60.0, 4) + gap = dynamics.body_get_position(ball).y - dynamics.body_get_position(floor).y - 0.3 + if gap < deepest { deepest = gap } + s = s + 1 + } + struck = dynamics.body_get_linear_velocity(ball).y + dynamics.destroy_world(world_id) + mesh.destroy_mesh(flat) + if struck < 3.5 { return 0.0 - 1.0 } + return deepest +} + +// A pile with no kinematic body, stepped with and without the setting. +pile_hash(speculate: bool) -> long { + fixtures_ready() + wdef = dynamics.default_world_def() + wdef.enable_kinematic_speculation = speculate + world_id = dynamics.create_world(&wdef) + ground_in(world_id, g_ground) + sdef = dynamics.default_shape_def() + i = 0 + while i < 12 { + bdef = dynamics.default_body_def() + bdef.kind = dynamics.BODY_DYNAMIC + bdef.position = math.vec3(0.3 * ((i % 3) as float), 0.5 + 0.9 * ((i / 3) as float), 0.0) + dynamics.create_hull_shape(dynamics.create_body(world_id, &bdef), &sdef, g_box as *HullData) + i = i + 1 + } + k = 0 + while k < 120 { + physics_world.step(world_id, 1.0 / 60.0, 4) + k = k + 1 + } + hash = dynamics.hash_world_state(dynamics.get_world(world_id.index1 - 1)) + dynamics.destroy_world(world_id) + return hash +} + +test_kinematic_speculation() { + ball = kinematic_strike(true, 7.0, STRIKE_BALL) + ensure("speculation: a ball struck at 7 m/s, its deepest under 1 cm (${ball} m)", ball > 0.0 - 0.01) + reference = kinematic_strike(false, 7.0, STRIKE_BALL) + ensure("and 6 cm or more without it, the reference's (${reference} m)", reference < 0.0 - 0.06) + box = kinematic_strike(true, 7.0, STRIKE_BOX) + ensure("speculation: a box struck at 7 m/s (${box} m)", box > 0.0 - 0.01) + capsule = kinematic_strike(true, 7.0, STRIKE_CAPSULE) + ensure("speculation: a capsule struck at 7 m/s (${capsule} m)", capsule > 0.0 - 0.01) + fast = kinematic_strike(true, 20.0, STRIKE_BALL) + ensure("speculation: a ball struck at 20 m/s, 33 cm a step past the bounds' margin (${fast} m)", fast > 0.0 - 0.01) + fast_reference = kinematic_strike(false, 20.0, STRIKE_BALL) + ensure("and deep without it (${fast_reference} m)", fast_reference < 0.0 - 0.1) + // A turning face's approach is linearized over each sub-step, so a + // spinning strike keeps a little of its depth: within 1.5 cm. + spun = spinning_strike(true) + ensure("speculation: a ball a spinning paddle swats, within 1.5 cm (${spun} m)", spun > 0.0 - 0.015) + spun_reference = spinning_strike(false) + ensure("and 3 cm or more without it (${spun_reference} m)", spun_reference < 0.0 - 0.03) + lifted = mesh_strike(true) + ensure("speculation: a ball a kinematic mesh rises into at 7 m/s (${lifted} m)", lifted > 0.0 - 0.01) + ensure("speculation: no kinematic body, the same bits", pile_hash(true) == pile_hash(false)) + world_id = new_world() + ensure("speculation: off by default, the reference's", physics_world.is_kinematic_speculation_enabled(world_id) == false) + physics_world.enable_kinematic_speculation(world_id, true) + ensure("speculation: and a world turns it on", physics_world.is_kinematic_speculation_enabled(world_id)) + dynamics.destroy_world(world_id) +} + // A hull of twelve random points rocking on the floor about one of its // vertices (#134), from its state at step 800 of the soak's seed 1. The // reference, and this engine before, rocked it in a cycle of 400 steps for @@ -1541,6 +1735,7 @@ main() { test_limit_stiffness() test_elliptical_cone() test_kinematic_softness() + test_kinematic_speculation() test_rocking_hull() test_encoded_sims() test_non_dynamic_joint_disable() diff --git a/tests/test_manifold.ae b/tests/test_manifold.ae index c4eb88c..2291e32 100644 --- a/tests/test_manifold.ae +++ b/tests/test_manifold.ae @@ -995,11 +995,11 @@ main() { test_hull_capsule_edge_deep() test_sphere_hull_seam() test_rounded_pairs() - // The module's scratch stays allocated: the two clip polygons, the - // unreduced points, the edge axes the separating-axis test gathers A's - // edges into, its two hulls' candidate edge lists, and the support - // search's lanes. - ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 6) + // The module's scratch stays allocated: each worker's speculative + // distance, the two clip polygons, the unreduced points, the edge axes + // the separating-axis test gathers A's edges into, its two hulls' + // candidate edge lists, and the support search's lanes. + ensure("every other counted allocation was freed (${basics.alloc_count() - before})", basics.alloc_count() == before + 7) println("manifold: ${checks} checks") if failures == 0 { diff --git a/tests/test_recording.ae b/tests/test_recording.ae index 97dcd85..a221738 100644 --- a/tests/test_recording.ae +++ b/tests/test_recording.ae @@ -1146,6 +1146,7 @@ all_ops() { physics_world.set_restitution_iterations(world_id, 3) physics_world.enable_restitution_propagation(world_id, true) physics_world.enable_kinematic_static_softness(world_id, true) + physics_world.enable_kinematic_speculation(world_id, true) physics_world.set_hit_event_threshold(world_id, 0.5) physics_world.set_contact_tuning(world_id, 25.0, 8.0, 2.5) physics_world.set_contact_recycle_distance(world_id, 0.02) @@ -1253,7 +1254,7 @@ all_ops() { dynamics.destroy_world(world_id) // Every opcode a recording has. - all_op_count = 160 + all_op_count = 161 all_ops_list = [ dynamics.OP_CREATE_BODY, dynamics.OP_DESTROY_BODY, dynamics.OP_BODY_SET_TRANSFORM, dynamics.OP_BODY_SET_LINEAR_VELOCITY, dynamics.OP_BODY_SET_ANGULAR_VELOCITY, @@ -1323,7 +1324,7 @@ all_ops() { dynamics.OP_WHEEL_JOINT_SET_MAX_SPIN_TORQUE, dynamics.OP_WHEEL_JOINT_ENABLE_STEERING, dynamics.OP_WHEEL_JOINT_SET_STEERING_HERTZ, dynamics.OP_WHEEL_JOINT_SET_STEERING_DAMPING_RATIO, dynamics.OP_WHEEL_JOINT_SET_MAX_STEERING_TORQUE, dynamics.OP_WHEEL_JOINT_ENABLE_STEERING_LIMIT, - dynamics.OP_WHEEL_JOINT_SET_STEERING_LIMITS, dynamics.OP_WHEEL_JOINT_SET_TARGET_STEERING_ANGLE + dynamics.OP_WHEEL_JOINT_SET_STEERING_LIMITS, dynamics.OP_WHEEL_JOINT_SET_TARGET_STEERING_ANGLE, dynamics.OP_ENABLE_KINEMATIC_SPECULATION ] seen = calloc(4096, 4) mark_ops(r, seen) diff --git a/tests/test_snapshot.ae b/tests/test_snapshot.ae index a54078f..9d5f213 100644 --- a/tests/test_snapshot.ae +++ b/tests/test_snapshot.ae @@ -84,6 +84,8 @@ fixtures_free() { world_def() -> WorldDef { wdef = dynamics.default_world_def() wdef.gravity = math.vec3(0.0, 0.0 - 10.0, 0.0) + // The kinematic paddle reaches out (#167): a loaded world steps alike only if the setting came with it. + wdef.enable_kinematic_speculation = true return wdef } @@ -336,7 +338,10 @@ round_trip(before: int) { steps(original, before) block, byte_count = snapshot.save_world(original) ensure("round trip at ${before}: saved (${byte_count} bytes)", block != null && byte_count > 0) - loaded = snapshot.load_world(&wdef, block, byte_count) + // Loaded with a def that has the setting off: the snapshot's settings rule. + plain = dynamics.default_world_def() + loaded = snapshot.load_world(&plain, block, byte_count) + ensure("round trip at ${before}: the kinematic speculation came with it", dynamics.world_is_valid(loaded) && physics_world.is_kinematic_speculation_enabled(loaded)) ensure("round trip at ${before}: loaded", dynamics.world_is_valid(loaded)) if dynamics.world_is_valid(loaded) == false { snapshot.free_snapshot(block, byte_count)