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.

blend-tree-1d.ts
typescript
import { BlendTreeEvaluator } from '@web-engine-dev/core/animation';
import type { AnimationBlendTree1DNode } from '@web-engine-dev/core/animation';
// Define 1D blend tree for locomotion
const 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 tree
const 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 - t
weight₂ = t

2D 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.

blend-tree-2d.ts
typescript
import type { AnimationBlendTree2DNode } from '@web-engine-dev/core/animation';
// Define 2D blend tree for directional movement
const 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 blend
const 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 threshold

2D 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 - t
targetWeight = t
(where t = elapsed / duration)

Blend Curves#

The engine supports multiple blend curves for natural transitions:

CurveFormulaUse Case
LineartConstant speed, mechanical
SmoothStep3t² - 2t³Default, natural ease in/out
EaseInSlow start, fast end
EaseOut1 - (1-t)²Fast start, slow end
SmootherStep6t⁵ - 15t⁴ + 10t³Smoothest, cinematics
blend-curves.ts
typescript
import {
AnimationStateMachine,
BlendCurve
} from '@web-engine-dev/core/animation';
// Configure state machine with blend curve
const config = {
maxQueueSize: 16,
maxRequestAgeFrames: 10,
blendCurve: BlendCurve.SmoothStep, // Default smooth blend
entityId: entity,
};
const stateMachine = AnimationStateMachine.acquire(config);
// Queue transition with custom duration
stateMachine.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 - referencePose

Additive Layer Application#

Apply additive layer with weight:

finalPose = basePose + (additivePose × weight)
additive-layers.ts
typescript
import {
AnimationLayerManager,
createAnimationLayerState
} from '@web-engine-dev/core/animation';
import type { AnimationLayerBlendMode } from '@web-engine-dev/core/animation';
// Create base locomotion layer
const layerManager = new AnimationLayerManager(world);
const baseLayer = createAnimationLayerState(
'locomotion',
'Override' as AnimationLayerBlendMode, // Replace previous pose
1.0 // Full weight
);
// Create additive aim layer
const 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 animation

Common 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-stack.ts
typescript
// 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 offset
const 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:

bone-masks.ts
typescript
import {
HUMANOID_BONE_GROUPS,
PRESET_AVATAR_MASKS,
expandHumanoidMask
} from '@web-engine-dev/core/animation';
// Use preset masks for common cases
const 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 groups
const torsoMask = expandHumanoidMask({
head: false,
leftArm: false,
rightArm: false,
leftLeg: false,
rightLeg: false,
spine: true, // Only spine
});
// Or manually specify bone names
const 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:

layer-sync.ts
typescript
import type { AnimationSyncGroup } from '@web-engine-dev/core/animation';
// Create sync group for coordinated layers
const 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 transitions

Performance 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
Animation | Web Engine Docs | Web Engine Docs