Animation & Pose Architecture

How a creature in Standard of Iron goes from "this unit is attacking" to moving geometry on screen β€” and why it is fast enough to do for thousands of units at once.

This document is the conceptual map. For the binary file format see CREATURE_BPAT_FORMAT.md; for the wider render thread see RENDERING_ARCHITECTURE.md.

Overview

Animation is split between an offline bake path and a lightweight runtime path:

OFFLINE β€” build time
SpeciesManifest
      β”‚
      β–Ό
 bpat_baker
      β”‚
      β”œβ”€β”€β–Ί <species>.bpat
      └──► <species>_minimal.bpsm
      β”‚
      β”‚ loaded at runtime
      β–Ό
ONLINE β€” every frame
game state β†’ intent β†’ clip + phase
                    β”‚
                    β–Ό
             sample or shape pose
                    β”‚
                    β–Ό
               bone palette
                    β”‚
                    β–Ό
               GPU skinning

The runtime seam is deliberately small: intent is essentially (clip_id, phase). That is what keeps large battles cheap.

Runtime pipeline

Every creature follows the same runtime path each frame. The diagram shows the data flow; the sections below identify the code that owns each step.

ECS components
      β”‚
      β–Ό
sample_anim_state()
      β”‚
      β–Ό
AnimationInputs
      β”‚
      β–Ό
resolve_pose()
      β”‚
      β–Ό
PoseIntent / AnimationStateId
      β”‚
      β–Ό
clip resolution
      β”‚
      β–Ό
ResolvedClipPlayback / HumanoidPose
      β”‚
      β–Ό
BPAT sampling or procedural posing
      β”‚
      β–Ό
bone palette β†’ palette texture β†’ vertex shader β†’ GPU-skinned geometry

Input bridge

sample_anim_state() in render/gl/humanoid/animation/animation_inputs.cpp reads the ECS and produces one AnimationInputs snapshot per entity per frame (is it attacking? dying? guarding? kneeling? how fast is it moving?). Per-entity persistent animation memory (filtered speed/turn, locomotion phase accumulator, guard/hold progress, combat visual state) lives in HumanoidAnimationStateComponent (render/creature/animation_state_components.h).

Intent and selection

resolve_pose() in render/creature/pose_intent.* collapses all the booleans into a single PoseIntent in strict priority order (dying > dead > hit-react > attacking > … > walk > idle). This replaced the old scattered if/else chains: one resolution per entity per frame. Combat additionally runs through a small transactional state machine (below).

Clip resolution

In render/creature/pipeline/, the intent becomes an AnimationStateId, which indexes a precomputed per-archetype table to get a clip_id β€” this is O(1), no string lookups on the hot path:

PoseIntent
    β”‚
    β–Ό
AnimationStateId
    β”‚
    β–Ό
ArchetypeRegistry::bpat_clip[state]
    β”‚
    β”œβ”€β”€ + clip_variant (seed / equipment)
    β–Ό
resolve_bpat_clip()
    β”‚
    β–Ό
uint16 clip_id

BpatRegistry::find_clip(name) (the by-name utility) is backed by an O(1) per-blob hash map (BpatBlob::clip_index), but it is only used off the hot path; runtime selection uses the precomputed index above.

Pose production

GPU skinning

The bone palette (one matrix per bone) is uploaded as a texture and the vertex shader transforms each vertex by its bone matrices. Thousands of units skin in parallel on the GPU. See RENDERING_ARCHITECTURE.md.

Bake pipeline

render/<species>/<species>_manifest.cpp
    β”‚
    β–Ό
SpeciesManifest
    β”‚
    β–Ό
tools/bpat_baker
    β”‚
    β”œβ”€β”€ for each clip and frame: solve skeleton
    β”œβ”€β”€ pack bone palettes and markers
    β”‚
    β”œβ”€β”€β–Ί assets/creatures/<species>.bpat
    └──► assets/creatures/<species>_minimal.bpsm
         (far-LOD snapshot mesh where applicable)

*.bpat files are generated artifacts (git-ignored). Regenerate with:

make bake-bpat          # or: ./build/bin/bpat_baker assets/creatures

The current build ships six species: humanoid, horse, elephant, humanoid_sword, humanoid_spear, humanoid_skeleton (ids 0–5).

