Procedural Animation

Procedural animation generates motion algorithmically instead of using keyframe data. The engine provides IK solvers, look-at constraints, foot IK for terrain adaptation, and ragdoll physics for dynamic character death.

Inverse Kinematics (IK)#

IK solves bone rotations to position an end effector (hand, foot) at a target location. The engine provides multiple IK solvers optimized for different use cases:

Two-Bone IK Solver#

Analytic solver for 2-segment chains (arms, legs). Extremely fast and stable:

two-bone-ik.ts
typescript
import {
TwoBoneIKSolver,
setupLegIK,
setupArmIK
} from '@web-engine-dev/core/animation';
import type { TwoBoneIKState } from '@web-engine-dev/core/animation';
// Setup leg IK (e.g., for foot placement)
const legIK: TwoBoneIKState = {
rootBone: null, // Will be set by setupLegIK
middleBone: null, // Knee
endBone: null, // Foot
target: [0, 0, 0],
poleTarget: [0, 0, 1], // Knee direction
weight: 1.0,
lengths: { upper: 0, lower: 0 }, // Auto-calculated
};
// Initialize from skeleton
setupLegIK(legIK, skeleton, 'left');
// Set target position (e.g., ground contact point)
legIK.target = [0.5, 0.1, 1.0];
legIK.weight = 1.0;
// Solve IK (updates bone rotations)
TwoBoneIKSolver.solve(legIK, legIK, delta);

Two-Bone IK Algorithm#

Uses law of cosines for analytic solution:

// 1. Calculate distance to target
distance = length(target - root)
// 2. Clamp to reachable range
maxReach = upperLength + lowerLength
minReach = abs(upperLength - lowerLength)
distance = clamp(distance, minReach, maxReach)
// 3. Calculate angles using law of cosines
// a² = b² + c² - 2bc·cos(A)
angleUpper = acos((upper² + dist² - lower²) / (2·upper·dist))
angleMid = acos((upper² + lower² - dist²) / (2·upper·lower))
// 4. Orient bones to target with pole vector constraint
// ... (detailed quaternion math)

FABRIK Solver#

Forward And Backward Reaching IK for longer chains (spine, tail, tentacles). Iterative solver that converges to solution:

fabrik-solver.ts
typescript
import {
FABRIKSolver,
Chain
} from '@web-engine-dev/core/animation';
// Create IK chain from root to end effector
const spine = skinnedMesh.skeleton.getBoneByName('Spine');
const head = skinnedMesh.skeleton.getBoneByName('Head');
const chain = new Chain(spine, head);
// Configure solver
const solver = new FABRIKSolver(
0.001, // Tolerance (convergence threshold)
10 // Max iterations
);
// Set target position (e.g., look-at point)
const target = new THREE.Vector3(0, 2, 5);
// Solve IK chain
solver.solve(chain, target);
// Chain bones now orient towards target

FABRIK Algorithm#

Iterates forward (tip to root) and backward (root to tip):

// Forward pass (tip to root)
positions[n] = target
for i = n-1 to 0:
direction = normalize(positions[i] - positions[i+1])
positions[i] = positions[i+1] + direction * length[i]
// Backward pass (root to tip)
positions[0] = rootPosition
for i = 0 to n-1:
direction = normalize(positions[i+1] - positions[i])
positions[i+1] = positions[i] + direction * length[i]
// Repeat until converged or max iterations

CCD Solver#

Cyclic Coordinate Descent for simple chains. Fast but less stable than FABRIK:

ccd-solver.ts
typescript
import { CCDSolver } from '@web-engine-dev/core/animation';
const solver = new CCDSolver(
10, // Max iterations
0.001 // Tolerance
);
// Solve chain
solver.solve(chain, target);

Look-At Constraints#

Orient bones (head, eyes, turret) to track targets with angle limits:

look-at.ts
typescript
import {
LookAtIKSolver,
setupLookAtIK
} from '@web-engine-dev/core/animation';
import type { LookAtIKState } from '@web-engine-dev/core/animation';
// Create look-at state
const lookAt: LookAtIKState = {
headBone: null,
neckBone: null,
spineBones: [],
target: [0, 1.6, 5], // Eye level, 5m forward
weight: 1.0,
config: {
headBone: 'Head',
neckBone: 'Neck',
spineBones: ['Spine1', 'Spine2'],
maxHorizontalAngle: Math.PI / 2, // 90° left/right
maxVerticalAngle: Math.PI / 4, // 45° up/down
weightDistribution: [0.6, 0.25, 0.1, 0.05], // Head, neck, spine
},
};
// Initialize from skeleton
setupLookAtIK(lookAt, skeleton);
// Update target each frame
lookAt.target = [enemy.x, enemy.y + 1.6, enemy.z];
lookAt.weight = 0.8; // 80% blend
// Solve look-at
LookAtIKSolver.solve(lookAt, delta);

Weight Distribution#

Look-at rotation is distributed across multiple bones for natural movement:

  • Head - 60% of rotation (most expressive)
  • Neck - 25% of rotation (secondary)
  • Upper Spine - 10% of rotation (subtle)
  • Lower Spine - 5% of rotation (minimal)

Natural Look-At

Distribute rotation across multiple bones instead of rotating only the head. This prevents unnatural "owl neck" and creates believable character awareness.

Foot IK (Terrain Adaptation)#

Foot IK adapts character feet to terrain slope and height for grounded locomotion:

foot-ik.ts
typescript
import {
setupFootIK,
setFootIKGroundObjects,
FootIK
} from '@web-engine-dev/core/animation';
import type { FootIKState } from '@web-engine-dev/core/animation';
// Create foot IK state
const footIK: FootIKState = {
leftLegIK: {
rootBone: null,
middleBone: null,
endBone: null,
target: [0, 0, 0],
poleTarget: [0, 0, 1],
weight: 1.0,
lengths: { upper: 0, lower: 0 },
},
rightLegIK: {
rootBone: null,
middleBone: null,
endBone: null,
target: [0, 0, 0],
poleTarget: [0, 0, 1],
weight: 1.0,
lengths: { upper: 0, lower: 0 },
},
hipAdjustment: [0, 0, 0],
weight: 1.0,
config: {
raycastDistance: 0.5, // Max distance to ground
footOffset: 0.05, // Foot above ground
hipAdjustmentSpeed: 5, // Hip lowering smoothness
minHipAdjustment: -0.2, // Max hip drop
maxHipAdjustment: 0.1, // Max hip raise
},
};
// Initialize from skeleton
setupFootIK(footIK, skeleton);
// Set ground objects for raycasting
setFootIKGroundObjects([terrainMesh, floorMesh]);
// Update foot IK each frame
FootIK.solve(footIK, character.position, delta);
// Feet now align to terrain slope and height

Foot IK Algorithm#

  1. Raycast down from foot bones to find ground contact points
  2. Adjust hip height to keep both feet grounded (lower hip if needed)
  3. Solve two-bone IK for each leg to reach ground contact
  4. Rotate foot bones to align with ground normal
  5. Smooth hip adjustments to prevent jitter

Performance Warning

Foot IK requires raycasting each frame. Limit to player and nearby NPCs. Disable for distant characters or use animation LOD to skip IK updates.

Ragdoll Physics#

Ragdolls simulate character death/unconsciousness with physics-driven bone animation:

ragdoll.ts
typescript
import { createRagdoll } from '@web-engine-dev/core/animation';
import * as RAPIER from '@dimforge/rapier3d';
// Create ragdoll from skeleton
const ragdoll = createRagdoll(skeleton, world, {
// Physics properties
density: 1.0,
friction: 0.5,
restitution: 0.1,
// Joint limits
enableJointLimits: true,
jointStiffness: 0.8,
jointDamping: 0.5,
// Collision groups
collisionGroups: 0x0002,
collisionMask: 0xFFFF,
});
// Activate ragdoll on death
function onCharacterDeath(entity) {
// Disable animation
Animator.enabled[entity] = false;
// Enable ragdoll
ragdoll.activate();
// Apply death impulse
const forceDirection = getHitDirection(entity);
ragdoll.applyImpulse(
'Spine',
forceDirection.multiplyScalar(5.0),
new THREE.Vector3(0, 1, 0) // Apply above center of mass
);
}
// Update ragdoll (reads physics, writes bone transforms)
function updateRagdoll(delta) {
if (ragdoll.active) {
ragdoll.syncFromPhysics();
}
}

Ragdoll Components#

  • Rigid Bodies - One per major bone (hips, spine, head, limbs)
  • Colliders - Capsules/boxes approximating bone shape
  • Joints - Constraints with angular limits (hinge, ball-socket)

