diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 450e15ae..87cc8d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,7 +433,13 @@ jobs: # on #876-#889, and #888's first run also stopped at 25. 40 holds a cold # Linux shard; a warm one is unaffected. This change starts a run on main # that saves its cache. - timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 40 }} + # 60 on Linux too (#905): after #891, a merge that touched most of the + # engine, main's first Linux runs on the 26.04 image built cold and were + # cut off at 34 in Run CI (run 41279330619, linux and linux-2), each + # leaving a rerun to finish what it had started. A cut-off run and its + # rerun cost more than one run let finish, and a warm shard (24 min) + # never comes near the limit. + timeout-minutes: 60 defaults: run: @@ -618,7 +624,7 @@ jobs: # Stopped six minutes inside the job's own limit, so that a cold run cut # off here still saves what it compiled (the step below). - name: Run CI - timeout-minutes: ${{ matrix.os == 'macos-latest' && 54 || 34 }} + timeout-minutes: 54 run: | # PATH is set here rather than through GITHUB_PATH. setup-msys2 # defaults to `path-type: minimal`, so the MSYS2 shell does not diff --git a/src/ae3d/animator/module.ae b/src/ae3d/animator/module.ae index 767dd25a..d7c071e3 100644 --- a/src/ae3d/animator/module.ae +++ b/src/ae3d/animator/module.ae @@ -45,7 +45,7 @@ exports ( ANY, ABOVE, BELOW, IS, IS_NOT, TRIGGERED, ENDED, set_float, set_bool, trigger, float_param, add_transition, transition_when, transition_crossfade, bind, bind2, snapshot, add_aim, aim_add, set_aim, set_time, clock_by, transition_synced, transition_or, STATE_MATCH, add_matcher, matcher_add, matcher_build, match_trajectory, matcher_searches, matcher_changes, - attach, animator_free, rig, take, add_take, add_state, add_blend, blend_add, set_param, param, play, play_at, play_synced, phase, + attach, animator_free, rig, take, add_take, share_take, pause, paused, resume, add_state, add_blend, blend_add, set_param, param, play, play_at, play_synced, phase, current, current_take, state_name, find_state, state_time, set_rate, rate, transitioning, set_root_motion, root_moved, ground_speed, add_layer, layer_weight, layer_restart, layer_time, set_feet, feet, set_hands, hands, look_at, look_off, aim_at, aim_off, trunk_aim, set_body, body, evaluate, pose_now, contact_weight, plant_weight, figure_object, update_now, @@ -161,6 +161,8 @@ struct Animator { dt_before: float evaluated: bool stepper: ptr // the behaviour + paused: bool // handed to another writer (pause): nothing written + drawn: bool // a pose read back from the figure while paused fired_takes: ptr // ptr[]: the take of each event fired this frame fired_at: ptr // int[]: and its index in the take fired_count: int @@ -273,6 +275,13 @@ attach(e: *Engine, o: *GameObject) -> *Animator { animator_free(a: *Animator) { if a == null { return } + // Out of the engine's loop first: freed while the engine ran, the pose + // phase went on calling it on freed memory (Siltreach, 2026-11-01). + if a.stepper != null { + engine.engine_remove_behaviour(a.engine as *Engine, a.stepper as *Behaviour) + heap.free(a.stepper as *Behaviour) + a.stepper = null + } i = 0 while i < list.size(a.states) { s = list.get_raw(a.states, i) as *State @@ -1647,6 +1656,17 @@ animator_pose(state: ptr, e: *Engine) { // update_now after engine_update for what the frame drew. evaluate(a: *Animator, dt: float) { r = a.rig as *pose.Rig + // Paused: the figure is another writer's (pause). Nothing is written; + // what that writer drew is read back, the last two frames kept, so + // resume eases out of it at the speed it was moving. + if a.paused { + pose.copy(r, a.out, a.before) + pose.read(r, a.out) + if !a.drawn { pose.copy(r, a.out, a.before) } + a.drawn = true + if dt > 0.0 { a.dt_before = dt } + return + } apply_bindings(a) if a.evaluated { take_transitions(a) } s = state_at(a, a.current) @@ -1698,6 +1718,46 @@ evaluate(a: *Animator, dt: float) { update_now(a: *Animator, dt: float) { evaluate(a, dt) } +// The figure handed to another writer for a while (motion's sit and get-up, +// a cutscene, a ragdoll): the animator writes none of its joints until +// resume, and keeps reading what the other writer draws. +pause(a: *Animator) { + if a == null || a.paused { return } + a.paused = true + a.drawn = false +} + +paused(a: *Animator) -> bool { return a != null && a.paused } + +// The figure taken back after pause: the graph's pose eased in over +// `seconds` from the pose the other writer drew last, inertialized as a +// transition is (each joint's offset and the speed it was changing at +// decayed to nothing), so taking the figure back does not snap it. The feet +// let go of what they held before the pause: the figure has moved since. +resume(a: *Animator, seconds: float) { + if a == null || !a.paused { return } + a.paused = false + if a.feet != null { ikrig.feet_reset(a.feet as *Feet) } + s = state_at(a, a.current) + if s == null || !a.drawn || seconds <= 0.0 { return } + sample_state(a, s, a.work) + inertialize(a, a.out, a.before, a.work, seconds) +} + +// A take its owner keeps, used by this animator too and not freed with it. +// Retargeted once for a rig, one take serves every figure on that rig: a +// camp's settlers each retargeting eight captures per walk paid for its own +// copy every time. Null when the take was made for a rig of another size. +// Its owner frees it after every animator using it. Root motion taken out +// when `root`, once for all of them. +share_take(a: *Animator, t: *Take, root: bool) -> *Take { + if a == null || t == null { return null } + r = a.rig as *pose.Rig + if t.count != pose.count(r) { return null } + if root && !clips.has_root_motion(t) { clips.extract_root(t, r, clips.up_of(r)) } + return t +} + // The pose drawn this frame. pose_now(a: *Animator) -> ptr { return a.out } diff --git a/src/ae3d/engine/module.ae b/src/ae3d/engine/module.ae index c91c2745..bb51f3be 100644 --- a/src/ae3d/engine/module.ae +++ b/src/ae3d/engine/module.ae @@ -156,6 +156,9 @@ struct Engine { // What takes part in the loop: every Behaviour added to the engine, in // the order added. The loop calls their phases; nothing else does. behaviours: ptr + // A behaviour removed while a phase walks the list leaves a hole (null), + // closed up at the start of the next frame (compact_behaviours). + behaviour_holes: bool // The engine's scene of game objects (a *Scene), run as a behaviour, // and the models made before the backend was up, added at start. scene: ptr @@ -389,23 +392,68 @@ engine_add_behaviour(e: *Engine, b: *Behaviour) { list.add_raw(e.behaviours, b as ptr) } +// Removed from the loop. Its slot is emptied rather than taken out: a +// behaviour removed from inside a phase (an animator freed in a game's +// update) shifted the ones after it under the loop walking them, so the +// next one was skipped that frame and the last read past the list. The +// holes close at the start of the next frame. engine_remove_behaviour(e: *Engine, b: *Behaviour) { count = list.size(e.behaviours) i = 0 while i < count { if list.get_raw(e.behaviours, i) == b as ptr { - list.remove(e.behaviours, i) + list.set(e.behaviours, i, null) + e.behaviour_holes = true return } i = i + 1 } } -engine_behaviour_count(e: *Engine) -> int { return list.size(e.behaviours) } +// The behaviours with the holes removals left closed up; between frames +// only, never while a phase walks the list. +compact_behaviours(e: *Engine) { + if !e.behaviour_holes || e.behaviours == null { return } + kept = list.new() + count = list.size(e.behaviours) + i = 0 + while i < count { + b = list.get_raw(e.behaviours, i) + if b != null { list.add_raw(kept, b) } + i = i + 1 + } + list.free(e.behaviours) + e.behaviours = kept + e.behaviour_holes = false +} + +// The behaviours taking part, and the `index`th of them: a removed one is +// gone from both at once, before its hole is closed up. +engine_behaviour_count(e: *Engine) -> int { + count = list.size(e.behaviours) + live = 0 + i = 0 + while i < count { + if list.get_raw(e.behaviours, i) != null { live = live + 1 } + i = i + 1 + } + return live +} engine_behaviour(e: *Engine, index: int) -> *Behaviour { - if index < 0 || index >= list.size(e.behaviours) { return null } - return list.get_raw(e.behaviours, index) as *Behaviour + if index < 0 { return null } + count = list.size(e.behaviours) + seen = 0 + i = 0 + while i < count { + b = list.get_raw(e.behaviours, i) + if b != null { + if seen == index { return b as *Behaviour } + seen = seen + 1 + } + i = i + 1 + } + return null } // A scene of game objects and components takes part in the loop as one @@ -1056,6 +1104,7 @@ engine_over(renderer: @keep ptr, api: int, camera: *Camera, light: *Light) -> *E // phase, the way the loop runs them -- so physics steps under the editor // as it steps under a program. engine_update(e: *Engine, delta: float) { + compact_behaviours(e) // The owner's renders since the last update drew what it asked for; // this one asks again. if e.overlay != null { hud.clear(e.overlay as *Hud) } @@ -3518,6 +3567,7 @@ engine_loop(e: *Engine) { // let go, the way every game loop does. if delta > MAX_FRAME_SECONDS { delta = MAX_FRAME_SECONDS } if e.tick > 0.0 { delta = e.tick } + compact_behaviours(e) // A held frame still draws, so the window stays alive and the scene can // be queried and snapshotted; it just does not advance. Time stops and diff --git a/src/ae3d/ikrig/module.ae b/src/ae3d/ikrig/module.ae index 017491d7..c387e4ab 100644 --- a/src/ae3d/ikrig/module.ae +++ b/src/ae3d/ikrig/module.ae @@ -35,7 +35,7 @@ import ae3d.ik exports ( Feet, Look, RELEASE, LOCK_SLIP, feet_new, feet_free, feet_ground_physics, feet_set_lock, feet_set_weight, feet_solve, toe_local, - feet_set_steps, feet_steps, feet_stepping, feet_set_stride, + feet_set_steps, feet_steps, feet_stepping, feet_set_stride, feet_reset, feet_locked, feet_drop, feet_target, feet_soles, toe_tip, soft_reach, Limb, limb_new, limb_free, leg_limb, arm_limb, limb_solve, limb_bend, limb_side, limb_end_twist, KNEE_MOST, ELBOW_MOST, ARM_TWIST, look_new, look_free, look_set, look_solve, look_weight, aim_chain, @@ -1896,6 +1896,27 @@ body_pull_from(b: *Body, worlds: ptr, goals: float[], from: int, to: int, moved: // and dragged them when the lock let go. feet_set_steps(f: *Feet, on: bool) { if f != null { f.steps = on } } +// The feet let go of everything they hold (their locks, a step under way, +// the turns held on them) and take the next frame's pose as it comes. For +// a figure handed back from another writer (animator.resume): its locks +// are where its feet stood before, and held to them its legs snapped back. +// The ground's height under each foot is kept, so the feet follow it on +// from there. +feet_reset(f: *Feet) { + if f == null { return } + k = 0 + while k < 2 { + (f.locked as float[])[k] = 0.0 + (f.release as float[])[k] = 0.0 + (f.was_locked as float[])[k] = 0.0 + (f.step_t as float[])[k] = 0.0 - 1.0 + (f.held_yaw as float[])[k] = 0.0 + (f.released_yaw as float[])[k] = 0.0 + (f.easing as float[])[k] = 0.0 + k = k + 1 + } +} + // Each foot's step `scale` of the clip's (stride warping, as UE5 cuts a // stride): the feet stepping nearer the hips along the way the figure // faces. The game moves the figure as much slower: the clip keeps its diff --git a/tests/test_anim_pipeline.ae b/tests/test_anim_pipeline.ae index 5574385d..0ef6fb52 100644 --- a/tests/test_anim_pipeline.ae +++ b/tests/test_anim_pipeline.ae @@ -1756,6 +1756,223 @@ transitions() { engine.engine_free(e) } +// --- handing a figure over (#859, #860) --------------------------------------------------------- + +struct Freer { + target: ptr // the *Animator to free + calls: int + at: int // the pose phase it frees it in +} + +// A behaviour that frees an animator from inside the pose phase, on its +// `at`th call: a game freeing a figure in the middle of a frame. +freer_pose(state: ptr, e: *Engine) { + f = state as *Freer + f.calls = f.calls + 1 + if f.calls == f.at && f.target != null { + animator.animator_free(f.target as *Animator) + f.target = null + } +} + +// Three figures on their walks and, after the first in the engine's loop, +// a behaviour that frees the first one's animator in the pose phase. The +// animator leaves the loop, and the figures after it are each posed once in +// that frame: removed outright, the list shifted under the loop walking it, +// so the second figure went unposed for a frame and the third, read again +// past the list's end, was posed twice (and the animator, never removed, +// was called on freed memory; Siltreach, 2026-11-01). +freed_mid_frame() { + e = fresh() + o1 = figure.figure(e, "First", RIGIFY) + a1 = animator.attach(e, o1) + freer = Freer { target: a1 as ptr, calls: 0, at: 10 } + b = engine.behaviour_new("freer", (&freer) as ptr) + b.pose = freer_pose + engine.engine_add_behaviour(e, b) + o2 = figure.figure(e, "Second", RIGIFY) + a2 = animator.attach(e, o2) + o3 = figure.figure(e, "Third", RIGIFY) + a3 = animator.attach(e, o3) + animator.play(a1, animator.add_state(a1, "walk", animator.take(a1, "Walk_Loop", false), true), 0.0) + animator.play(a2, animator.add_state(a2, "walk", animator.take(a2, "Walk_Loop", false), true), 0.0) + animator.play(a3, animator.add_state(a3, "walk", animator.take(a3, "Walk_Loop", false), true), 0.0) + engine.engine_update(e, STEP) + before = engine.engine_behaviour_count(e) + k = 0 + while k < 20 { + engine.engine_update(e, STEP) + k = k + 1 + } + after = engine.engine_behaviour_count(e) + // Each evaluation moves an animator's clock on a step: 21 frames, 21 steps. + wanted = 21.0 * STEP + second = animator.state_time(a2) + third = animator.state_time(a3) + println(" an animator freed inside the pose phase: the engine's behaviours ${before} then ${after}; the figures after it ${second / STEP} and ${third / STEP} frames on, of 21") + check("an animator freed mid-frame leaves the engine's loop, and the figures after it are each posed once a frame", freer.target == null && after == before - 1 && core.abs_of(second - wanted) < STEP * 0.5 && core.abs_of(third - wanted) < STEP * 0.5) + animator.animator_free(a2) + animator.animator_free(a3) + figure.figures_free(e) + engine.engine_free(e) +} + +// The fastest any joint of the walk or the idle turns, played on their own. +walk_idle_fastest(r: *pose.Rig, walk: *Take, idle: *Take) -> float { + own = 0.0 + p = pose.new(r) + q = pose.new(r) + k = 0 + while k < 2 { + t = walk + if k == 1 { t = idle } + clips.sample(t, r, 0.0, true, p) + n = 0 + while n < (clips.duration(t) / STEP) as int + 2 { + clips.sample(t, r, ((n + 1) as float) * STEP, true, q) + f = fastest_turn(r, p, q) + if f > own { own = f } + pose.copy(r, q, p) + n = n + 1 + } + k = k + 1 + } + pose.release(p) + pose.release(q) + return own +} + +// The Rigify mannequin on its walk handed to another writer for a second, +// here one playing its idle into the joints itself, then taken back with +// resume over `seconds`. How far what stood while paused was from what that +// writer drew (radians), the fastest joint turn after it was taken back, the +// clips' own fastest, and the largest joint offset to ease out (radians). +resumed(seconds: float) -> (float, float, float, float) { + e = fresh() + o = figure.figure(e, "Person", RIGIFY) + a = animator.attach(e, o) + r = animator.rig(a) + walk = animator.take(a, "Walk_Loop", false) + idle = animator.take(a, "Idle_Loop", false) + own = walk_idle_fastest(r, walk, idle) + animator.play(a, animator.add_state(a, "walk", walk, true), 0.0) + k = 0 + while k < 30 { + engine.engine_update(e, STEP) + k = k + 1 + } + animator.pause(a) + other = pose.new(r) + seen = pose.new(r) + held = 0.0 + k = 0 + while k < 60 { + clips.sample(idle, r, (k as float) * STEP, true, other) + pose.apply(r, other) + engine.engine_update(e, STEP) + pose.read(r, seen) + j = 0 + while j < pose.count(r) { + d = turned_by(pose.rotation(other, j), pose.rotation(seen, j)) + if d > held { held = d } + j = j + 1 + } + k = k + 1 + } + // The largest offset the resume eases out: each joint from what was + // drawn to where the walk is. + target = pose.new(r) + clips.sample(walk, r, animator.state_time(a), true, target) + offset = 0.0 + j = 0 + while j < pose.count(r) { + d = turned_by(pose.rotation(other, j), pose.rotation(target, j)) + if d > offset { offset = d } + j = j + 1 + } + pose.release(target) + animator.resume(a, seconds) + fastest = 0.0 + last = pose.new(r) + now = pose.new(r) + pose.copy(r, other, last) + k = 0 + while k < 40 { + engine.engine_update(e, STEP) + pose.read(r, now) + f = fastest_turn(r, last, now) + if f > fastest { fastest = f } + pose.copy(r, now, last) + k = k + 1 + } + pose.release(other) + pose.release(seen) + pose.release(last) + pose.release(now) + animator.animator_free(a) + figure.figures_free(e) + engine.engine_free(e) + return held, fastest, own, offset +} + +// The figure handed to another writer and taken back (animator.pause and +// resume), as motion sits a settler down and stands it up: paused, the +// animator writes none of its joints; resumed over 0.3 s, its walk eases in +// from what the other writer drew, no joint turning faster than the clips' +// own fastest plus the easing's own (an offset x eased out over T on the +// quintic turns at 1.875 x / T at its fastest). Taken back at once, it snaps. +hand_off() { + held, eased, own, offset = resumed(0.3) + _held_at_once, snapped, _own_at_once, _offset_at_once = resumed(0.0) + allowed = own + 1.875 * offset / 0.3 + println(" handed to another writer for 1 s: what stood was ${held * 57.29578} degrees off what it drew; taken back over 0.3 s from ${offset * 57.29578} degrees off the walk, the fastest joint turn ${eased} rad/s, at once ${snapped} (the clips' own ${own}, with the easing's ${allowed})") + check("paused, the animator writes none of the figure's joints", held < 0.000001) + check("taken back over 0.3 s, no joint turns faster than the clips' own fastest plus the easing's (taken back at once, it snaps)", eased <= allowed * 1.02 && snapped > allowed * 1.02) +} + +// One take loaded once and shared by two figures on the same rig: both play +// it alike, it outlives both animators (its owner frees it), and a figure on +// a rig of another size is refused it. +shared_take() { + e = fresh() + o1 = figure.figure(e, "One", RIGIFY) + o2 = figure.figure(e, "Two", RIGIFY) + a1 = animator.attach(e, o1) + a2 = animator.attach(e, o2) + own_rig = pose.of_skeleton(figure.skeleton(figure.figure_of(o1))) + walk = clips.from_scene(own_rig, figure.scene(figure.figure_of(o1)), "Walk_Loop", 0.0) + animator.play(a1, animator.add_state(a1, "walk", animator.share_take(a1, walk, false), true), 0.0) + animator.play(a2, animator.add_state(a2, "walk", animator.share_take(a2, walk, false), true), 0.0) + k = 0 + while k < 20 { + engine.engine_update(e, STEP) + k = k + 1 + } + apart = 0.0 + j = 0 + while j < pose.count(own_rig) { + d = turned_by(pose.rotation(animator.pose_now(a1), j), pose.rotation(animator.pose_now(a2), j)) + if d > apart { apart = d } + j = j + 1 + } + other = figure.figure(e, "Other", MANNEQUIN) + ao = animator.attach(e, other) + refused = ao != null && pose.count(animator.rig(ao)) != pose.count(own_rig) && animator.share_take(ao, walk, false) == null + animator.animator_free(a1) + animator.animator_free(a2) + animator.animator_free(ao) + p = pose.new(own_rig) + clips.sample(walk, own_rig, 0.5, true, p) + outlived = clips.frames(walk) > 1 && pose.rotation(p, pose.human(own_rig, pose.THIGH_L)).w != 0.0 + println(" one walk shared by two figures: their joints ${apart * 57.29578} degrees apart at most; it outlived both animators: ${outlived}; refused to a rig of another size: ${refused}") + check("a shared take plays alike on two figures, outlives their animators, and is refused to a rig of another size", apart < 0.000001 && outlived && refused) + pose.release(p) + clips.take_free(walk) + pose.rig_free(own_rig) + figure.figures_free(e) + engine.engine_free(e) +} + // --- layers ------------------------------------------------------------------------------------- layers() { @@ -2038,6 +2255,9 @@ main() { turn_on_the_spot() motion_matching() transitions() + freed_mid_frame() + hand_off() + shared_take() layers() root_motion() capsule_root_motion()