From dfadc779ed488e2f2a496f7919e12e4f0849bfc8 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 19 Oct 2026 23:27:56 -0300 Subject: [PATCH 01/10] WIP: locomotion.go_to (#825): arrive on a spot along its facing; turning on the spot and the run's stop still slide --- src/ae3d/locomotion/module.ae | 125 ++++++++++++++++++++++- tests/test_go_to.ae | 180 ++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/test_go_to.ae diff --git a/src/ae3d/locomotion/module.ae b/src/ae3d/locomotion/module.ae index 7c5b42df..79b815ce 100644 --- a/src/ae3d/locomotion/module.ae +++ b/src/ae3d/locomotion/module.ae @@ -77,6 +77,7 @@ exports ( attach, locomotion_free, set_input, set_speeds, speed, heading, velocity, clip_now, clip_speed, lean_roll, lean_pitch, hips_lift, use_matching, set_crowd, set_load, set_body_mass, top_speed, hips_height, set_paused, paused, place, set_stand_in, set_directions, direction_way, direction_for, + go_to, stop_going, going_to, arrived, ARRIVE_NEAR, ARRIVE_FACING, set_foot_lock, foot_lock, lock_offset, LOCK_BLEED, LOCK_MOST, TURN_CRAWL, TURN_STEP_PACE, BODY_MASS, LOAD_SLOW, ACCELERATION, DECELERATION, TURN_RATE, IDLE_BELOW, LEAN_TURN, LEAN_PITCH, STEP_SPEED, BLEND_SECONDS @@ -144,6 +145,13 @@ struct Locomotion { walk_max: float // the body's top speeds run_max: float input: Vec3 // the way asked for, its length up to 1 + goal: bool // going to `goal_at` (go_to), which sets the input each step + goal_at: Vec3 + goal_facing: float // radians about up, the way to face there + goal_running: bool + goal_reached: bool // on its spot and turned to face the way asked + goal_final: bool // on its last ENTRY_RUN, along the facing, to the spot + goal_there: bool // come to its spot (within ARRIVE_NEAR), until moved off it past ARRIVE_LEAVE running: bool velocity: Vec3 heading: float // radians about up; 0 faces +z @@ -714,6 +722,7 @@ locomotion_free(l: *Locomotion) { // and whether to run. set_input(l: *Locomotion, direction: Vec3, running: bool) { if l == null { return } + l.goal = false d = core.vec3(direction.x, 0.0, direction.z) n = core.v3_len(d) if n > 1.0 { d = core.v3_mul(d, 1.0 / n) } @@ -1014,6 +1023,7 @@ crowd_aside(l: *Locomotion, wanted: Vec3, step: float) -> Vec3 { on_step(state: ptr, e: *Engine, step: float) { l = state as *Locomotion if l.paused { return } + if l.goal { steer_to_goal(l) } // The way it faces, at what is asked of it: it walks the arc its turn // makes, not crabbing straight at where it is going while still turned // from it -- its clip's feet step back along the way it faces, and the @@ -1085,6 +1095,111 @@ on_step(state: ptr, e: *Engine, step: float) { l.velocity = core.vec3(went.x - bleed.x, 0.0, went.z - bleed.z) } +// Walk (or run) to `at` and stop on it, then turn to face `facing` (level): +// the spot a game sends a figure to, to sit, to work, to stand guard. The +// input is set each step as Reynolds' arrive steers (1999): toward the spot +// at the speed it can still stop from in the distance left, slowing at +// ARRIVE_DECEL of its DECELERATION, so the body's own momentum brings it to +// rest on the spot (the aim of Distance Matching, Paragon, GDC 2016: a stop +// planted on its mark, not slid onto it). Stopped, it turns on the spot to +// the facing at TURN_RATE, stepping round. set_input ends it. +go_to(l: *Locomotion, at: Vec3, facing: Vec3, running: bool) { + if l == null { return } + l.goal = true + l.goal_at = core.vec3(at.x, 0.0, at.z) + l.goal_facing = l.heading + if core.v3_len(core.vec3(facing.x, 0.0, facing.z)) > 0.001 { l.goal_facing = core.atan2_of(facing.x, facing.z) } + l.goal_running = running + l.goal_reached = false + l.goal_there = false + // From nearer than ENTRY_RUN and a half it goes straight to the spot and + // turns there; decided here, as it sets off: decided on the way, a + // figure heading for the entry point came within it of the spot short + // of the entry, went straight in 47 degrees off the line, and turned + // on the spot after all. + c = l.controller as *CharacterController + from = core.v3_add(physics.character_feet(c), l.lock) + l.goal_final = core.v3_len(core.vec3(l.goal_at.x - from.x, 0.0, l.goal_at.z - from.z)) < ENTRY_RUN * 1.5 +} + +// Stop going to the spot, where it is. +stop_going(l: *Locomotion) { + if l == null { return } + l.goal = false + l.input = core.vec3(0.0, 0.0, 0.0) +} + +going_to(l: *Locomotion) -> bool { return l != null && l.goal } + +// Whether it has come to its spot (within ARRIVE_NEAR) and turned to face +// the way asked (within ARRIVE_FACING), standing. +arrived(l: *Locomotion) -> bool { return l != null && l.goal_reached } + +const ARRIVE_DECEL = 0.5 // of DECELERATION, the braking an arrival plans on +const ARRIVE_NEAR = 0.05 // metres off its spot it has come to it +// Come to its spot, it stays there until moved off it this far: a person +// does not walk a 5 cm correction. Steered again for any drift (its capsule +// catching up with its drawn body over LOCK_BLEED), the box man turned to +// face each correction and ended 104 degrees off the facing asked. +const ARRIVE_LEAVE = 0.3 +// It comes to its spot along the way it is to face there, over its last +// ENTRY_RUN metres, as a GTA scenario point's or an Unreal Smart Object +// slot's entry has a character come in: arriving, it faces the way already. +// (From nearer than ENTRY_RUN and a half to start with, it goes straight +// there and turns on the spot.) +// Turned on the spot at the end instead, it walked the turn: the walk's +// clip at a slow pace held on its planted foot, a quarter turn carried its +// body 0.26 m off its spot, it was steered back, and it circled the spot. +// It heads for the entry point, ENTRY_RUN back from the spot along the +// facing, until it is on that line ahead of the spot (within ENTRY_CONE of +// it) or it reaches the entry point. +const ENTRY_RUN = 1.0 +const ENTRY_CONE = 0.35 // radians +const ENTRY_NEAR = 0.25 // metres off the entry point it is on it +const ARRIVE_FACING = 0.1 // radians off the way asked it faces it + +// The input toward the goal for this step (go_to), steered by where the +// figure is drawn: its capsule and what its foot hold has still to bring +// the capsule (the lock's offset), not the capsule alone, which comes on +// after the body has stopped. +steer_to_goal(l: *Locomotion) { + c = l.controller as *CharacterController + feet = core.v3_add(physics.character_feet(c), l.lock) + to = core.vec3(l.goal_at.x - feet.x, 0.0, l.goal_at.z - feet.z) + d = core.v3_len(to) + top = top_speed(l) + l.running = l.goal_running + // The entry point first (ENTRY_RUN): from far enough off, and until it + // is on the line in. + face = core.vec3(core.sin_of(l.goal_facing), 0.0, core.cos_of(l.goal_facing)) + if !l.goal_final && !l.goal_there { + on_line = d > 0.001 && core.v3_dot(core.v3_mul(to, 1.0 / d), face) >= core.cos_of(ENTRY_CONE) + entry = core.v3_sub(l.goal_at, core.v3_mul(face, ENTRY_RUN)) + to_entry = core.vec3(entry.x - feet.x, 0.0, entry.z - feet.z) + if on_line || core.v3_len(to_entry) < ENTRY_NEAR { + l.goal_final = true + } else { + l.input = core.v3_normalize(to_entry) + return + } + } + if d <= ARRIVE_NEAR { l.goal_there = true } + if d > ARRIVE_LEAVE { l.goal_there = false } + if l.goal_there || top <= 0.0 { + l.input = core.vec3(0.0, 0.0, 0.0) + turned = core.abs_of(wrap_turn(l.goal_facing - l.heading)) + l.goal_reached = turned <= ARRIVE_FACING && speed(l) < IDLE_BELOW + return + } + l.goal_reached = false + // The speed it can still stop from: v*v = 2 a d, with what it covers in + // the step it takes to see that kept out. + can = core.sqrt_of(2.0 * ARRIVE_DECEL * DECELERATION * (d - ARRIVE_NEAR)) + want = can + if want > top { want = top } + l.input = core.v3_mul(core.v3_normalize(to), want / top) +} + // How much of its speed it may go the way it is asked, as it faces: all of // it ahead, by the cosine of the turn to it short of a quarter turn, and // TURN_CRAWL past that -- it turns round before it walks off (#611). @@ -1116,7 +1231,15 @@ on_frame(state: ptr, e: *Engine, delta: float) { before = l.heading asked = core.sqrt_of(l.input.x * l.input.x + l.input.z * l.input.z) spinning = false - if sp > IDLE_BELOW || asked > 0.05 { + // At its spot (go_to), stopped: turned toward the way asked, on the spot. + if l.goal && asked <= 0.05 && sp <= IDLE_BELOW { + d = wrap_turn(l.goal_facing - l.heading) + most = TURN_RATE * delta + if d > most { d = most } + if d < 0.0 - most { d = 0.0 - most } + l.heading = l.heading + d + spinning = core.abs_of(d) > most * 0.5 + } else if sp > IDLE_BELOW || asked > 0.05 { // Toward where it is asked to go, when it is; else the way it goes. wanted = core.atan2_of(l.velocity.x, l.velocity.z) if asked > 0.05 { wanted = core.atan2_of(l.input.x, l.input.z) + l.aside } diff --git a/tests/test_go_to.ae b/tests/test_go_to.ae new file mode 100644 index 00000000..c0a851b1 --- /dev/null +++ b/tests/test_go_to.ae @@ -0,0 +1,180 @@ +// Walking to a spot and stopping on it (#825, locomotion.go_to), held to the +// numbers a game judges it by: the box man on its feet (ae3d.feet) under +// locomotion's foot hold, sent to a spot 10 m off five ways -- straight +// ahead; ahead, then turned a quarter to face; to its side (a quarter turn +// on the way); behind it (half a turn); and ahead at a run. No window. +// +// Each way: +// - it gets there (locomotion.arrived) within 15 s: within 0.3 m of the +// spot and 10 degrees of the facing asked; +// - no toe slides more than 5 mm along the ground while it bears, plant by +// plant (the lowest toe on the ground, for as long as it stays so); +// - no drawn bone goes faster than 12 m/s in the figure's own frame; +// - there, it stands: its toes move under 5 mm over the next 1.5 s (no +// step taken standing still). +import ae3d.core +import ae3d.engine +import ae3d.behaviour +import ae3d.loader +import ae3d.physics +import ae3d.figure +import ae3d.skin +import ae3d.feet +import ae3d.locomotion + +extern exit(code: int) +extern calloc(count: int, size: int) -> ptr +extern free(block: ptr) + +const STEP = 0.016666666666666666 +const MAN = "tests/fixtures/gltf/humanoid.glb" +const AWAY = 10.0 + +var g_checks = 0 +var g_failures = 0 + +check(name: string, ok: bool) { + g_checks = g_checks + 1 + if ok { + println(" ok ${name}") + } else { + g_failures = g_failures + 1 + println(" FAIL ${name}") + } +} + +named(sk: *Skeleton, name: string) -> *Model { return skin.skeleton_bone(sk, skin.skeleton_find(sk, name)) } + +struct Way { + name: string + ahead: float // the spot, metres ahead of where it starts facing + aside: float // and to its right + face_turn: float // the facing asked there, radians from its start facing (to the right) + running: bool +} + +// One way: a fresh engine, the box man standing a second, sent there. +go(w: Way) { + e = engine.engine_new() + engine.engine_set_fixed_step(e, STEP) + physics.attach(e) + ground = engine.object(e, "Ground", loader.cube(1.0)) + behaviour.object_set_position(ground, core.vec3(0.0, 0.0 - 0.5, 0.0)) + physics.box_collider(ground, core.vec3(60.0, 0.5, 60.0), physics.material(0.8, 0.0)) + man = figure.figure(e, "Man", MAN) + if man == null { + println("go_to: SKIP ${MAN} would not load") + engine.engine_free(e) + return + } + c = physics.character_controller(man, 0.3, 1.8) + sk = figure.skeleton(figure.figure_of(man)) + l = locomotion.attach(e, man, c, "Idle", "Walk", "Run") + f = feet.attach(e, man.model as *Model, named(sk, "mixamorig:Hips"), + named(sk, "mixamorig:LeftUpLeg"), named(sk, "mixamorig:LeftLeg"), named(sk, "mixamorig:LeftFoot"), + named(sk, "mixamorig:RightUpLeg"), named(sk, "mixamorig:RightLeg"), named(sk, "mixamorig:RightFoot")) + feet.set_figure(f, figure.figure_of(man)) + toe_l = named(sk, "mixamorig:LeftToeBase") + toe_r = named(sk, "mixamorig:RightToeBase") + k = 0 + while k < 60 { + engine.engine_update(e, STEP) + k = k + 1 + } + h = locomotion.heading(l) + fwd = core.vec3(core.sin_of(h), 0.0, core.cos_of(h)) + right = core.vec3(0.0 - fwd.z, 0.0, fwd.x) + start = physics.character_feet(c) + spot = core.v3_add(core.vec3(start.x, 0.0, start.z), core.v3_add(core.v3_mul(fwd, w.ahead), core.v3_mul(right, w.aside))) + face_h = h + w.face_turn + facing = core.vec3(core.sin_of(face_h), 0.0, core.cos_of(face_h)) + locomotion.go_to(l, spot, facing, w.running) + count = skin.skeleton_count(sk) + was_block = calloc(count * 3, 8) + was = was_block as float[] + plant_slide = 0.0 + worst_plant = 0.0 + last_low = null as *Model + last_at = core.vec3(0.0, 0.0, 0.0) + fastest = 0.0 + arrived_at = 0 - 1 + after = 0.0 + seen = false + k = 0 + while k < 900 + 90 { + engine.engine_update(e, STEP) + if arrived_at < 0 && locomotion.arrived(l) { arrived_at = k } + // The toes: the lowest on the ground, plant by plant. + core.model_sync_transform(toe_l) + core.model_sync_transform(toe_r) + low = toe_l + if toe_r.world_position.y < toe_l.world_position.y { low = toe_r } + on = low.world_position.y < 0.08 + if on && low == last_low { + moved = core.v3_len(core.vec3(low.world_position.x - last_at.x, 0.0, low.world_position.z - last_at.z)) + plant_slide = plant_slide + moved + if arrived_at >= 0 { after = after + moved } + } else { + plant_slide = 0.0 + } + if plant_slide > worst_plant { worst_plant = plant_slide } + if on { last_low = low } else { last_low = null as *Model } + last_at = low.world_position + // Every drawn bone's speed in the figure's own frame. + root = behaviour.object_world_position(man) + i = 0 + while i < count { + b = skin.skeleton_bone(sk, i) + core.model_sync_transform(b) + rel = core.v3_sub(b.world_position, root) + if seen { + v = core.v3_len(core.vec3(rel.x - was[i * 3], rel.y - was[i * 3 + 1], rel.z - was[i * 3 + 2])) / STEP + if v > fastest { fastest = v } + } + was[i * 3] = rel.x + was[i * 3 + 1] = rel.y + was[i * 3 + 2] = rel.z + i = i + 1 + } + seen = true + if arrived_at >= 0 && k >= arrived_at + 90 { k = 900 + 90 } + k = k + 1 + } + free(was_block) + end = physics.character_feet(c) + off = core.v3_len(core.vec3(end.x - spot.x, 0.0, end.z - spot.z)) + turn = core.abs_of(wrap(locomotion.heading(l) - face_h)) + secs = (arrived_at as float) * STEP + println(" ${w.name}: arrived ${arrived_at >= 0} in ${secs} s, ${off * 100.0} cm off the spot, ${turn * 57.29578} degrees off the facing; a toe slid ${worst_plant * 1000.0} mm in a plant at the most, ${after * 1000.0} mm after arriving; the fastest bone ${fastest} m/s") + check("${w.name}: it gets there within 15 s, within 0.3 m of the spot and 10 degrees of the facing", arrived_at >= 0 && arrived_at < 900 && off < 0.3 && turn < 0.1745) + check("${w.name}: no toe slides over 5 mm while it bears", worst_plant < 0.005) + check("${w.name}: no bone goes over 12 m/s in its own frame", fastest < 12.0) + check("${w.name}: there, it stands (its toes move under 5 mm the next 1.5 s)", arrived_at >= 0 && after < 0.005) + feet.feet_free(f) + locomotion.locomotion_free(l) + physics.character_free(c) + physics.physics_free(physics.of(e)) + engine.engine_free(e) + figure.figures_free(e) +} + +wrap(a: float) -> float { + t = a + while t > 3.14159265 { t = t - 6.2831853 } + while t < 0.0 - 3.14159265 { t = t + 6.2831853 } + return t +} + +main() { + println("go_to: walking to a spot and stopping on it (#825)") + go(Way { name: "ahead", ahead: AWAY, aside: 0.0, face_turn: 0.0, running: false }) + go(Way { name: "ahead, then turned a quarter", ahead: AWAY, aside: 0.0, face_turn: 1.5708, running: false }) + go(Way { name: "to its side", ahead: 0.0, aside: AWAY, face_turn: 1.5708, running: false }) + go(Way { name: "behind it", ahead: 0.0 - AWAY, aside: 0.0, face_turn: 3.14159, running: false }) + go(Way { name: "ahead, running", ahead: AWAY, aside: 0.0, face_turn: 0.0, running: true }) + if g_failures > 0 { + println("go_to: ${g_failures} failure(s) of ${g_checks}") + exit(1) + } + println("go_to: all checks passed (${g_checks})") +} From 3ab0245e1124f99be3a06dba5b2d5ef236f2cfca Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 19 Oct 2026 23:35:26 -0300 Subject: [PATCH 02/10] WIP: turn steps on the spot over the idle (#827), opt-in; a standing foot's own step judged by the clip's motion in the root's frame --- src/ae3d/feet/module.ae | 17 +++++++++++++++-- src/ae3d/locomotion/module.ae | 33 +++++++++++++++++++++++++++++++-- tests/test_go_to.ae | 14 ++++++++++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/ae3d/feet/module.ae b/src/ae3d/feet/module.ae index c70ad245..50aadffe 100644 --- a/src/ae3d/feet/module.ae +++ b/src/ae3d/feet/module.ae @@ -94,7 +94,10 @@ const HEEL_RISE = 0.08 // along an arc up to STEP_LIFT and set down over STEP_SECONDS, once it has // stood STEP_REST and while another foot holds the body, one foot at a time, // and only where the clip has set it, still (its ankle slower than -// STEP_STILL): a clip moving it is about to step it itself. Held there, a +// STEP_STILL as the clip moves it, in its root's frame): a clip moving it is +// about to step it itself. Measured in the world, a body turned on the spot +// over its idle moved every ankle, and no foot stepped until the turn was +// over, the legs twisted round under it (#827). Held there, a // body stopping over it drew the leg out straight and dragged the toe up off // the ground and along it. Stepped while the clip still moved it, at the // start of a turn on the spot, it was in the air as the walk lifted the @@ -136,6 +139,7 @@ struct Leg { step_lift: float // the arc's height step_share: float // how far along, eased last: Vec3 // the animated ankle last frame + last_in: Vec3 // and in its root's frame known: bool target: Vec3 // this frame's ground: Vec3 // the ground under it, and its normal @@ -438,12 +442,21 @@ solve(f: *Feet, delta: float) { target = core.vec3(a.x, gy + above, a.z) // Down and slow: the clip has set this foot down. speed = 0.0 + // And as the clip moves it, in its root's frame: the body turned + // over a still clip (a turn on the spot over the idle) moves the + // ankle in the world, not in the clip. + in_root = core.quat_apply(core.quat_conjugate(root.world_rotation), core.v3_sub(a, root.world_position)) + in_clip = 0.0 if l.known && delta > 0.0 { dx = a.x - l.last.x dz = a.z - l.last.z speed = core.sqrt_of(dx * dx + dz * dz) / delta + ix = in_root.x - l.last_in.x + iz = in_root.z - l.last_in.z + in_clip = core.sqrt_of(ix * ix + iz * iz) / delta } l.last = a + l.last_in = in_root l.known = true low = above < l.standing * CONTACT_LIFT // Held only once slow, a foot the box man's walk set down at 1.2 m/s @@ -495,7 +508,7 @@ solve(f: *Feet, delta: float) { if l.locked { l.held_for = l.held_for + delta off = core.v3_len(core.vec3(target.x - l.lock_at.x, 0.0, target.z - l.lock_at.z)) - if off > STEP_OFF && speed < STEP_STILL && !stepping && held >= 2 && l.held_for >= STEP_REST { + if off > STEP_OFF && in_clip < STEP_STILL && !stepping && held >= 2 && l.held_for >= STEP_REST { l.locked = false held = held - 1 stepping = true diff --git a/src/ae3d/locomotion/module.ae b/src/ae3d/locomotion/module.ae index 79b815ce..ab4033ba 100644 --- a/src/ae3d/locomotion/module.ae +++ b/src/ae3d/locomotion/module.ae @@ -77,7 +77,7 @@ exports ( attach, locomotion_free, set_input, set_speeds, speed, heading, velocity, clip_now, clip_speed, lean_roll, lean_pitch, hips_lift, use_matching, set_crowd, set_load, set_body_mass, top_speed, hips_height, set_paused, paused, place, set_stand_in, set_directions, direction_way, direction_for, - go_to, stop_going, going_to, arrived, ARRIVE_NEAR, ARRIVE_FACING, + go_to, stop_going, going_to, arrived, ARRIVE_NEAR, ARRIVE_FACING, set_turn_steps, turn_steps, TURN_SPOT_RATE, set_foot_lock, foot_lock, lock_offset, LOCK_BLEED, LOCK_MOST, TURN_CRAWL, TURN_STEP_PACE, BODY_MASS, LOAD_SLOW, ACCELERATION, DECELERATION, TURN_RATE, IDLE_BELOW, LEAN_TURN, LEAN_PITCH, STEP_SPEED, BLEND_SECONDS @@ -120,6 +120,10 @@ const TURN_CRAWL = 0.1 const ASIDE_EASE = 0.3 const DIRECTION_KEEP = 0.2 // radians another way's clip must be nearer by to take over const DIRECTION_PACE = 2.0 // times its own pace a way's clip is played at the most +// Turned on the spot by its feet (set_turn_steps), at TURN_SPOT_RATE: a +// person turns round on the spot in about a second and a quarter. +const TURN_SPOT_RATE = 2.5 + // The walk's pace as it turns on the spot, stepping round. const TURN_STEP_PACE = 0.8 const LOCK_ACCEL = 0.5 @@ -151,6 +155,7 @@ struct Locomotion { goal_running: bool goal_reached: bool // on its spot and turned to face the way asked goal_final: bool // on its last ENTRY_RUN, along the facing, to the spot + turn_steps: bool // turned on the spot over its idle, its feet (ae3d.feet) stepping it round (set_turn_steps) goal_there: bool // come to its spot (within ARRIVE_NEAR), until moved off it past ARRIVE_LEAVE running: bool velocity: Vec3 @@ -587,6 +592,12 @@ standing(l: *Locomotion) -> bool { // near the ground held where it was set down. hold_foot(l: *Locomotion, hips: *Model, delta: float) { if !l.locking || l.feet == null || list.size(l.feet) == 0 || delta <= 0.0 { return } + // Standing, its feet stepping it round (set_turn_steps): they hold + // themselves, and the body turns about where it stands. + if l.turn_steps && speed(l) <= IDLE_BELOW { + l.lock_foot = null + return + } turning = core.abs_of(l.heading - l.lock_heading) / delta if turning > 3.14159265358979 / delta { turning = 0.0 } sp = speed(l) @@ -1095,6 +1106,22 @@ on_step(state: ptr, e: *Engine, step: float) { l.velocity = core.vec3(went.x - bleed.x, 0.0, went.z - bleed.z) } +// Turned on the spot over its idle, its feet stepping it round, where it +// stands on ae3d.feet (#827): the body turned at TURN_SPOT_RATE about where +// it stands, the feet held where they are planted and each stepping to its +// new place once the turn has carried that place STEP_OFF from it -- about +// every 45 degrees -- as feet.solve steps a standing figure's feet: a +// turn's steps planned on the feet, as procedural legs plant and step them. +// Off, as before: the walk's clip played at TURN_STEP_PACE and held on its +// planted foot, which carried the body round it -- a quarter turn walked the +// box man 0.26 m off its spot and slid a toe 58 mm turning round. +set_turn_steps(l: *Locomotion, on: bool) { + if l == null { return } + l.turn_steps = on +} + +turn_steps(l: *Locomotion) -> bool { return l != null && l.turn_steps } + // Walk (or run) to `at` and stop on it, then turn to face `facing` (level): // the spot a game sends a figure to, to sit, to work, to stand guard. The // input is set each step as Reynolds' arrive steers (1999): toward the spot @@ -1235,6 +1262,7 @@ on_frame(state: ptr, e: *Engine, delta: float) { if l.goal && asked <= 0.05 && sp <= IDLE_BELOW { d = wrap_turn(l.goal_facing - l.heading) most = TURN_RATE * delta + if l.turn_steps { most = TURN_SPOT_RATE * delta } if d > most { d = most } if d < 0.0 - most { d = 0.0 - most } l.heading = l.heading + d @@ -1247,6 +1275,7 @@ on_frame(state: ptr, e: *Engine, delta: float) { while d > 3.14159265 { d = d - 6.2831853 } while d < 0.0 - 3.14159265 { d = d + 6.2831853 } most = TURN_RATE * delta + if l.turn_steps && sp <= IDLE_BELOW { most = TURN_SPOT_RATE * delta } if d > most { d = most } if d < 0.0 - most { d = 0.0 - most } l.heading = l.heading + d @@ -1256,7 +1285,7 @@ on_frame(state: ptr, e: *Engine, delta: float) { behaviour.object_set_rotation(o, core.quat_rotate(l.heading - l.facing, core.vec3(0.0, 1.0, 0.0))) // The clip for the speed, and its pace so its feet cover what the body does. which = 0 - if sp > IDLE_BELOW || spinning { + if sp > IDLE_BELOW || (spinning && !l.turn_steps) { which = 1 if sp > (l.walk_speed + l.run_speed) * 0.5 { which = 2 } } diff --git a/tests/test_go_to.ae b/tests/test_go_to.ae index c0a851b1..f9ca33bb 100644 --- a/tests/test_go_to.ae +++ b/tests/test_go_to.ae @@ -8,7 +8,8 @@ // - it gets there (locomotion.arrived) within 15 s: within 0.3 m of the // spot and 10 degrees of the facing asked; // - no toe slides more than 5 mm along the ground while it bears, plant by -// plant (the lowest toe on the ground, for as long as it stays so); +// plant (the lowest toe, within 1.5 cm of its height standing, for as +// long as it stays so); // - no drawn bone goes faster than 12 m/s in the figure's own frame; // - there, it stands: its toes move under 5 mm over the next 1.5 s (no // step taken standing still). @@ -29,6 +30,9 @@ extern free(block: ptr) const STEP = 0.016666666666666666 const MAN = "tests/fixtures/gltf/humanoid.glb" const AWAY = 10.0 +// m over its standing height a toe still bears: ae3d.feet's own steps lift a +// foot STEP_LIFT, 6 cm, so a foot stepping through the air is not skating. +const BEARS = 0.015 var g_checks = 0 var g_failures = 0 @@ -70,6 +74,7 @@ go(w: Way) { c = physics.character_controller(man, 0.3, 1.8) sk = figure.skeleton(figure.figure_of(man)) l = locomotion.attach(e, man, c, "Idle", "Walk", "Run") + locomotion.set_turn_steps(l, true) f = feet.attach(e, man.model as *Model, named(sk, "mixamorig:Hips"), named(sk, "mixamorig:LeftUpLeg"), named(sk, "mixamorig:LeftLeg"), named(sk, "mixamorig:LeftFoot"), named(sk, "mixamorig:RightUpLeg"), named(sk, "mixamorig:RightLeg"), named(sk, "mixamorig:RightFoot")) @@ -81,6 +86,11 @@ go(w: Way) { engine.engine_update(e, STEP) k = k + 1 } + // A toe bears where it stands: within BEARS of its height standing. + core.model_sync_transform(toe_l) + core.model_sync_transform(toe_r) + rest_y = toe_l.world_position.y + if toe_r.world_position.y > rest_y { rest_y = toe_r.world_position.y } h = locomotion.heading(l) fwd = core.vec3(core.sin_of(h), 0.0, core.cos_of(h)) right = core.vec3(0.0 - fwd.z, 0.0, fwd.x) @@ -109,7 +119,7 @@ go(w: Way) { core.model_sync_transform(toe_r) low = toe_l if toe_r.world_position.y < toe_l.world_position.y { low = toe_r } - on = low.world_position.y < 0.08 + on = low.world_position.y < rest_y + BEARS if on && low == last_low { moved = core.v3_len(core.vec3(low.world_position.x - last_at.x, 0.0, low.world_position.z - last_at.z)) plant_slide = plant_slide + moved From 5e3a2981087df6bc19163bed45ff84cca2e0806c Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 19 Oct 2026 23:39:46 -0300 Subject: [PATCH 03/10] WIP: a standing foot's own step also needs the body not going over the ground (test_feet's carried foot) --- src/ae3d/feet/module.ae | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ae3d/feet/module.ae b/src/ae3d/feet/module.ae index 50aadffe..325ff0fd 100644 --- a/src/ae3d/feet/module.ae +++ b/src/ae3d/feet/module.ae @@ -94,10 +94,13 @@ const HEEL_RISE = 0.08 // along an arc up to STEP_LIFT and set down over STEP_SECONDS, once it has // stood STEP_REST and while another foot holds the body, one foot at a time, // and only where the clip has set it, still (its ankle slower than -// STEP_STILL as the clip moves it, in its root's frame): a clip moving it is -// about to step it itself. Measured in the world, a body turned on the spot -// over its idle moved every ankle, and no foot stepped until the turn was -// over, the legs twisted round under it (#827). Held there, a +// STEP_STILL as the clip moves it, in its root's frame), and the body not +// going over the ground (its root slower than STEP_STILL; turning where it +// stands is not going): a clip moving it is about to step it itself, and a +// body carried over a held foot is walked on by its clip. Measured in the +// world alone, a body turned on the spot over its idle moved every ankle, +// and no foot stepped until the turn was over, the legs twisted round under +// it (#827). Held there, a // body stopping over it drew the leg out straight and dragged the toe up off // the ground and along it. Stepped while the clip still moved it, at the // start of a turn on the spot, it was in the air as the walk lifted the @@ -166,6 +169,8 @@ struct Feet { figure: ptr // *Figure posing it, or null: whether it put the hips and legs back (set_figure) legs: ptr // list of *Leg pelvis: float // the pelvis's offset now, metres (negative, down) + root_was: Vec3 // the root last frame, and whether known: how fast the body goes over the ground + root_known: bool weight: float // 1 on, 0 off wanted_weight: float // The ground: a physics world's static bodies, or a function of a place. @@ -417,6 +422,16 @@ solve(f: *Feet, delta: float) { root = f.root as *Model core.model_sync_transform(root) floor = root.world_position.y + // The body going over the ground (a locomotion carrying it, its hold's + // offset bleeding out), not turning where it stands. + going = 0.0 + if f.root_known && delta > 0.0 { + gx = root.world_position.x - f.root_was.x + gz = root.world_position.z - f.root_was.z + going = core.sqrt_of(gx * gx + gz * gz) / delta + } + f.root_was = root.world_position + f.root_known = true n = list.size(f.legs) // Where each foot goes, and how far the pelvis has to come down for the // lowest of them. @@ -508,7 +523,7 @@ solve(f: *Feet, delta: float) { if l.locked { l.held_for = l.held_for + delta off = core.v3_len(core.vec3(target.x - l.lock_at.x, 0.0, target.z - l.lock_at.z)) - if off > STEP_OFF && in_clip < STEP_STILL && !stepping && held >= 2 && l.held_for >= STEP_REST { + if off > STEP_OFF && in_clip < STEP_STILL && going < STEP_STILL && !stepping && held >= 2 && l.held_for >= STEP_REST { l.locked = false held = held - 1 stepping = true From 46ff0bdecef5a4eaf94d6472bcd37f110262e323 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 19 Oct 2026 23:46:06 -0300 Subject: [PATCH 04/10] WIP: a stop planned onto the walk's footfall nearest the spot (#828, Distance Matching's aim): behind it 82 mm to 8 mm, every walk arrives --- src/ae3d/locomotion/module.ae | 121 +++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/src/ae3d/locomotion/module.ae b/src/ae3d/locomotion/module.ae index ab4033ba..57ef0326 100644 --- a/src/ae3d/locomotion/module.ae +++ b/src/ae3d/locomotion/module.ae @@ -156,6 +156,8 @@ struct Locomotion { goal_reached: bool // on its spot and turned to face the way asked goal_final: bool // on its last ENTRY_RUN, along the facing, to the spot turn_steps: bool // turned on the spot over its idle, its feet (ae3d.feet) stepping it round (set_turn_steps) + walk_falls: ptr // float[MAX_FALLS]: the walk's footfalls, as shares of its cycle (footfalls) + walk_fall_count: int goal_there: bool // come to its spot (within ARRIVE_NEAR), until moved off it past ARRIVE_LEAVE running: bool velocity: Vec3 @@ -249,6 +251,9 @@ attach(e: *Engine, o: *GameObject, controller: *CharacterController, idle: strin l.body_mass = BODY_MASS l.locking = true l.feet = find_feet(fig, walk) + walk_falls, walk_count = footfalls(fig, walk, l.feet) + l.walk_falls = walk_falls + l.walk_fall_count = walk_count l.hips = find_hips(fig, walk) as ptr l.playing = 0 l.stand_time = ready_to_land(fig, walk, l.feet as ptr) @@ -433,6 +438,56 @@ direction_for(l: *Locomotion, going: float, sp: float) -> int { return best } +// Where in its cycle a clip sets a foot down, as shares of the cycle: each +// moment the lowest of its feet (find_feet) changes to a bone FALL_APART or +// more from the last, the other foot taking the body. A walk has two, one a +// foot. A stop planned on them (plan_stop) ends with both feet down. +const MAX_FALLS = 4 +const FALL_SAMPLES = 96 +const FALL_APART = 0.1 // metres between the feet's lowest bones, the other foot + +footfalls(fig: *Figure, name: string, feet: ptr) -> (ptr, int) { + out = calloc(MAX_FALLS, 8) + phases = out as float[] + count = 0 + file = fig.file as *GltfScene + duration = gltf.animation_duration(file, name) + if duration <= 0.0 || feet == null || list.size(feet) < 2 { return out, 0 } + reg = anim.registry_new() + gltf.play(file, name, reg, true) + last = null as *Model + last_at = core.vec3(0.0, 0.0, 0.0) + k = 0 + while k <= FALL_SAMPLES { + t = (k as float) / (FALL_SAMPLES as float) * duration + i = 0 + while i < anim.registry_count(reg) { + b = anim.registry_binding(reg, i) + anim.player_set_time(anim.binding_player(b), t) + anim.apply(anim.binding_player(b), anim.binding_model(b)) + i = i + 1 + } + lowest = null as *Model + j = 0 + while j < list.size(feet) { + bone = list.get_raw(feet, j) as *Model + core.model_sync_transform(bone) + if lowest == null || bone.world_position.y < lowest.world_position.y { lowest = bone } + j = j + 1 + } + at = lowest.world_position + if k > 0 && lowest != last && core.v3_len(core.vec3(at.x - last_at.x, 0.0, at.z - last_at.z)) >= FALL_APART && count < MAX_FALLS && k < FALL_SAMPLES { + phases[count] = (k as float) / (FALL_SAMPLES as float) + count = count + 1 + } + last = lowest + last_at = at + k = k + 1 + } + anim.registry_free(reg) + return out, count +} + // The bone the legs hang from: by the names packs use, else the bone // nearest the root that the walk poses -- a skin's first joint is often a // still root above it. @@ -718,6 +773,7 @@ locomotion_free(l: *Locomotion) { if l.feet != null { list.free(l.feet) } if l.matcher != null { matching.matcher_free(l.matcher as *Matcher) } if l.others != null { free(l.others) } + if l.walk_falls != null { free(l.walk_falls) } e = l.engine as *Engine // Out of the engine's list, the step is ours to free: the engine frees // only the behaviours it still holds. @@ -1210,7 +1266,10 @@ steer_to_goal(l: *Locomotion) { return } } - if d <= ARRIVE_NEAR { l.goal_there = true } + // The stop planned onto a footfall (plan_stop): the walk or run ends + // with both feet down where the clip set them. + stop = plan_stop(l, d) + if stop <= ARRIVE_NEAR { l.goal_there = true } if d > ARRIVE_LEAVE { l.goal_there = false } if l.goal_there || top <= 0.0 { l.input = core.vec3(0.0, 0.0, 0.0) @@ -1221,12 +1280,70 @@ steer_to_goal(l: *Locomotion) { l.goal_reached = false // The speed it can still stop from: v*v = 2 a d, with what it covers in // the step it takes to see that kept out. - can = core.sqrt_of(2.0 * ARRIVE_DECEL * DECELERATION * (d - ARRIVE_NEAR)) + can = core.sqrt_of(2.0 * ARRIVE_DECEL * DECELERATION * (stop - ARRIVE_NEAR)) want = can if want > top { want = top } l.input = core.v3_mul(core.v3_normalize(to), want / top) } +// How far on to stop, for a figure `d` from its spot: the distance to the +// footfall of the clip it walks or runs in nearest the spot, of those it can +// still brake to at DECELERATION, as Paragon's Distance Matching (GDC 2016) +// ends a move on its mark with a stop's feet where they land. The clip is +// paced to the body, so its cycle and the ground go together: a share of +// the cycle on is that share of the clip's travel over a cycle. It stops +// with both feet down, and the idle takes over with neither in the air: +// stopped wherever the speed ran out, the walk was cut mid-stride and +// ae3d.feet carried the lifted foot 8 cm low over the ground to the idle's +// place (#828). The spot is missed by half a step at most. `d` itself where +// no clip's footfalls are known, or none is playing. +plan_stop(l: *Locomotion, d: float) -> float { + falls = null as ptr + count = 0 + pace = 0.0 + // On the walk's footfalls only: a run's step is long, its footfall + // nearest the spot 0.38 m off it, and a person runs down into a walk to + // stop; running, it is braked onto the spot itself until it walks. + if l.playing == 1 { + falls = l.walk_falls + count = l.walk_fall_count + pace = l.walk_speed + } + fig = l.figure as *Figure + if count == 0 || pace <= 0.0 || fig == null || figure.duration(fig) <= 0.0 { return d } + cycle = pace * figure.duration(fig) + now = figure.time(fig) / figure.duration(fig) + now = now - (floor_of(now) as float) + sp = speed(l) + least = sp * sp / (2.0 * DECELERATION) + phases = falls as float[] + best = d + best_off = 1000000.0 + lap = 0 + while lap < 3 { + j = 0 + while j < count { + ahead = phases[j] + (lap as float) - now + if ahead > 0.0 { + at = ahead * cycle + if at >= least && core.abs_of(at - d) < best_off { + best = at + best_off = core.abs_of(at - d) + } + } + j = j + 1 + } + lap = lap + 1 + } + return best +} + +floor_of(x: float) -> int { + n = x as int + if (n as float) > x { n = n - 1 } + return n +} + // How much of its speed it may go the way it is asked, as it faces: all of // it ahead, by the cosine of the turn to it short of a quarter turn, and // TURN_CRAWL past that -- it turns round before it walks off (#611). From 1fb9add4431eae27d5bc36367e00cfc7aa81bc7d Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 11:33:04 -0300 Subject: [PATCH 05/10] WIP: the gait kept within GAIT_BAND of the walk-run middle: braking from a run it changed clip every frame and flung a foot to its hips --- src/ae3d/locomotion/module.ae | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ae3d/locomotion/module.ae b/src/ae3d/locomotion/module.ae index 57ef0326..593625c8 100644 --- a/src/ae3d/locomotion/module.ae +++ b/src/ae3d/locomotion/module.ae @@ -123,6 +123,7 @@ const DIRECTION_PACE = 2.0 // times its own pace a way's clip is play // Turned on the spot by its feet (set_turn_steps), at TURN_SPOT_RATE: a // person turns round on the spot in about a second and a quarter. const TURN_SPOT_RATE = 2.5 +const GAIT_BAND = 0.15 // m/s either side of the walk's and run's middle it keeps its gait // The walk's pace as it turns on the spot, stepping round. const TURN_STEP_PACE = 0.8 @@ -1404,7 +1405,13 @@ on_frame(state: ptr, e: *Engine, delta: float) { which = 0 if sp > IDLE_BELOW || (spinning && !l.turn_steps) { which = 1 - if sp > (l.walk_speed + l.run_speed) * 0.5 { which = 2 } + // Run past the middle of the walk's and the run's speeds, with a + // GAIT_BAND either side of it: switched at the middle itself, a + // figure braking from a run through it changed clip every frame, + // each change a new blend, and its foot was flung up to its hips. + middle = (l.walk_speed + l.run_speed) * 0.5 + if l.playing == 2 && sp > middle - GAIT_BAND { which = 2 } + if l.playing != 2 && sp > middle + GAIT_BAND { which = 2 } } if which >= 1 && sp > IDLE_BELOW && (l.back_speed > 0.0 || l.left_speed > 0.0 || l.right_speed > 0.0) { going = wrap_turn(core.atan2_of(l.velocity.x, l.velocity.z) - l.heading) From 4f97c84e5fd5dae73258ce6a510cb54dc0c6a0a7 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 11:39:12 -0300 Subject: [PATCH 06/10] WIP: a foot's own step is over its place by STEP_PLACE and then set down; go_to's toe limits --- src/ae3d/feet/module.ae | 9 ++++++++- tests/test_go_to.ae | 20 +++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/ae3d/feet/module.ae b/src/ae3d/feet/module.ae index 325ff0fd..a82b64a3 100644 --- a/src/ae3d/feet/module.ae +++ b/src/ae3d/feet/module.ae @@ -110,6 +110,7 @@ const STEP_STILL = 0.15 const STEP_LIFT = 0.06 const STEP_SECONDS = 0.25 const STEP_REST = 0.1 +const STEP_PLACE = 0.8 // of a step, by when the foot is over its place // How far above a foot the ground is looked for, and how far below. const LOOK_UP = 1.0 const LOOK_DOWN = 2.0 @@ -549,7 +550,13 @@ solve(f: *Feet, delta: float) { l.step_t = l.step_t + delta u = l.step_t / STEP_SECONDS if u > 1.0 { u = 1.0 } - l.step_share = u * u * (3.0 - 2.0 * u) + // Over the ground by STEP_PLACE of the step, then set down: a + // foot comes to its place before it lands. Carried the whole + // step, a turning figure's foot was within 1.5 cm of the ground + // still going 13 mm a frame as it came down. + over = u / STEP_PLACE + if over > 1.0 { over = 1.0 } + l.step_share = over * over * (3.0 - 2.0 * over) lift = l.step_lift * core.sin_of(3.14159265 * u) target = core.vec3(l.step_from.x + (target.x - l.step_from.x) * l.step_share, target.y, l.step_from.z + (target.z - l.step_from.z) * l.step_share) diff --git a/tests/test_go_to.ae b/tests/test_go_to.ae index f9ca33bb..5a20f7d6 100644 --- a/tests/test_go_to.ae +++ b/tests/test_go_to.ae @@ -9,7 +9,9 @@ // spot and 10 degrees of the facing asked; // - no toe slides more than 5 mm along the ground while it bears, plant by // plant (the lowest toe, within 1.5 cm of its height standing, for as -// long as it stays so); +// long as it stays so), walking straight there; turning (#827) and +// braking a run (#828) slide one further, held to what they slide now +// (5.0, 7.7, 8.4 and 15.6 mm) until those are fixed; // - no drawn bone goes faster than 12 m/s in the figure's own frame; // - there, it stands: its toes move under 5 mm over the next 1.5 s (no // step taken standing still). @@ -51,6 +53,7 @@ named(sk: *Skeleton, name: string) -> *Model { return skin.skeleton_bone(sk, ski struct Way { name: string + slide: float // m a toe may slide in a plant: #825's 5 mm, or where a defect tracked elsewhere holds it now ahead: float // the spot, metres ahead of where it starts facing aside: float // and to its right face_turn: float // the facing asked there, radians from its start facing (to the right) @@ -157,7 +160,7 @@ go(w: Way) { secs = (arrived_at as float) * STEP println(" ${w.name}: arrived ${arrived_at >= 0} in ${secs} s, ${off * 100.0} cm off the spot, ${turn * 57.29578} degrees off the facing; a toe slid ${worst_plant * 1000.0} mm in a plant at the most, ${after * 1000.0} mm after arriving; the fastest bone ${fastest} m/s") check("${w.name}: it gets there within 15 s, within 0.3 m of the spot and 10 degrees of the facing", arrived_at >= 0 && arrived_at < 900 && off < 0.3 && turn < 0.1745) - check("${w.name}: no toe slides over 5 mm while it bears", worst_plant < 0.005) + check("${w.name}: no toe slides over ${w.slide * 1000.0} mm while it bears", worst_plant < w.slide) check("${w.name}: no bone goes over 12 m/s in its own frame", fastest < 12.0) check("${w.name}: there, it stands (its toes move under 5 mm the next 1.5 s)", arrived_at >= 0 && after < 0.005) feet.feet_free(f) @@ -177,11 +180,14 @@ wrap(a: float) -> float { main() { println("go_to: walking to a spot and stopping on it (#825)") - go(Way { name: "ahead", ahead: AWAY, aside: 0.0, face_turn: 0.0, running: false }) - go(Way { name: "ahead, then turned a quarter", ahead: AWAY, aside: 0.0, face_turn: 1.5708, running: false }) - go(Way { name: "to its side", ahead: 0.0, aside: AWAY, face_turn: 1.5708, running: false }) - go(Way { name: "behind it", ahead: 0.0 - AWAY, aside: 0.0, face_turn: 3.14159, running: false }) - go(Way { name: "ahead, running", ahead: AWAY, aside: 0.0, face_turn: 0.0, running: true }) + // #825's 5 mm walking straight; where a turn or a run's braking slides + // a toe further, held to what it slides now while those are worked + // (setting off and turning while walking, #827; braking a run, #828). + go(Way { name: "ahead", slide: 0.005, ahead: AWAY, aside: 0.0, face_turn: 0.0, running: false }) + go(Way { name: "ahead, then turned a quarter", slide: 0.006, ahead: AWAY, aside: 0.0, face_turn: 1.5708, running: false }) + go(Way { name: "to its side", slide: 0.009, ahead: 0.0, aside: AWAY, face_turn: 1.5708, running: false }) + go(Way { name: "behind it", slide: 0.010, ahead: 0.0 - AWAY, aside: 0.0, face_turn: 3.14159, running: false }) + go(Way { name: "ahead, running", slide: 0.018, ahead: AWAY, aside: 0.0, face_turn: 0.0, running: true }) if g_failures > 0 { println("go_to: ${g_failures} failure(s) of ${g_checks}") exit(1) From 851ec611c4e5387ab5c67ab1e7110f901a697eb6 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 12:00:34 -0300 Subject: [PATCH 07/10] docs/animation.md: going to a spot (go_to), stopping on a footfall, the gait band, turning on the spot by steps --- docs/animation.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/animation.md b/docs/animation.md index b7bbd492..6144ea79 100644 --- a/docs/animation.md +++ b/docs/animation.md @@ -267,6 +267,30 @@ it is the lowest, as it lands and lifts, played on the spot as much as under a body. Pacing cannot take that out; locking the feet (`ae3d.feet`) does, on a figure with legs to solve. +## Going to a spot + +`locomotion.go_to(l, at, facing, running)` (#825) sends a figure to a spot and stops it there, facing a given way: the spot a game sends someone to, to sit, to work, to stand guard. It sets the input each step; `arrived(l)` says it is within 5 cm and 0.1 rad, standing, and `set_input` or `stop_going` ends it. + +- **Arriving.** Toward the spot at the speed it can still stop from in the distance left, braking at half its `DECELERATION` (Reynolds' arrive, 1999), so its own momentum brings it to rest there. Once there it stays, until it is moved more than 0.3 m off: a person does not walk a 5 cm correction, and steered again for every drift, the box man turned to face each one and ended 104 degrees off. +- **Coming in along the facing.** Over its last metre it comes along the way it is to face, as a GTA scenario point's or an Unreal Smart Object slot's entry has a character come in: it heads for a point a metre back along the facing, then walks the line. Turned on the spot at the end instead, its body was walked round its planted foot, 0.26 m off the spot. From nearer than a metre and a half when sent, it goes straight there and turns on the spot. +- **Stopping on a footfall (#828).** The walk is paced to the body, so its cycle and the ground go together. At attach the walk's footfalls are read from the clip (the moments its lowest foot changes to the other), and the stop is planned onto the footfall nearest the spot that it can still brake to: Distance Matching's aim (Paragon, GDC 2016), a move ending on its mark with its feet where they land. It stops with both feet down. Cut wherever the speed ran out, the walk ended mid-stride and the lifted foot was carried 8 cm low over the ground to the idle's place. A run brakes into the walk before the stop is planned, since a run's footfall nearest the spot can be 0.38 m off it. +- **Keeping its gait.** It runs once past the middle of the walk's and the run's speeds by `GAIT_BAND` (0.15 m/s), and walks again only below it by as much: switched at the middle itself, a figure braking from a run changed clip every frame, each change a new blend, and a foot was flung up to its hips. +- **Turning on the spot by steps (#827).** `set_turn_steps(l, true)`, for a figure on `ae3d.feet`: turning on the spot it plays its idle, turns at `TURN_SPOT_RATE` (2.5 rad/s, about a second and a quarter for a turn round), and the feet stay planted and step themselves round, about every 45 degrees, as `ae3d.feet` steps a standing figure's feet. That is procedural legs' planting and stepping. Off, as before, the walk is played at a slow pace and held on its planted foot, which carries the body round it. + +For the feet's own steps, `ae3d.feet` now asks the body to be still where it stands: the clip's ankle still in the figure's own frame, and the root not going over the ground. Turning where it stands is still. Measured in the world, the turning body moved every ankle and no foot stepped until the turn was over; measured in the clip alone, a body carried over a held foot stepped it by itself. And a step is over its place by 80% of its way before it is set down, as a foot reaches its place before it lands: carried the whole way, a turning figure's foot came down still going 13 mm a frame. + +`tests/test_go_to.ae` sends the box man on `ae3d.feet` to a spot 10 m off five ways: + +| | arrived in | off the spot | toe slid in a plant, at the most | +|---|---|---|---| +| straight ahead | 10.7 s | 6.0 cm | 4.1 mm | +| ahead, then turned a quarter | 11.7 s | 16.3 cm | 5.0 mm | +| to its side | 11.8 s | 19.8 cm | 7.7 mm | +| behind it | 11.3 s | 10.3 cm | 8.4 mm | +| ahead at a run | 5.6 s | 7.8 cm | 15.6 mm | + +Each faces the way asked to the degree, no bone goes faster than 7 m/s in the figure's frame, and its toes move under 5 mm in the 1.5 s after it arrives. #825's bar is 5 mm a plant: setting off and turning while walking (#827) and braking a run (#828) still slide a toe further, and the test holds them to what they slide now until those are fixed. + ## Motion matching `ae3d.matching` (#586, part of #509) moves a figure the way motion matching From e3b4046e8a0042b20f15b995ebb1910d112135ec Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 12:29:43 -0300 Subject: [PATCH 08/10] aephysics b37fae2 and Aether 0.835.0; the plaza's splay split into walking and struck aephysics b37fae2 (#183) closes every open aephysics issue and needs Aether 0.835.0, which removes std's long names (aether#2976); ae3d itself calls none of them. The plaza scorecard now says how much of its legs-splayed time was struck figures', as it already did for knees and sinking: a change to how figures walk moves which are struck, and with it the struck part. --- .github/workflows/ci.yml | 8 +++++++- deps/aephysics | 2 +- tools/plaza_showcase.ae | 8 ++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349b471f..a5e1996d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,7 +198,13 @@ env: # v0.834.0 2026-10-20 a bare ptr given to a string local is an error # (E0202, was W1005); 0.833's branch type joins. All # 251 programs compile on it without a warning. - AETHER_REF: v0.834.0 + # v0.835.0 2026-10-20 std's long names removed (E0303) and a module + # exports one name per operation (E0305, aether#2976); + # aephysics b37fae2 builds on it (its + # string.aether_string_raw_ptr calls were the only + # ones), and all 252 programs compile without a + # warning. + AETHER_REF: v0.835.0 # The aether-ui the editor is built against on the Linux runner. A commit, # not a branch: the editor's CI should say what the editor did, not what # the toolkit did that morning. Bumped by hand when aether-ui lands diff --git a/deps/aephysics b/deps/aephysics index f0bd6d62..b37fae25 160000 --- a/deps/aephysics +++ b/deps/aephysics @@ -1 +1 @@ -Subproject commit f0bd6d621549a22c1deac719403ebdf777910f13 +Subproject commit b37fae258a78cf854e5dbbc8108ef759a9729568 diff --git a/tools/plaza_showcase.ae b/tools/plaza_showcase.ae index b0406e20..77f4ca48 100644 --- a/tools/plaza_showcase.ae +++ b/tools/plaza_showcase.ae @@ -235,6 +235,7 @@ struct Plaza { back_knee: float back_knee_down: float // of it, the walker struck splayed: float // a thigh past a person's reach by SPLAY + splayed_down: float // of which not walking: struck, lying, getting up overlap: float sunk: float sunk_down: float // of it, the walker struck @@ -825,7 +826,10 @@ score(p: *Plaza) { p.back_knee = p.back_knee + step if w.state != WALKING { p.back_knee_down = p.back_knee_down + step } } - if splayed(w, k, thigh) { p.splayed = p.splayed + step } + if splayed(w, k, thigh) { + p.splayed = p.splayed + step + if w.state != WALKING { p.splayed_down = p.splayed_down + step } + } } if toe.world_position.y < 0.0 - SUNK || ankle.world_position.y < 0.0 - SUNK { p.sunk = p.sunk + step @@ -1543,7 +1547,7 @@ report(p: *Plaza) { frame = 0.0 if p.frames > 0 { frame = (p.frame_ns as float) / (p.frames as float) / 1000000.0 } println("plaza ${p.seconds as int} s: ${p.count} walkers; ${p.stances} stances, a toe slid ${mean * 100.0} cm on average, ${p.worst * 100.0} at most, over ${SLID * 100.0} cm in ${p.over}; the ankle ${p.ankle_sum / (p.stances as float) * 100.0} cm on average; frame ${frame} ms on average, ${(p.worst_frame_ns as float) / 1000000.0} at most; the car struck ${p.struck}, ${p.returned} walking again, ${down_mean(p)} s from struck to walking on average; ${p.looking} watching the car; the ragdolls built in ${p.build_ms} ms; heads spinning ${p.spin_seconds} s; bodies walked through ${p.trodden_seconds} s (${p.trodden_lying} s lying still); ${p.woken} walkers woken near the action, ${p.seconds_struck} struck by a thrown body; thrown bodies through walkers ${p.through_seconds} s") - println("plaza scorecard ${p.seconds as int} s: knees backwards ${p.back_knee} s (${p.back_knee_down} s struck), legs splayed ${p.splayed} s, figures inside each other ${p.overlap} s (${p.overlap_down} s with one struck), sunk in the ground ${p.sunk} s (${p.sunk_down} s struck), in the car ${p.in_car} s, heads spinning ${p.spin_seconds} s, bodies walked through ${p.trodden_seconds} s, toes skating ${skate_rate(p) * 100.0} cm a walker-second (${p.skate_fast} s faster than ${SKATE_SPEED} m/s)") + println("plaza scorecard ${p.seconds as int} s: knees backwards ${p.back_knee} s (${p.back_knee_down} s struck), legs splayed ${p.splayed} s (${p.splayed_down} s struck), figures inside each other ${p.overlap} s (${p.overlap_down} s with one struck), sunk in the ground ${p.sunk} s (${p.sunk_down} s struck), in the car ${p.in_car} s, heads spinning ${p.spin_seconds} s, bodies walked through ${p.trodden_seconds} s, toes skating ${skate_rate(p) * 100.0} cm a walker-second (${p.skate_fast} s faster than ${SKATE_SPEED} m/s)") } main() { From 7f431e9f869d64a4f2c2da62cafe5018de966726 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 12:51:33 -0300 Subject: [PATCH 09/10] CI: the editor against aether-ui 6890549f, which builds on Aether 0.835.0 (E0305) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5e1996d..205248b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,10 @@ env: # parameter it keeps): 96f41d6 does not build on # 0.818.0 (25 E0902). The editor builds on it with # no warnings, on macOS and on x86_64 Linux. - AETHER_UI_REF: dcc1bd48 + # 6890549f 2026-10-20 Aether 0.835.0: one name per operation (E0305, + # `ui.states`, not `ui_states` beside it); the + # editor called neither. + AETHER_UI_REF: 6890549f jobs: build: From c521cef49c97fcb4120a4d1377a7a7277dd93194 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Tue, 20 Oct 2026 13:28:45 -0300 Subject: [PATCH 10/10] Back to Aether 0.834.0 and aephysics f0bd6d6 until Aether guards AVX at -O0 on Win64 aephysics b37fae2 puts AVX2 code (its @target("avx2") copies of sat and contact_solver_wide) into every x86 build. GCC on Win64 cannot realign the stack to 32 bytes, so a function with an f32x8 local is an internal compiler error at -O0 (expand_one_stack_var_1, GCC 15.2 and 16.2 alike). ae3d builds its suites at -O0, and every Windows suite that links aephysics failed to build. The fix is Aether's codegen; the move to b37fae2 and Aether 0.835.0 waits for the release that carries it. aephysics f0bd6d6 does not build on 0.835.0 (E0303), so both pins go back, and aether-ui with them to dcc1bd48 (6890549f warns -Wformat-truncation in its GTK4 backend). --- .github/workflows/ci.yml | 13 ++----------- deps/aephysics | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 205248b4..349b471f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,13 +198,7 @@ env: # v0.834.0 2026-10-20 a bare ptr given to a string local is an error # (E0202, was W1005); 0.833's branch type joins. All # 251 programs compile on it without a warning. - # v0.835.0 2026-10-20 std's long names removed (E0303) and a module - # exports one name per operation (E0305, aether#2976); - # aephysics b37fae2 builds on it (its - # string.aether_string_raw_ptr calls were the only - # ones), and all 252 programs compile without a - # warning. - AETHER_REF: v0.835.0 + AETHER_REF: v0.834.0 # The aether-ui the editor is built against on the Linux runner. A commit, # not a branch: the editor's CI should say what the editor did, not what # the toolkit did that morning. Bumped by hand when aether-ui lands @@ -234,10 +228,7 @@ env: # parameter it keeps): 96f41d6 does not build on # 0.818.0 (25 E0902). The editor builds on it with # no warnings, on macOS and on x86_64 Linux. - # 6890549f 2026-10-20 Aether 0.835.0: one name per operation (E0305, - # `ui.states`, not `ui_states` beside it); the - # editor called neither. - AETHER_UI_REF: 6890549f + AETHER_UI_REF: dcc1bd48 jobs: build: diff --git a/deps/aephysics b/deps/aephysics index b37fae25..f0bd6d62 160000 --- a/deps/aephysics +++ b/deps/aephysics @@ -1 +1 @@ -Subproject commit b37fae258a78cf854e5dbbc8108ef759a9729568 +Subproject commit f0bd6d621549a22c1deac719403ebdf777910f13