Animation Blending
Animation blending combines multiple animation clips to create smooth transitions and complex behaviors. The engine provides blend trees, crossfading, additive blending, and layered animation with bone masking.
Blend Trees#
Blend trees interpolate between multiple animation clips based on runtime parameters. The engine supports 1D and 2D blend trees with zero-allocation evaluation.
1D Blend Trees#
1D blend trees blend along a single parameter axis (e.g., speed, turn angle). Perfect for locomotion blending: idle → walk → run → sprint.
import { BlendTreeEvaluator } from '@web-engine-dev/core/animation';import type { AnimationBlendTree1DNode } from '@web-engine-dev/core/animation'; // Define 1D blend tree for locomotionconst locomotionBlendTree: AnimationBlendTree1DNode = { id: 'locomotion', name: 'Locomotion', type: 'blendTree1D', parameterName: 'speed', // Parameter to blend on thresholds: [ { clipName: 'Idle', threshold: 0.0, speed: 1.0 }, { clipName: 'Walk', threshold: 1.5, speed: 1.0 }, { clipName: 'Run', threshold: 4.0, speed: 1.0 }, { clipName: 'Sprint', threshold: 7.0, speed: 1.2 }, ], transitions: [], events: [],}; // Evaluate blend treeconst evaluator = new BlendTreeEvaluator();const parameters = new Map([['speed', 2.5]]); // Current speed value // Speed = 2.5 blends between Walk (1.5) and Run (4.0)// Weight = (2.5 - 1.5) / (4.0 - 1.5) = 0.4// Result: Walk @ 60% + Run @ 40% evaluator.evaluate1D( locomotionBlendTree, actions, // Map<string, AnimationAction> parameters, 1.0 // Master weight);1D Blend Formula#
For parameter value P between thresholds T₁ and T₂:
t = (P - T₁) / (T₂ - T₁)weight₁ = 1 - tweight₂ = t2D Blend Trees#
2D blend trees blend in 2D parameter space (e.g., X/Z velocity for strafing). Uses barycentric interpolation for directional accuracy or inverse-distance weighting for flexibility.
import type { AnimationBlendTree2DNode } from '@web-engine-dev/core/animation'; // Define 2D blend tree for directional movementconst directionalBlendTree: AnimationBlendTree2DNode = { id: 'directional', name: 'Directional Movement', type: 'blendTree2D', parameterX: 'velocityX', // Left/right parameterY: 'velocityZ', // Forward/back thresholds: [ // Center { clipName: 'Idle', position: [0, 0] }, // Cardinal directions { clipName: 'WalkForward', position: [0, 1] }, { clipName: 'WalkBackward', position: [0, -1] }, { clipName: 'StrafeLeft', position: [-1, 0] }, { clipName: 'StrafeRight', position: [1, 0] }, // Diagonals { clipName: 'WalkFL', position: [-0.707, 0.707] }, // Forward-left { clipName: 'WalkFR', position: [0.707, 0.707] }, // Forward-right { clipName: 'WalkBL', position: [-0.707, -0.707] }, // Back-left { clipName: 'WalkBR', position: [0.707, -0.707] }, // Back-right ], transitions: [], events: [],}; // Evaluate 2D blendconst parameters = new Map([ ['velocityX', 0.5], // Slight right ['velocityZ', 0.8], // Mostly forward]); evaluator.evaluate2D( directionalBlendTree, actions, parameters, 1.0); // Result: Blends WalkForward + WalkFR based on distance to each threshold2D Blend Algorithms#
Barycentric Interpolation#
Finds the triangle containing the parameter point and uses barycentric coordinates. Provides accurate directional blending with only 3 active clips:
- O(n³) triangle search (fast for < 16 thresholds)
- Exact interpolation within triangles
- Only 3 clips active (efficient)
- Requires triangulated threshold layout
Inverse-Distance Weighting#
Fallback when point is outside all triangles. Blends all clips based on distance:
- weight = 1 / distance³ (cubic falloff for smooth blending)
- Normalized weights sum to 1.0
- All clips can be active (more GPU cost)
- Works with any threshold layout
Optimization Tip
Keep 2D blend trees under 16 thresholds for optimal cache usage. Pre-allocate weight buffers per node to avoid per-frame allocations.
Crossfading#
Crossfading smoothly transitions between two animations by blending their weights over time. The AnimationStateMachine handles crossfading automatically during state transitions.
Linear Crossfade#
Simple linear interpolation between source and target:
sourceWeight = 1 - ttargetWeight = t(where t = elapsed / duration)Blend Curves#
The engine supports multiple blend curves for natural transitions:
| Curve | Formula | Use Case |
|---|---|---|
| Linear | t | Constant speed, mechanical |
| SmoothStep | 3t² - 2t³ | Default, natural ease in/out |
| EaseIn | t² | Slow start, fast end |
| EaseOut | 1 - (1-t)² | Fast start, slow end |
| SmootherStep | 6t⁵ - 15t⁴ + 10t³ | Smoothest, cinematics |
import { AnimationStateMachine, BlendCurve} from '@web-engine-dev/core/animation'; // Configure state machine with blend curveconst config = { maxQueueSize: 16, maxRequestAgeFrames: 10, blendCurve: BlendCurve.SmoothStep, // Default smooth blend entityId: entity,}; const stateMachine = AnimationStateMachine.acquire(config); // Queue transition with custom durationstateMachine.queueTransition( 'Run', // Target state 0.3, // 300ms crossfade TransitionPriority.Normal);Additive Blending#
Additive blending adds animation deltas on top of a base pose. Perfect for layering procedural adjustments (aim offset, recoil, breathing) on top of locomotion.
Additive Pose Extraction#
Compute additive delta relative to a reference pose (usually first frame):
additivePose = currentPose - referencePoseAdditive Layer Application#
Apply additive layer with weight:
finalPose = basePose + (additivePose × weight)import { AnimationLayerManager, createAnimationLayerState} from '@web-engine-dev/core/animation';import type { AnimationLayerBlendMode } from '@web-engine-dev/core/animation'; // Create base locomotion layerconst layerManager = new AnimationLayerManager(world);const baseLayer = createAnimationLayerState( 'locomotion', 'Override' as AnimationLayerBlendMode, // Replace previous pose 1.0 // Full weight); // Create additive aim layerconst aimLayer = createAnimationLayerState( 'aim_offset', 'Additive' as AnimationLayerBlendMode, // Add to base pose 0.8 // 80% blend); // Configure bone mask (only affect upper body)aimLayer.mask = { includedBones: new Set(['Spine', 'Spine1', 'Spine2', 'LeftArm', 'RightArm']), recursive: true, // Include all children}; // Additive layer adds aim offset to upper body only// Lower body continues playing locomotion animationCommon Additive Use Cases#
- Aim Offset - Upper body aim adjustment while moving
- Recoil - Weapon recoil layered on shooting animation
- Breathing - Subtle chest movement on idle pose
- Look-At - Head rotation to track targets
- Procedural Lean - Body lean based on velocity
Layered Animation#
Animation layers allow multiple animation graphs to blend together with bone masking. Each layer can use Override or Additive blending.
Layer Stack#
Layers are evaluated bottom-to-top, with each layer blending onto the previous:
// Layer 0: Base locomotion (full body)const locomotionLayer = { name: 'locomotion', blendMode: 'Override', weight: 1.0, mask: null, // Affects all bones}; // Layer 1: Upper body actions (shooting, reloading)const upperBodyLayer = { name: 'upper_body', blendMode: 'Override', weight: 1.0, mask: { includedBones: new Set(['Spine', 'LeftArm', 'RightArm']), recursive: true, },}; // Layer 2: Additive aim offsetconst aimLayer = { name: 'aim', blendMode: 'Additive', weight: 0.8, mask: { includedBones: new Set(['Spine1', 'Spine2', 'LeftArm', 'RightArm']), recursive: true, },}; // Final pose:// - Legs: locomotion layer (walk/run)// - Upper body: shooting animation + aim offset// - Head: follows aim target (from aim layer)Bone Masking#
Bone masks control which bones are affected by each layer:
import { HUMANOID_BONE_GROUPS, PRESET_AVATAR_MASKS, expandHumanoidMask} from '@web-engine-dev/core/animation'; // Use preset masks for common casesconst upperBodyMask = PRESET_AVATAR_MASKS.upperBody;const lowerBodyMask = PRESET_AVATAR_MASKS.lowerBody;const leftArmMask = PRESET_AVATAR_MASKS.leftArm;const rightArmMask = PRESET_AVATAR_MASKS.rightArm; // Or create custom mask from humanoid groupsconst torsoMask = expandHumanoidMask({ head: false, leftArm: false, rightArm: false, leftLeg: false, rightLeg: false, spine: true, // Only spine}); // Or manually specify bone namesconst customMask = { includedBones: new Set([ 'Hips', 'Spine', 'Spine1', 'Spine2', 'LeftShoulder', 'LeftArm', 'LeftForeArm', 'LeftHand', ]), recursive: true, // Include all children (fingers, etc.)};Layer Synchronization#
Sync multiple layers to the same normalized time for coordinated animation:
import type { AnimationSyncGroup } from '@web-engine-dev/core/animation'; // Create sync group for coordinated layersconst walkSyncGroup: AnimationSyncGroup = { id: 'walk_sync', name: 'Walk Synchronization', leaderLayer: 'locomotion', // This layer controls time followerLayers: ['upper_body', 'aim'], // These match leader time}; // All synced layers advance at the same normalized time// Prevents upper/lower body desync during transitionsPerformance Considerations#
Blend Tree Optimization#
- Limit thresholds to 16 per blend tree (cache-friendly)
- Use 1D over 2D when possible (4x faster)
- Pre-allocate weight buffers per node (zero allocations)
- Cache parameter lookups outside hot loop
Layer Optimization#
- Keep layer count under 4 (GPU bandwidth)
- Use bone masks to minimize affected bones
- Disable layers at weight < 0.001 (skip evaluation)
- Prefer additive layers over full-body overrides
Memory Management#
- Reuse BlendTreeEvaluator instance (maintains weight buffers)
- Clear animation layer cache when switching scenes
- Monitor clip cache stats with getClipCacheStats()
Frame Budget
Blend tree evaluation: < 0.02ms per node. Layer blending: < 0.05ms per layer. Total animation blending budget: < 0.2ms per entity at 60 FPS.
Best Practices#
Blend Tree Design#
- Use 1D for speed (idle → walk → run)
- Use 2D for direction (forward/back, left/right)
- Keep threshold count low (4-8 for 1D, 8-12 for 2D)
- Arrange 2D thresholds in triangulated grid for barycentric blending
Layer Usage#
- Layer 0: Full-body base (locomotion)
- Layer 1: Upper body override (shooting, reloading)
- Layer 2+: Additive adjustments (aim, recoil, breathing)
- Use sync groups for coordinated movement
Crossfade Tuning#
- Short durations (0.1-0.2s) for responsive actions
- Medium durations (0.3-0.5s) for natural transitions
- Long durations (0.5-1.0s) for cinematic blends
- Use SmoothStep for most cases, SmootherStep for cinematics