Three.js From Zero · Article s3-06
Physics-Driven Animation — Active Ragdolls
Physics-Driven Animation — Active Ragdolls
S3-01 gave you skeletons. S2-03 gave you physics ragdolls. Today we combine them. A character walks normally via keyframed animation. Gets hit. Physics takes over — the skeleton goes limp, collapses realistically. Then, crucially, blends back to animation. Stands up. Walks again. This is the pattern every AAA game uses for hit reactions, stumbles, and environmental impacts.
The demo shows the state machine driving a cube character — ANIM (bones driven by keyframe), RAGDOLL (bones driven by physics bodies), BLENDING (transition back to anim). Press "hit" to trigger. Adjust the recovery duration to feel the difference.
Three states, three data sources
| State | What drives each bone | Physics bodies |
|---|---|---|
| ANIM | AnimationMixer / procedural | Disabled (or kinematic, following animation) |
| RAGDOLL | Rapier body positions | Dynamic, simulating |
| BLENDING | Lerp from ragdoll pose → target anim pose | Kinematic, following blend result |
The key insight: every bone's final rotation is authored by some system. The state determines which system has authority. Clean switching requires knowing how to get a pose out of each system and merge them.
The trigger — going ragdoll
function goRagdoll(impulseVec) {
state = 'ragdoll';
// 1. Stop the animation mixer (or reduce weight to 0 over 0.05s)
mixer.stopAllAction();
// 2. Enable physics bodies. If they were kinematic, flip to dynamic.
for (const bp of bodyParts) {
bp.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
// 3. Seed body transforms from current bone world positions
const pos = bp.bone.getWorldPosition(new THREE.Vector3());
const quat = bp.bone.getWorldQuaternion(new THREE.Quaternion());
bp.body.setTranslation(pos, true);
bp.body.setRotation(quat, true);
}
// 4. Apply the hit as an impulse on the torso
bodyParts.torso.body.applyImpulseAtPoint(impulseVec, hitPos, true);
}
The "seed body transforms from current bone positions" step is critical. Without it, the ragdoll snaps to wherever the bodies were last standing — usually the origin — and explodes out in a bad direction.
Every frame in RAGDOLL state
Bones follow bodies instead of the reverse:
function syncBonesFromPhysics() {
for (const bp of bodyParts) {
const p = bp.body.translation();
const q = bp.body.rotation();
// Convert world-space to bone's local frame
const worldMatrix = new THREE.Matrix4().compose(
new THREE.Vector3(p.x, p.y, p.z),
new THREE.Quaternion(q.x, q.y, q.z, q.w),
new THREE.Vector3(1, 1, 1),
);
const parentInverse = new THREE.Matrix4().copy(bp.bone.parent.matrixWorld).invert();
const local = new THREE.Matrix4().multiplyMatrices(parentInverse, worldMatrix);
local.decompose(bp.bone.position, bp.bone.quaternion, new THREE.Vector3());
}
}
The matrix dance converts world → local because bones live in parent-relative space. Do it wrong and you get violent gibberish.
Detecting "settled" — when to blend back
The ragdoll should blend back to keyframe when it's mostly stopped moving (lying on the floor) OR after a timeout:
function ragdollSettled() {
let totalV = 0;
for (const bp of bodyParts) {
const v = bp.body.linvel();
totalV += Math.hypot(v.x, v.y, v.z);
}
return totalV < 0.3 || timeSinceRagdoll > MAX_RAGDOLL_TIME;
}
Low velocity for several frames = settled. The MAX fallback handles edge cases where the ragdoll lands on geometry and keeps oscillating.
The blend — the tricky part
Now the hard part. You want the character to SMOOTHLY transition from its ragdoll pose (face down on the ground, twisted) back to an upright keyframe (e.g. "get up" animation starting pose).
Capture the ragdoll's final pose as each bone's rotation. Over recoverTime
seconds, lerp each bone from that pose to what the keyframe says it should be:
function startBlendBack() {
state = 'blending';
blendStart = performance.now();
// 1. Switch bodies to kinematic — they'll follow the bones now
for (const bp of bodyParts) {
bp.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
}
// 2. Capture ragdoll pose as each bone's quaternion
for (const bp of bodyParts) {
bp.ragdollQ = bp.bone.quaternion.clone();
}
// 3. Start the "get up" animation — invisibly for now
mixer.clipAction(getUpClip).reset().play();
}
function updateBlend(dt) {
const t = Math.min(1, (performance.now() - blendStart) / (recoverTime * 1000));
const smoothT = t * t * (3 - 2 * t); // smoothstep for easing
// Step the mixer so getUp animation advances
mixer.update(dt);
// For each bone, lerp from captured ragdoll pose → current mixer pose
for (const bp of bodyParts) {
const animQ = bp.bone.quaternion.clone(); // what the mixer just set
bp.bone.quaternion.slerpQuaternions(bp.ragdollQ, animQ, smoothT);
}
if (t >= 1) state = 'anim';
}
Smoothstep (t² × (3-2t)) instead of linear gives eased blend — slow at the start, accelerates, slow at the end. Feels natural.
Hit reactions without full ragdoll
Full ragdoll is dramatic. For smaller hits (bullet impact, shove), use partial ragdoll — only the upper body goes limp, legs keep animating:
const affectedBones = ['spine_upper', 'neck', 'head', 'arm_l', 'arm_r'];
for (const name of affectedBones) {
bodyParts[name].body.setBodyType('dynamic', true);
}
// Legs stay driven by mixer
Two different update paths in the same frame — affected bones read from physics, unaffected bones stay driven by the mixer. Character keeps walking while upper body reels from the hit.
Hit direction + force scaling
The impulse vector should make physical sense. A bullet: small impulse at the hit point (high angular torque from being off-center). A punch: larger impulse at solar-plexus, mostly linear. An explosion: radial impulse scaled by 1/distance².
function applyHit(hitPoint, direction, force) {
// Find the body part closest to the hit point
let nearestPart = null, bestD = Infinity;
for (const bp of bodyParts) {
const p = bp.body.translation();
const d = hitPoint.distanceTo(new THREE.Vector3(p.x, p.y, p.z));
if (d < bestD) { bestD = d; nearestPart = bp; }
}
const impulse = direction.clone().multiplyScalar(force);
nearestPart.body.applyImpulseAtPoint(impulse, hitPoint, true);
}
Get-up animations
"Get up from prone" and "get up from supine" (back down) are two different animations. Pick based on the ragdoll's resting orientation:
function pickGetUpClip() {
const spine = bodyParts.spine.body.rotation();
// quaternion → forward direction
const q = new THREE.Quaternion(spine.x, spine.y, spine.z, spine.w);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q);
return up.y > 0 ? getUpSupineClip : getUpProneClip;
}
Foot IK during blend
During the blend back, the feet should plant on the ground no matter what. Run foot IK (from S3-04) on top of the blend. The animation provides ~correct leg angles; IK snaps the feet exactly to terrain.
Common first-time pitfalls
- Ragdoll explodes on trigger. Body transforms not seeded from bone positions. See section above.
- Blend back snaps. You're not lerping — you're stopping physics then setting bones to mixer output in one frame. Capture ragdollQ, then slerp.
- Character lies on floor forever.
ragdollSettled()threshold too low, or no MAX timeout. Add a 3s fallback. - Bone rotation matrix decompose fails. Parent matrix is stale. Call
bone.parent.updateMatrixWorld(true)before the conversion. - Joints don't match body positions during blend. Blend controls bone rotation, but joints between bodies assume specific poses. Either deactivate joints during blend or temporarily make the bodies fixed.
- Get-up animation starts from wrong pose. Don't interpolate — just play the get-up clip from the start. The blend layer handles the transition from ragdoll-pose to get-up-start-pose.
Exercises
- Hit-reaction layer: on small hit, keep ANIM state but apply an additive "flinch" rotation to spine bones for 0.3s then lerp back.
- Wake-detection: in blend state, if the character gets pushed again, jump back to RAGDOLL.
- Partial ragdoll (upper body only): demonstrate with arm bones ragdolling while legs keep the walk animation.
What's next
S3-07 — Motion Matching. The AAA technique that replaces blend trees entirely. Instead of authoring blend axes, match every frame to the best clip in a big database by velocity + trajectory. State machines disappear.