Skeletal Animation

Skeletal animation deforms meshes by transforming bones in a hierarchy. Each vertex is influenced by multiple bones with weighted blending, enabling realistic character animation with minimal data.

Bone Hierarchy#

A skeleton is a tree of bones (joints) representing a character's structure. Each bone has:

  • Name - Unique identifier (e.g., "Spine", "LeftArm")
  • Parent - Reference to parent bone (null for root)
  • Children - Array of child bones
  • Transform - Local position, rotation, scale relative to parent
  • Bind Matrix - Inverse of initial world transform

Bone Registry#

The BoneRegistry provides fast lookup and hierarchy traversal for bone masking:

bone-registry.ts
typescript
import { BoneRegistry } from '@web-engine-dev/core/animation';
// Register skeleton when entity spawns
const rootBone = skinnedMesh.skeleton.bones[0];
BoneRegistry.registerSkeleton(entity.uuid, rootBone);
// Lookup bone by name
const spineBone = BoneRegistry.getByName('Spine');
// Get all bones in hierarchy (for bone masking)
const upperBodyBones = BoneRegistry.getBoneHierarchy('Spine');
console.log(`Upper body: ${upperBodyBones.length} bones`);
// Find bones by pattern (case-insensitive)
const fingerBones = BoneRegistry.findBonesByPattern('finger');
// IMPORTANT: Unregister when entity despawns
BoneRegistry.unregisterSkeleton(entity.uuid);

Memory Leak Prevention

Always call BoneRegistry.unregisterSkeleton() when entities are destroyed or scenes are unloaded. Failure to do so will cause bone references to accumulate in memory.

Bone Naming Conventions#

Consistent bone names enable retargeting between different skeletons:

BoneCommon NamesPurpose
HipsHips, Pelvis, RootRoot bone for locomotion
SpineSpine, Spine1, Spine2Torso bending
HeadHead, NeckHead rotation, look-at
ArmsLeftArm, RightArm, LeftForeArmArm movement, IK targets
LegsLeftUpLeg, RightLeg, LeftFootLocomotion, foot IK

Skinned Meshes#

Skinned meshes (SkinnedMesh in Three.js) are deformed by bone transformations. Each vertex stores bone indices and weights:

Vertex Skinning#

Vertex positions are computed as a weighted sum of bone transforms:

vertex-shader.glsl
glsl
// Pseudocode for GPU skinning (vertex shader)
vec4 skinnedPosition = vec4(0.0);
for (int i = 0; i < 4; i++) {
int boneIndex = int(skinIndex[i]);
float boneWeight = skinWeight[i];
mat4 boneMatrix = boneMatrices[boneIndex];
skinnedPosition += boneWeight * (boneMatrix * vec4(position, 1.0));
}
gl_Position = projectionMatrix * viewMatrix * skinnedPosition;

Bone Influences#

  • 1 bone - Rigid parts (head, feet) - no blending overhead
  • 2 bones - Simple joints (elbows, knees) - linear blend
  • 3-4 bones - Complex deformation (shoulders, hips) - smooth blend

Performance Tip

Limit bone influences to 2 per vertex on mobile devices. 4 influences require 4x texture fetches and significantly increase vertex shader cost.

Bind Pose#

The bind pose (T-pose or A-pose) is the skeleton's initial state when skinning weights are authored. The bind matrix transforms from bone space to mesh space:

bind-matrices.ts
typescript
// Get skeleton bind matrices
const skeleton = skinnedMesh.skeleton;
const bindMatrices = skeleton.boneInverses; // Array of inverse bind matrices
// Bone matrix for skinning = currentWorldMatrix * boneInverse
// This transforms vertices from bind pose to current pose

Animation Clips#

Animation clips store keyframe data for bone transforms over time. Each clip contains:

  • Name - Clip identifier (e.g., "Walk", "Attack_Sword")
  • Duration - Length in seconds
  • Tracks - Per-bone keyframe data (position, rotation, scale)

Keyframe Tracks#

Each track targets a specific bone property with time and value arrays:

animation-clip.ts
typescript
import * as THREE from 'three';
// Manually create animation clip (usually loaded from GLTF)
const times = [0, 0.5, 1.0]; // Keyframe times in seconds
const values = [0, 0, 0, 0, 1, 0, 0, 0, 0]; // Y position: 0 → 1 → 0 (jump)
const positionTrack = new THREE.VectorKeyframeTrack(
'Hips.position', // Bone name + property
times,
values,
THREE.InterpolateLinear // or InterpolateSmooth, InterpolateDiscrete
);
const rotationTrack = new THREE.QuaternionKeyframeTrack(
'Head.quaternion',
[0, 1.0],
[0, 0, 0, 1, 0.707, 0, 0, 0.707], // Rotate 90° over 1 second
);
const clip = new THREE.AnimationClip('Jump', 1.0, [positionTrack, rotationTrack]);

Animation Registry#

The AnimationRegistry provides centralized clip management with validation:

