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.
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.
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
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).
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).
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.
resolve_bpat_playback(clip, phase) turns a normalized phase into a frame index + interpolation weight; the bone palette is read straight from the BPAT blob. No skeleton is solved at runtime.poser.cpp (compute_locomotion_pose) and pose_controller.cpp shape a HumanoidPose analytically. This is where the felt realism lives; see βHumanoid locomotionβ below.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.
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).
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:
smooth_towards, per-channel tau). Combat phases use authored ease curves; guard and hold/kneel transitions both ease with the same smoothstep so there is no pop at the ends.phase += dt / cycle_time), never reset on a state change, so feet never teleport between cycles.stride_distance_scale(), so planted feet track displacement. (A full world-space IK foot-lock is a documented future refinement; it needs in-engine visual tuning.)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.
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.
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.
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.
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:
ankle_lift_for_pitch() raises the ankle by however much the rotation would otherwise drive the heel or the toe under the floor, so the contact point stays at ground level.palette_contact_y() (render/creature/pipeline/preparation_common.cpp) grounds the humanoid on the sole, not the ankle bone. It transforms a heel point and a toe point through the posed and bind foot bones and takes the lower of the two. Grounding on the ankle would have cancelled the ankle lift and dragged the model down at every toe-off. Animation::humanoid_foot_contact_lift() is the same relationship exposed for callers that only have a pitch.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.
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.
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:
contact_y_for_playback, which pushes the model down until the lowest foot bone sits on the terrain. In a handstand the feet are two metres in the air, so that rule would bury the character. showcase_ clips are excluded from grounding alongside riding_ clips, and their keys are therefore authored in absolute model space with the standing feet at the rig's foot offset.humanoid_showcase_root_travel(move, phase) and ShowcaseRoutineSystem integrates it into the transform, rotated into the performer's facing and scaled by the entity's render scale. Pose plus entity motion equals the world motion, with no snap when the clip ends.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.
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 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:
humanoid_preview --report) and the head had visibly left the neck behind.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
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.
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
anticipation_start, weapon_release, contact, recover_unlocked, exit_safe β baked by the tool and read directly (no runtime name-substring guessing).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).
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.
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.
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:
PosePrimitives::solve_arm_ik() is a real two-bone solve, so |shoulderβelbow| and |elbowβhand| are the bind lengths by construction. The old elbow_bend_torso() heuristic placed the elbow a fraction along the shoulder-hand line and let the segments come out however they came out β up to 35% over bind on the spear's offhand grip.place_hand_at() clamps the request to the arm's reach before solving, so no authored key can ask for an impossible pose. The reach fractions live together in pose_primitives.h β relaxed (0.985), braced two-handed grip (0.96), committed melee swing (0.94), seated rider (0.75) β because they are four deliberate policies, not four copies of one number.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.
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.
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:
melee_lunge_offset loads the weight back a few centimetres through the wind-up, drives forward 0.24 m (sword) or 0.30 m (spear) into contact and eases back through the recovery, with a forward lean of up to 8Β°. The curve is a function of the visual attack_phase, so every soldier's lunge lands on its own cut; formation ranks use a shorter step (they already carry the lane depth pulses), an evaded swing overextends, and a heavy one drives deeper. First-person commanders are excluded β the chase camera hangs off the simulation transform and the controller already steps them in.HitReactionKind (flinch, block, evade, stagger, recoil) drives a recoil along the blow, a pitch about the feet, a roll and a squash, each with its own out-and-back envelope. A single body the simulation has already knocked back gets a smaller visual recoil so the two do not add up. An attacker's Recoil β the bounce off a blocked blow β is layered over the swing rather than interrupting it, and so is any light reaction that lands while the blade is already in its strike.Three clips back this up, baked per profile so a swordsman raises his shield where a spearman turns his shaft:
combat_ready β the fighting stance. It is authored on the first frame of the swing (HumanoidPoseController::combat_ready_stance samples the attack pose at a small phase), with knees bent (crouch), torso forward, the shield half up and a slow breathing bob. Locked single bodies use it as their base between swings instead of the parade-rest idle, and formation ranks blend their swings over it. The selection happens in apply_combat_ready_clip.react_flinch, react_block, react_evade, react_stagger β non-looping reactions driven by the reaction's own progress (apply_melee_reaction_clip), so a 0.34 s block and a 0.60 s stagger each play end to end. Their curves live in animation/reaction_pose_manifest.cpp; the pose controller turns them into crouch, torso tilt, flinch, shield raise and hand offsets.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.
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).
bpat_clip[state] index); the by-name registry lookup is an O(1) hash map for off-hot-path use.bpat_reader.cpp) to make per-frame reads branch-free; this is intentional (lazy decode would add mutable state + thread-safety risk for marginal memory savings).
| 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 |