Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion src/ae3d/animator/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }

Expand Down
58 changes: 54 additions & 4 deletions src/ae3d/engine/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion src/ae3d/ikrig/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading