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:
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 skeletonsetupLegIK(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 targetdistance = length(target - root) // 2. Clamp to reachable rangemaxReach = upperLength + lowerLengthminReach = 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:
import { FABRIKSolver, Chain} from '@web-engine-dev/core/animation'; // Create IK chain from root to end effectorconst spine = skinnedMesh.skeleton.getBoneByName('Spine');const head = skinnedMesh.skeleton.getBoneByName('Head');const chain = new Chain(spine, head); // Configure solverconst 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 chainsolver.solve(chain, target); // Chain bones now orient towards targetFABRIK Algorithm#
Iterates forward (tip to root) and backward (root to tip):
// Forward pass (tip to root)positions[n] = targetfor 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] = rootPositionfor 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 iterationsCCD Solver#
Cyclic Coordinate Descent for simple chains. Fast but less stable than FABRIK:
import { CCDSolver } from '@web-engine-dev/core/animation'; const solver = new CCDSolver( 10, // Max iterations 0.001 // Tolerance); // Solve chainsolver.solve(chain, target);Look-At Constraints#
Orient bones (head, eyes, turret) to track targets with angle limits:
import { LookAtIKSolver, setupLookAtIK} from '@web-engine-dev/core/animation';import type { LookAtIKState } from '@web-engine-dev/core/animation'; // Create look-at stateconst 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 skeletonsetupLookAtIK(lookAt, skeleton); // Update target each framelookAt.target = [enemy.x, enemy.y + 1.6, enemy.z];lookAt.weight = 0.8; // 80% blend // Solve look-atLookAtIKSolver.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:
import { setupFootIK, setFootIKGroundObjects, FootIK} from '@web-engine-dev/core/animation';import type { FootIKState } from '@web-engine-dev/core/animation'; // Create foot IK stateconst 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 skeletonsetupFootIK(footIK, skeleton); // Set ground objects for raycastingsetFootIKGroundObjects([terrainMesh, floorMesh]); // Update foot IK each frameFootIK.solve(footIK, character.position, delta); // Feet now align to terrain slope and heightFoot IK Algorithm#
- Raycast down from foot bones to find ground contact points
- Adjust hip height to keep both feet grounded (lower hip if needed)
- Solve two-bone IK for each leg to reach ground contact
- Rotate foot bones to align with ground normal
- 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:
import { createRagdoll } from '@web-engine-dev/core/animation';import * as RAPIER from '@dimforge/rapier3d'; // Create ragdoll from skeletonconst 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 deathfunction 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#
| Joint | Type | Limits |
|---|---|---|
| Spine | Ball-Socket | ±45° twist, ±30° bend |
| Neck | Ball-Socket | ±60° twist, ±45° bend |
| Shoulder | Ball-Socket | ±90° all axes |
| Elbow | Hinge | 0° to 140° bend |
| Hip | Ball-Socket | ±90° twist, ±120° bend |
| Knee | Hinge | 0° to 135° bend |
Procedural Motion Techniques#
Aim Offset#
Procedurally adjust upper body to aim at targets while moving:
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:
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:
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#
| Solver | Complexity | Cost/Frame |
|---|---|---|
| Two-Bone IK | O(1) | < 0.01ms |
| FABRIK (4 bones) | O(n·i) | < 0.05ms |
| CCD (4 bones) | O(n·i) | < 0.03ms |
| Look-At | O(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