Joint Types#

JointTypeLimits
SpineBall-Socket±45° twist, ±30° bend
NeckBall-Socket±60° twist, ±45° bend
ShoulderBall-Socket±90° all axes
ElbowHinge0° to 140° bend
HipBall-Socket±90° twist, ±120° bend
KneeHinge0° to 135° bend

Procedural Motion Techniques#

Aim Offset#

Procedurally adjust upper body to aim at targets while moving:

aim-offset.ts
typescript
function updateAimOffset(entity, target) {
const aimDirection = target.clone().sub(entity.position).normalize();
// Calculate pitch and yaw relative to character forward
const yaw = Math.atan2(aimDirection.x, aimDirection.z);
const pitch = Math.asin(aimDirection.y);
// Clamp angles to reasonable limits
const clampedPitch = Math.max(-Math.PI/4, Math.min(Math.PI/4, pitch));
const clampedYaw = Math.max(-Math.PI/3, Math.min(Math.PI/3, yaw));
// Set aim parameters for blend tree
stateMachine.setParameter('aim_pitch', clampedPitch);
stateMachine.setParameter('aim_yaw', clampedYaw);
}

Procedural Lean#

Lean character based on velocity for dynamic locomotion:

procedural-lean.ts
typescript
function updateProceduralLean(entity, velocity) {
const speed = velocity.length();
const direction = velocity.clone().normalize();
// Calculate lean angle (proportional to speed)
const maxLeanAngle = Math.PI / 12; // 15°
const leanAngle = Math.min(speed / 10.0, 1.0) * maxLeanAngle;
// Apply lean in direction of movement
const spineBone = skeleton.getBoneByName('Spine1');
const leanQuat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(-direction.z, 0, direction.x),
leanAngle
);
// Blend with current rotation
spineBone.quaternion.slerp(leanQuat, 0.1 * delta);
}

Breathing Animation#

Add subtle breathing to idle poses:

breathing.ts
typescript
function updateBreathing(entity, time) {
// Sine wave for breathing cycle
const breathCycle = Math.sin(time * 0.5) * 0.5 + 0.5; // 0-1
// Apply to chest bone
const chestBone = skeleton.getBoneByName('Spine2');
const breathScale = 1.0 + breathCycle * 0.05; // 0-5% scale
chestBone.scale.set(breathScale, breathScale, breathScale);
}

Performance Considerations#

IK Solver Cost#

SolverComplexityCost/Frame
Two-Bone IKO(1)< 0.01ms
FABRIK (4 bones)O(n·i)< 0.05ms
CCD (4 bones)O(n·i)< 0.03ms
Look-AtO(b)< 0.02ms
Foot IK (both legs)O(1)< 0.05ms

Optimization Tips#

  • Use two-bone IK for arms/legs (fastest, most stable)
  • Limit FABRIK/CCD to < 6 bones per chain
  • Reduce max iterations (3-5 usually sufficient)
  • Skip IK updates for distant characters (LOD)
  • Disable foot IK when not grounded (jumping, falling)
  • Deactivate ragdolls after settling (static sleep)

IK Budget

Budget 0.1ms per character for all IK (2 legs + 2 arms + look-at). Allows 10-15 characters with full IK at 60 FPS.

Best Practices#

IK Usage#

  • Use two-bone IK for limbs (arms, legs)
  • Use FABRIK for spine/tail (4-6 bones)
  • Use CCD only for simple cases (tentacles, cables)
  • Blend IK weight gradually (avoid instant snapping)

Look-At Configuration#

  • Limit horizontal angle to ±90° (prevents unnatural rotation)
  • Limit vertical angle to ±45° (prevents "broken neck")
  • Distribute rotation: 60% head, 25% neck, 15% spine
  • Smooth target changes with lerp/slerp

Foot IK Setup#

  • Set raycast distance to max step height (0.3-0.5m)
  • Add foot offset (0.05m) to prevent foot penetration
  • Smooth hip adjustments (speed = 5-10)
  • Disable on non-grounded states (jumping, falling)

Ragdoll Physics#

  • Use capsule colliders for limbs (better contacts)
  • Enable joint limits to prevent unnatural poses
  • Add damping (0.5) to prevent jitter
  • Put ragdolls to sleep after settling (save CPU)
  • Remove ragdolls after death animation completes
Animation | Web Engine Docs | Web Engine Docs