Humanoid locomotion

Walk/run/turn quality is produced by poser.cpp::compute_locomotion_pose, driven by a velocity-blended, phase-continuous gait state built in prepare_animation.cpp.

ground speed
    β”‚
    β–Ό
build_locomotion_targets()
    β”‚  target speed / blend / run / turn / cadence
    β–Ό
smooth_towards(prev, target, dt, tau)
    β”‚
    β–Ό
filtered speed / locomotion blend / run blend / turn / cadence
    β”‚
    β–Ό
compute_locomotion_pose()
    β”‚
    β”œβ”€β”€ walk_profile ↔ run_profile blend
    β”œβ”€β”€ speed-scaled stride and foot plant / toe-off
    β”œβ”€β”€ turn lean, torso twist and stride bias
    β”œβ”€β”€ arm swing and counter-motion
    └── vertical bob, head stabilization and pelvis weight shift

Key properties that keep it smooth:

Walk and run mechanics

The two profiles are not just amplitude variants. Walking keeps each foot planted for 60% of its cycle, creating double support; it uses a wider track, an upright torso, relaxed arms with a slight elbow bend and a pronounced heel-to-toe roll. Running cuts ground contact to 32%, narrows the track and leaves two flight windows per cycle. It also lands near-flat, recovers the swing leg earlier and leans farther over the stride. Free arms use a compact bent-elbow pump; weapon-ready profiles lower and draw their carry inward so running does not reuse the walking guard silhouette.

Their centre-of-mass curves are authored separately before blending. A walker rises over the supporting leg and settles during double support. A runner compresses over the loaded leg and rises during flight. Blending the two waves, instead of merely reversing one amplitude, preserves vertical motion through the middle of a walk-to-run transition.

Swing-to-stance velocity continuity

A planted foot slides backwards relative to the hips at -stride / planted_fraction per unit phase. swing_travel() is therefore a Hermite whose end tangents carry that slope, not an ease that starts and finishes at rest: the foot leaves the ground still travelling backwards, reaches its furthest forward point shortly before touchdown, and retracts into the plant. Take the tangents away and the foot path has a corner at toe-off and another at heel strike β€” one visible hitch per stride, which is what the old 0.68Β·smoothstep(t) + 0.32·√t produced (√ has an infinite slope at t = 0).

The retraction near touchdown is also the cheapest foot-lock the stylised gait gets: the foot is moving with the ground at the moment it lands rather than against it.

Stand, walk and run crossfades

Selection resolves one baked clip, so a unit that starts or stops walking would cut between idle and walk on the frame the movement flag flips β€” mid-stride, both feet in the wrong place. resolve_locomotion_crossfade() (animation/selection_manifest.cpp) instead names a primary clip and a second one to blend against, and humanoid_animation_selection.cpp hangs the second on the request's full_body_blend layer. The played state stays whatever the movement says, so nothing downstream sees a walking unit reported as idle.

The weight is locomotion_presence / run_presence, not locomotion_blend. The blends carry how fast the unit is going, so driving the clip mix from them would leave every unit slower than the reference walk permanently diluted with the stand β€” a builder at half reference speed would look like it never commits to a step. Presence only answers "is there a stride at all", eased over the same tau, so it settles at one for a crawl and a sprint alike.

The stride also keeps cycling while it fades: resolve_humanoid_locomotion_sample() integrates the phase at the last walking cadence for as long as presence is above k_locomotion_residual_blend, instead of handing it straight back to the idle free-run. A walk that froze mid-step and then vanished was the single most visible hitch in first-person play.

Gait amplitude and foot skate

walk_profile() / run_profile() in animation/locomotion_manifest.cpp hold the gait amplitudes. Note that stride_length is the distance the foot sweeps relative to the body over one stance, while the body itself covers speed * cycle_time per cycle. At the reference walk speed those numbers are far apart, so planted feet always slide somewhat β€” this is a stylised RTS gait, not a foot-locked one, and stride_distance_scale() only keeps the ratio stable across speeds rather than closing it.