animation-registry.ts
typescript
import { AnimationRegistry } from '@web-engine-dev/core/animation';
// Register clips from loaded model
const model = await loadGLTF('character.glb');
const records = AnimationRegistry.registerClips(
model.uuid, // Asset UUID (stable across reloads)
model.assetId, // Numeric ID (optional)
model.animations // Array of THREE.AnimationClip
);
console.log(`Registered ${records.length} clips`);
// Retrieve clip by name
const walkClip = AnimationRegistry.findClip(model.uuid, 'Walk');
// Get all clips for an asset
const allClips = AnimationRegistry.getClipsForAssetUuid(model.uuid);
// Get clip metadata
const walkRecord = AnimationRegistry.get(records[0].id);
console.log(`Duration: ${walkRecord.duration}s, Tracks: ${walkRecord.trackCount}`);
// Validate clip
const issues = AnimationRegistry.validateClip(walkClip);
if (issues.some(i => i.severity === 'error')) {
console.error('Invalid clip:', issues);
}

Clip Validation#

The registry automatically validates clips during registration:

  • Name - Warns if missing, auto-generates fallback
  • Duration - Errors if <= 0 or not finite
  • Tracks - Errors if empty or keyframe times not increasing
  • Values - Errors if value count doesn't match time count

Validation Errors

If clips fail validation, an AnimationValidationError is thrown with all blocking issues. Fix errors in your 3D authoring tool (Blender, Maya, etc.) before exporting.

Animation Retargeting#

Retargeting applies animations from one skeleton to another with different bone names or proportions. The engine uses name-based matching with fallback strategies:

Name-Based Retargeting#

Simplest approach: match bones by name between source and target skeletons.

retargeting.ts
typescript
// Retarget animation from source to target skeleton
function retargetAnimation(
sourceClip: THREE.AnimationClip,
sourceSkeleton: THREE.Skeleton,
targetSkeleton: THREE.Skeleton
): THREE.AnimationClip {
const retargetedTracks: THREE.KeyframeTrack[] = [];
for (const track of sourceClip.tracks) {
// Extract bone name from track name (e.g., "Hips.position" → "Hips")
const [boneName, property] = track.name.split('.');
// Find matching bone in target skeleton
const targetBone = targetSkeleton.bones.find(b => b.name === boneName);
if (!targetBone) continue; // Skip if bone doesn't exist in target
// Clone track with new target
const retargetedTrack = track.clone();
retargetedTrack.name = `${targetBone.name}.${property}`;
retargetedTracks.push(retargetedTrack);
}
return new THREE.AnimationClip(
sourceClip.name,
sourceClip.duration,
retargetedTracks
);
}

Advanced Retargeting#

For skeletons with different proportions, use bone mapping with scale correction:

  • Bone Mapping - Map source bone names to target bone names
  • Scale Correction - Adjust translations based on bone length ratios
  • Rotation Only - Copy only rotations, ignore translations (safe default)

Humanoid Retargeting

For humanoid characters, the engine provides preset bone mappings for common skeleton rigs (Mixamo, UE4, Unity). Use HumanoidGraph preset for automatic retargeting.

SIMD-Accelerated Bone Updates#

The engine uses WebAssembly SIMD for 4x faster bone matrix updates:

simd-skeleton.ts
typescript
import {
updateSkeletonMatricesWASM,
isSkeletonWASMAvailable
} from '@web-engine-dev/core/animation';
// Check SIMD support
const hasSIMD = await isSkeletonWASMAvailable();
console.log(`WASM SIMD: ${hasSIMD ? 'enabled' : 'fallback to JS'}`);
// Automatic SIMD acceleration
function updateSkeletons(world, delta) {
const query = defineQuery([SkinnedMesh]);
const entities = query(world);
for (const eid of entities) {
const skeleton = SkinnedMesh.skeleton[eid];
// Uses SIMD if available, JS fallback otherwise
updateSkeletonMatricesWASM(skeleton);
}
}
// Benchmark SIMD vs JS
import { benchmarkSkeletonMatrices } from '@web-engine-dev/core/animation';
const results = await benchmarkSkeletonMatrices(skeleton, 1000);
console.log(`SIMD: ${results.simd.toFixed(2)}ms, JS: ${results.js.toFixed(2)}ms`);
console.log(`Speedup: ${(results.js / results.simd).toFixed(1)}x`);

Skeleton LOD#

Reduce bone count for distant characters to save CPU/GPU:

skeleton-lod.ts
typescript
import { setSkeletonLOD } from '@web-engine-dev/core/animation';
// LOD 0: Full skeleton (100 bones)
// LOD 1: Simplified (50 bones, merge finger/toe bones)
// LOD 2: Minimal (20 bones, spine becomes single bone)
const distanceToCamera = entity.position.distanceTo(camera.position);
if (distanceToCamera > 50) {
setSkeletonLOD(skeleton, 2); // Minimal LOD
} else if (distanceToCamera > 20) {
setSkeletonLOD(skeleton, 1); // Medium LOD
} else {
setSkeletonLOD(skeleton, 0); // Full quality
}

Best Practices#

Skeleton Design#

  • Keep bone count under 100 for real-time (200 max on high-end)
  • Use T-pose or A-pose as bind pose (industry standard)
  • Name bones consistently for retargeting (Mixamo/UE naming)
  • Avoid deeply nested hierarchies (> 10 levels)

Clip Authoring#

  • Use 30 FPS keyframes for most animations (60 FPS for precise actions)
  • Bake constraints before export (IK, physics, etc.)
  • Validate clips in engine before deployment
  • Optimize tracks: remove redundant keyframes, use lower sample rates

Performance#

  • Register skeletons once, cache BoneRegistry lookups
  • Use skeleton LOD for crowds and distant characters
  • Profile with benchmarkSkeletonMatrices() to verify SIMD
  • Unregister skeletons on despawn to prevent memory leaks
Animation | Web Engine Docs | Web Engine Docs