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:
import { BoneRegistry } from '@web-engine-dev/core/animation'; // Register skeleton when entity spawnsconst rootBone = skinnedMesh.skeleton.bones[0];BoneRegistry.registerSkeleton(entity.uuid, rootBone); // Lookup bone by nameconst 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 despawnsBoneRegistry.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:
| Bone | Common Names | Purpose |
|---|---|---|
| Hips | Hips, Pelvis, Root | Root bone for locomotion |
| Spine | Spine, Spine1, Spine2 | Torso bending |
| Head | Head, Neck | Head rotation, look-at |
| Arms | LeftArm, RightArm, LeftForeArm | Arm movement, IK targets |
| Legs | LeftUpLeg, RightLeg, LeftFoot | Locomotion, 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:
// 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:
// Get skeleton bind matricesconst 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 poseAnimation 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:
import * as THREE from 'three'; // Manually create animation clip (usually loaded from GLTF)const times = [0, 0.5, 1.0]; // Keyframe times in secondsconst 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:
import { AnimationRegistry } from '@web-engine-dev/core/animation'; // Register clips from loaded modelconst 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 nameconst walkClip = AnimationRegistry.findClip(model.uuid, 'Walk'); // Get all clips for an assetconst allClips = AnimationRegistry.getClipsForAssetUuid(model.uuid); // Get clip metadataconst walkRecord = AnimationRegistry.get(records[0].id);console.log(`Duration: ${walkRecord.duration}s, Tracks: ${walkRecord.trackCount}`); // Validate clipconst 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.
// Retarget animation from source to target skeletonfunction 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:
import { updateSkeletonMatricesWASM, isSkeletonWASMAvailable} from '@web-engine-dev/core/animation'; // Check SIMD supportconst hasSIMD = await isSkeletonWASMAvailable();console.log(`WASM SIMD: ${hasSIMD ? 'enabled' : 'fallback to JS'}`); // Automatic SIMD accelerationfunction 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 JSimport { 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:
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