The practical consequence: raising stride_length reduces skate, it does not cause it. The values shipped before the hip-drop solve (0.40 walk / 0.58 run) were small enough that the legs read as a stiff shuffle from the game camera; the authored stance travel is now 0.95 m for the walk and 0.96 m for the run. The run baker samples the profile at normalized speed so its canonical clip keeps that skate budget instead of silently inflating it.

The ceiling used to be leg reach: at a half-stride approaching UPPER_LEG_LEN + LOWER_LEG_LEN the foot could no longer reach the ground from a fixed pelvis, and solve_knee_ik absorbed the deficit by clamping β€” which silently stretched the shin. resolve_humanoid_locomotion_pose now closes that loop itself: given the pelvis height, hip offsets and leg length, it computes the drop each foot needs and sinks the pelvis (and the whole upper body with it) by the larger of the two. Long strides therefore cost hip height, exactly as they do on a real walker, instead of costing bone length. The arena's NoLimbOverextension expectation still guards the arms.

Foot roll

HumanoidPose carries foot_pitch_l / foot_pitch_r, and the FootL / FootR bones are built from that pitch instead of always standing square to the world. The gait drives it: the foot lands toes-up at heel strike, rolls flat through mid-stance, pushes off the toe at the end of stance, and picks the toe back up for swing clearance. heel_strike_pitch, toe_off_pitch and swing_clearance_pitch on the profile are the knobs; a runner lands much flatter and pushes off harder than a walker.

Two things have to move with it or the foot leaves the ground:

Review changes with the humanoid_gait_review scenario, or far faster with build/bin/humanoid_preview --clip walk --view side --report, which renders the baked clip as a phase strip and prints per-frame bone stretch.

Formation pivoting and wheeling

A formation given a destination behind it does not walk a U-turn: movement_system stops translating once the heading error passes ~100Β° and turns the whole unit on the spot at formation_turn_speed_degrees (outer-file speed capped at max(2, 1.5Β·speed), so an 8-wide line turns at roughly 67Β°/s). The simulation reports MotionPresentationState::Turning for that second or two, which selects the idle clip β€” and every slot is a rigid offset from the unit yaw, so before this note the men on the wings were dragged sideways along a 4 m arc at 4.6 m/s in a standing pose. The *_locomotion_matrix arena scenarios reported it as command_response_timeout: nothing walked, nothing translated, the unit had merely rotated.

resolve_soldier_turn_smoothing (render/humanoid/runtime/soldier_turn_smoothing.cpp) already knew how to wheel a man to a rotated slot β€” the wheel path, the catch-up speed, the travel-facing yaw β€” but for a unit that publishes a FormationPresentation the slot is position_is_authoritative and the state snapped to it every frame, so nothing ever relocated. The smoother now detects a sweep on its own inputs: the formation yaw turning faster than 10Β°/s while either the formation centre moves under 0.35 m/s (the same threshold movement_system uses to decide it is turning rather than walking) or the man's own slot outruns the centre by more than 0.5 m/s. The second clause matters as much as the first: the unit begins translating while it is still turning, and for the next 1.5 s a wing slot travels at ~6 m/s under a 2.5 m/s walk gait β€” 17 % of every moving soldier frame in infantry_locomotion_matrix was a body outrunning its unit by over 1 m/s. An inner file on a gentle marching corner (30Β°/s at 1.5 m from the centre) stays inside the margin and keeps its slot. While a sweep holds, and until the man has settled again, the slot stops owning his position and he walks after it, facing his direction of travel; a man swept faster than 0.3 m/s counts as relocating even inside the 0.30 m relocate band, which is what keeps the inner files stepping rather than gliding. A man who started relocating under a sweep is wheeling and may jog at 1.5Γ— the ordinary catch-up cap, so a wing regains its place in a couple of seconds instead of trailing the line for ten. Once the sweep ends and he is within settle_distance, the slot is authoritative again with no snap, because he is already standing on it. The snap-distance teleport guard doubles while wheeling so a wide line's wing is not cut to its slot mid-arc.

Two cases keep the rigid sweep on purpose, through allow_pivot_wheel: a formation in melee (contact geometry is simulation-owned and a lagging body would fight beside the wrong man) and one in hold mode (a kneeling wall must not stand up to shuffle). The arena's command-response check now also accepts a 5Β° rotation as a visible response, so a unit that turns before it walks is no longer reported as ignoring its order.

Showcase moves

Six humanoid clips are authored as keyframes rather than shaped from gait parameters: showcase_jump, showcase_front_flip, showcase_handstand, showcase_side_aerial, showcase_sword_flourish, showcase_spear_throw. They exist because an acrobatic move is not a small perturbation of a stance β€” a handstand inverts the whole body, so the additive delta vocabulary the ambient idles use (ambient_pose_manifest.cpp) cannot express it.

animation/showcase_pose_manifest.{h,cpp} holds the keys; the forward kinematics that turn them into a complete HumanoidPose live in animation/rig/pose_fk.h, shared with the death collapses (Β§4c):

ShowcaseKey
(root, body rotation, spine, head, blade, limb aims)
    β”‚
    β–Ό
smoothstep interpolation between keys
    β”‚
    β–Ό
PoseFk
    β”œβ”€β”€ spine β†’ neck / shoulders / head
    └── limb aims β†’ exact-length limb segments
    β”‚
    β–Ό
whole-body rotation about the pelvis
    β”‚
    β–Ό
translation to the authored root

A limb aim is (pitch, splay, yaw, bend) in degrees: pitch swings the limb forward, splay outward, yaw around the body axis, and bend is joint flexion β€” knees fold backward, elbows forward. Because both the segment and its bend are rotations about the same local X axis they simply add, which is why the FK needs no IK solve and cannot produce a stretched limb.

Two things about these clips do not follow the usual rules:

tools/arena/promos/humanoid_showcase.json and the promo_humanoid_showcase scenario are what these were authored for; ShowcaseRoutineComponent is the scripted playlist that drives them (move id, duration, hold, loop) and it is the only production consumer.

Runtime playback

ShowcaseRoutineComponent
    β”‚
    β–Ό
ShowcaseRoutineSystem
    β”‚
    β”œβ”€β”€β–Ί root travel β†’ Transform
    β”‚
    └──► active / move / phase
             β”‚
             β–Ό
        presentation sync
             β”‚
             β–Ό
CreaturePresentationComponent
             β”‚
             β–Ό
animation_inputs.cpp β†’ AnimationInputs::showcase_clip
             β”‚
             β–Ό
humanoid_animation_selection.cpp forces clip + phase

The presentation revision counter includes the showcase fields. It has to: the render signature in world.cpp is built from that revision, and a routine that changed only its phase would otherwise be treated as an unchanged entity and drawn from the cached preparation.

Death animation

Death poses in animation/death_pose_manifest.{h,cpp} use the same authored-keyframe approach as showcase moves, for the same reason. It used to be a list of per-joint offsets faded in with a smoothstep, and that has two failure modes that a fall exposes immediately:

The falls are now DeathKey sequences resolved through the same forward kinematics as the showcase moves, shared in animation/rig/pose_fk.h: bone lengths are exact by construction, and the settled pose is authored where a body actually lies.

DeathKey
(root, body rotation, spine, head, foot pitch, limb aims)
    β”‚
    β–Ό
per-segment easing
(falling segments ease in with tΒ²)
    β”‚
    β–Ό
PoseFk
    β”œβ”€β”€ spine β†’ neck / shoulders / head
    └── limb aims β†’ elbows / hands / knees / feet
    β”‚
    β–Ό
whole-body rotation about the pelvis
    β”‚
    β–Ό
translation to the authored root

Collapse selection

Four collapses are authored. Three are reachable by an infantry casualty and are baked as clip variants, so resolve_bpat_clip picks one by adding the death_variant to the base clip index β€” hence die_infantry, die_infantry_face, die_infantry_side are contiguous in clip_manifest.h, and so are the three dead_infantry* clips that hold the corpses.

collapse what it is chosen when
BackSprawl trunk arches, knees fold, hips land, shoulders whip down struck from the front
FacePlant folds over its own knees, lands face-down cut down from behind
SideCrumple legs go out sideways, comes to rest twisted onto one side taken on the flank
MountedUnseat carried clear of the saddle before gravity gets him rider profile, not a variant

infantry_death_variant() (damage_application.cpp) takes the dot product of the blow direction against the casualty's facing. It is not a die roll: a man shot in the chest must not land on his face. Slot zero of a volley always takes the fall the blow argues for; the men behind him may be substituted onto the side crumple, which is where identical bodies would otherwise show.

The trunk roll on the side falls deliberately stops short of 90Β°. The rig carries its shoulders as two points half a metre apart on a rigid spine, so a body laid exactly on its side puts the lower shoulder underground β€” and from the game camera a three-quarter roll reads as "dropped" anyway.

Each collapse also owns its own length (humanoid_death_collapse_duration), and that one number drives both the baked frame count and the runtime DeathAnimationComponent::state_duration. They must agree or the body would still be moving when the clip runs out.

tests/render/creature/death_collapse_test.cpp guards both original defects: every segment holds its bind length across every sampled phase of every fall, and no joint is driven through the ground. Review a change with humanoid_preview --clip die_infantry --view iso --report.

Combat animation and marker-driven damage

Melee combat uses a small per-swing state machine. Crucially, the HP hit lands when the blade visually connects, not on the trigger frame.

Advance β†’ WindUp β†’ Strike β†’ Impact β†’ Recover β†’ Reposition β†’ Idle
             β”‚         β”‚        β”‚
             β”‚         β”‚        └── contact
             β”‚         └─────────── weapon_release
             └───────────────────── anticipation_start

BPAT markers also provide recover_unlocked and exit_safe.

contact
   β”‚
   β–Ό
deferred melee strike
   β”œβ”€β”€ swing start: snapshot damage + target; reset cooldown
   β”œβ”€β”€ contact time: revalidate alive / enemy / in range
   └── valid β†’ apply snapshotted hit; invalid β†’ cancel

contact_time = k_melee_contact_fraction * cooldown

The visual side (combat_visual_state.cpp) eases each phase (eased_combat_phase_progress) and applies a lane-driven weight curve (emphasis_scale Γ— finisher/amplified multipliers).

Stance blending through the swing

combat_attack_visual_weight() never reaches 1: a swing peaks at 0.95 of the attack clip with the stance showing through the rest. That weight has to be applied everywhere it is below one, not only during the wind-up and the exit. Blending the stance during Enter/Anticipation and nowhere else put a step in the mix exactly where anticipation hands over to the strike β€” the stance went from three tenths of the pose to none of it on one frame, right as the blade started to move.

The authored RPG timeline had a second hole in the same curve: exit_blend_progress was pinned at zero, so ExitBlend evaluated to a constant 0.80 for the whole recovery and then cut to the stance when the action ended. It is now read off the authored phase, so the weight actually walks down to zero and use_base_selection takes over cleanly before the move finishes.

Continuous swing arcs

sample_authored_sword_pose_key() interpolates the authored keys with Hermite/Catmull-Rom tangents (central differences, zero at the first and last key). Easing each segment separately with its own smoothstep β€” the old behaviour β€” drove the blade's velocity to zero at every key, so a cut read as a series of short lunges with a stop between each.

Blade direction is slerped, not lerp-and-normalise. The keys either side of a cut are up to 135Β° apart, and the chord path crawls near both ends and whips through the middle; slerp gives the cut a constant angular rate, which is what makes the arc readable.

Every RPG sword move also starts and finishes on one shared rpg_sword_guard_key(). It has to be literally the same key, not five near-copies: the moves chain into one another and fall back to the stance when they end, and an 8 cm hand offset between the last frame of one and the first frame of the next is a snap at exactly the moment the player is watching the blade.

Finally, the swing trail in sword_renderer.cpp is no longer gated off for the authored blade. Without it an RPG cut is a thin prism crossing the screen in five frames; sword_trail_window() takes the window from the move, because each RPG attack puts its cut in a different slice of its own timeline.

Arm IK and reach limits

Bone matrices are rigid β€” make_bone_basis() builds a rotation and a translation and no scale β€” so a pose that puts a hand further from the shoulder than the arm is long does not produce a long arm. It produces a forearm that ends in mid-air and a hand (and the weapon welded to it) floating away from the body. The authored RPG sword keys used to do this: at the strike frame the right hand sat 1.29 m from a 0.59 m arm, and the clips rendered with a detached hand and a sword planted in the ground.

Two rules keep that from happening again:

Body deltas are applied before the hands are placed. Solving an arm against the shoulder it had last frame is how the bow ended up clamped short of full draw: the draw pose pushes the shoulder 0.20 m forward, and the hand target was only out of reach relative to where the shoulder had not moved to yet.

Weapon orientation

A weapon is a static attachment welded to the HandR bone (sword_make_static_attachment), which means its direction in the world is entirely the hand bone's Y axis. The hand bone takes that axis from pose.grip_axis_r, and nothing set it for sword clips β€” so through every RTS sword swing the blade pointed at the sky and only the arm moved.

resolve_sword_pose() now authors a blade direction alongside each hand key (guard β†’ chambered behind the shoulder β†’ apex β†’ through the cut β†’ follow-through β†’ guard) and aim_held_weapon() converts it into the grip axis. k_sword_blade_axis_in_grip in sword_renderer.h is the single definition of where the blade sits in the grip frame; the static attachment, the runtime renderer and the bake-time aim all read it. The three infantry sword variants are a right-to-left cut, its mirror, and an overhead chop, and they now look like three different attacks.

The infantry spear thrust had the same shape of problem in a different place: resolve_infantry_spear_thrust_pose() ignored inputs.variant entirely, so attack_spear_a/b/c baked byte-identical clips. It now offsets hand height, crouch, shaft pitch and reach per variant β€” a level thrust, a low one, and one over the shield rim.

Root motion and hit reactions

Everything above picks a clip and a phase; the model matrix is built from the transform alone. That left a melee fight looking like two men tapping each other with spoons β€” the arms moved and nothing else did. Two pieces now move the root, both resolved by Animation::resolve_combat_root_motion (animation/combat_root_motion_manifest.cpp) and applied in render/humanoid/runtime/instance_prepare.cpp after the model has been grounded:

Three clips back this up, baked per profile so a swordsman raises his shield where a spearman turns his shaft:

tests/render/creature/combat_root_motion_test.cpp pins the lunge shape, the reaction envelopes and that no reaction tilts the torso far enough to read as a fall; humanoid_preview --clip combat_ready --weapon sword shows the stance.

Quadruped gait

Horse and elephant share a single parametric gait core instead of each carrying its own copy of the phase, bob and leg math.

Quadruped::evaluate_cycle_motion(...)
    β”‚
    β”œβ”€β”€ phase = wrap(time / cycle_time + offset)
    β”œβ”€β”€ bob = Ξ£ harmonics Γ— amplitude Γ— scale
    β”œβ”€β”€ per-leg swing target / default foot position
    └── body sway / swing ease / swing arc
    β”‚
    β”œβ”€β”€β–Ί HorseGait
    β”‚     └── horse_motion.cpp + rider phase selection
    β”‚
    └──► ElephantGait
          └── elephant_motion.cpp + trunk / ears / howdah extras

The shared evaluator gained behaviour-exact config knobs (bob harmonic weights/frequencies, bob base/intensity scale, cycle-time floor, optional unclamped swing ease/arc, optional non-mirrored swing target) so each species reproduces its prior output numerically β€” the consolidation deleted duplicate math without changing the gait feel. Mount/howdah attachment frames remain per-species (their anchor geometry differs).

Performance

Code map

Concern File(s)
ECS β†’ animation inputs render/gl/humanoid/animation/animation_inputs.cpp
Intent resolution render/creature/pose_intent.{h,cpp}
Combat visual state render/creature/combat_visual_state.{h,cpp}
Clip selection render/creature/archetype_registry.cpp, pipeline/humanoid_animation_selection.cpp
BPAT playback animation/bpat/bpat_playback.cpp
BPAT blob/registry animation/bpat/bpat_reader.cpp, bpat_registry.cpp
Humanoid locomotion render/humanoid/runtime/poser.cpp, runtime/animation_runtime.cpp
Humanoid combat poses render/humanoid/runtime/pose_controller.cpp
Quadruped shared gait render/creature/quadruped/gait.{h,cpp}
Horse / elephant motion render/horse/horse_motion.cpp, render/elephant/elephant_motion.cpp
Melee damage sync game/systems/combat_system/attack_processor.cpp
Bake tool tools/bpat_baker/, render/<species>/<species>_manifest.cpp