Introduction
Character animation is a crucial aspect of interactive 3D games. Three.js provides robust support for skeletal animation through its SkinnedMesh
and related classes. This tutorial explores how to load, display, and control animated 3D characters with skeletons in Three.js. This is the fifth in a series of tutorials on Three.js:
- Getting Started with Three.js in Vue 3
- Three.js Lighting and Textures
- Physics and Collisions in Three.js
- Enhancing Three.js Scenes with Shadows and Fog
- Creating Three.js Characters with Skeletons (this post)
- Full 3D Scene Example with Three.js
Complete code for the series of blog posts is available on GitHub.
How Three.js Skeletons Work
Skeletal animation in Three.js follows the same principles used in the 3D animation industry:
- Skeleton: A hierarchical structure of bones (joints) that define the character’s articulation points
- Skinning: The process of binding a 3D mesh (the character’s “skin”) to the skeleton
- Weights: Each vertex in the mesh is influenced by one or more bones, with weight values determining how much influence each bone has
- Animation: Keyframes define bone positions and rotations at specific times, with interpolation creating smooth movement
Core Components:
- Bone: A single joint in the skeleton hierarchy (THREE.Bone)
- Skeleton: A collection of bones arranged in a hierarchy (THREE.Skeleton)
- SkinnedMesh: A mesh that deforms based on the skeleton’s movement (THREE.SkinnedMesh)
- AnimationClip: Contains animation data for a specific action (THREE.AnimationClip)
- AnimationMixer: Controls playback of animations on a specific object (THREE.AnimationMixer)
When to Use Skeletal Animation
Skeletal animation is ideal for:
- Character Animation: Humans, animals, or creatures that need to move naturally
- Complex Articulated Objects: Robots, machines with moving parts
- Interactive Elements: Objects that need to respond to user input with lifelike movement
- Cutscenes or Storytelling: Pre-planned animations for narrative purposes
SkinnedMesh Explained
A SkinnedMesh
in Three.js is a special type of mesh that can be deformed by a skeleton. Here’s what makes it different:
- It contains a reference to a skeleton object
- It includes “binding poses” that define the initial position of each vertex
- It stores vertex weights that determine how each bone affects the mesh
- It implements specialized rendering techniques to perform skeletal deformation on the GPU
Let’s examine how this works in our example component:
Skeleton Animation Component
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { SkeletonHelper } from 'three';
const emit = defineEmits(['close']);
const containerRef = ref<HTMLDivElement | null>(null);
const loadingProgress = ref(0);
const isLoading = ref(true);
const animationSpeed = ref(1.0);
const showSkeleton = ref(true);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationFrameId: number;
let mixer: THREE.AnimationMixer;
let clock = new THREE.Clock();
let model: THREE.Group;
let skeletonHelper: SkeletonHelper;
let animations: THREE.AnimationClip[] = [];
let currentAnimation = 0;
const currentAnimationName = ref('None');
// Store animation-specific speeds
const animationSpeeds: Record<string, number> = {
'Sneak': 0.1, // Sneak animation plays at 0.1x speed
'Sad': 0.1, // Sad animation plays at 0.1x speed
'default': 1.0 // Default speed for other animations
};
const init = () => {
if (!containerRef.value) return;
// Create scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x282c34);
// Add grid helper
const gridHelper = new THREE.GridHelper(10, 10, 0x555555, 0x333333);
scene.add(gridHelper);
// Create camera
camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 2, 5);
// Create renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
containerRef.value.appendChild(renderer.domElement);
// Add orbit controls
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// Add lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
scene.add(directionalLight);
// Load model
loadModel();
// Handle window resize
window.addEventListener('resize', onWindowResize);
// Animation loop
const animate = () => {
animationFrameId = requestAnimationFrame(animate);
const delta = clock.getDelta();
// Update animation mixer with proper time scaling
if (mixer) {
// Get the current animation name
const currentClipName = animations[currentAnimation]?.name || '';
// Apply animation-specific speed adjustment
let baseSpeed = animationSpeed.value;
if (currentClipName.includes('Sneak') || currentClipName.includes('sneak')) {
// Apply a slower base speed for sneak animations
baseSpeed = baseSpeed * (animationSpeeds['Sneak'] / animationSpeeds['default']);
} else if (currentClipName.includes('Sad') || currentClipName.includes('sad')) {
// Apply a slower base speed for sad animations
baseSpeed = baseSpeed * (animationSpeeds['Sad'] / animationSpeeds['default']);
}
// Apply animation speed with clamping to prevent extreme values
const clampedSpeed = Math.max(0.01, Math.min(2, baseSpeed));
mixer.update(delta * clampedSpeed);
}
controls.update();
renderer.render(scene, camera);
};
animate();
};
const loadModel = () => {
const loader = new GLTFLoader();
// Use a free CC-licensed model from Mixamo or similar source
loader.load(
'https://threejs.org/examples/models/gltf/Xbot.glb', // Example model URL
(gltf) => {
model = gltf.scene;
model.traverse((object) => {
if ((object as THREE.Mesh).isMesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
// Center model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.x = -center.x;
model.position.z = -center.z;
// Add model to scene
scene.add(model);
// Create skeleton helper
if (model.children[0].children[0].type === 'SkinnedMesh') {
const skinnedMesh = model.children[0].children[0] as THREE.SkinnedMesh;
skeletonHelper = new THREE.SkeletonHelper(skinnedMesh.skeleton.bones[0]);
skeletonHelper.visible = showSkeleton.value;
scene.add(skeletonHelper);
}
// Set up animations
animations = gltf.animations;
if (animations && animations.length) {
mixer = new THREE.AnimationMixer(model);
playAnimation(0); // Start with first animation
} else {
currentAnimationName.value = 'No animations available';
}
isLoading.value = false;
},
(xhr) => {
loadingProgress.value = Math.floor((xhr.loaded / xhr.total) * 100);
},
(error) => {
console.error('Error loading model:', error);
isLoading.value = false;
}
);
};
const playAnimation = (index: number) => {
if (!mixer || !animations || animations.length === 0) return;
// Stop all current animations
mixer.stopAllAction();
// Play the selected animation
const clip = animations[index];
const action = mixer.clipAction(clip);
action.play();
currentAnimation = index;
const clipName = clip.name || `Animation ${index + 1}`;
currentAnimationName.value = clipName;
// Adjust the animation speed slider based on the animation type
if (clipName.includes('Sneak') || clipName.includes('sneak')) {
// For sneak animations, show the adjusted speed in the UI
// but keep the slider at the same position
document.getElementById('animation-speed')?.setAttribute('title',
`Actual speed: ${(animationSpeed.value * (animationSpeeds['Sneak'] / animationSpeeds['default'])).toFixed(1)}`);
} else if (clipName.includes('Sad') || clipName.includes('sad')) {
// For sad animations, show the adjusted speed in the UI
document.getElementById('animation-speed')?.setAttribute('title',
`Actual speed: ${(animationSpeed.value * (animationSpeeds['Sad'] / animationSpeeds['default'])).toFixed(1)}`);
}
};
const nextAnimation = () => {
if (!animations || animations.length === 0) return;
const nextIndex = (currentAnimation + 1) % animations.length;
playAnimation(nextIndex);
};
const previousAnimation = () => {
if (!animations || animations.length === 0) return;
const prevIndex = (currentAnimation - 1 + animations.length) % animations.length;
playAnimation(prevIndex);
};
const toggleSkeleton = () => {
if (skeletonHelper) {
skeletonHelper.visible = !skeletonHelper.visible;
showSkeleton.value = skeletonHelper.visible;
}
};
const onWindowResize = () => {
if (!camera || !renderer) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
window.removeEventListener('resize', onWindowResize);
if (containerRef.value && renderer) {
containerRef.value.removeChild(renderer.domElement);
}
if (controls) {
controls.dispose();
}
if (mixer) {
mixer.stopAllAction();
}
if (model) {
scene.remove(model);
model.traverse((object) => {
if ((object as THREE.Mesh).isMesh) {
if ((object as THREE.Mesh).geometry) {
(object as THREE.Mesh).geometry.dispose();
}
if ((object as THREE.Mesh).material) {
const materials = Array.isArray((object as THREE.Mesh).material)
? (object as THREE.Mesh).material
: [(object as THREE.Mesh).material];
materials.forEach(material => {
material.dispose();
});
}
}
});
}
if (skeletonHelper) {
scene.remove(skeletonHelper);
}
};
onMounted(() => {
init();
});
onBeforeUnmount(() => {
cleanup();
});
</script>
<template>
<div class="scene-container">
<div ref="containerRef" class="three-container"></div>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-content">
<div class="loading-text">Loading Model: {{ loadingProgress }}%</div>
<div class="loading-bar-container">
<div class="loading-bar" :style="{ width: `${loadingProgress}%` }"></div>
</div>
</div>
</div>
<button @click="emit('close')" class="close-button">Close Scene</button>
<div class="controls-container">
<div class="control-group">
<h3>Animation Controls</h3>
<div class="animation-name">Current: {{ currentAnimationName }}</div>
<div class="animation-controls">
<button @click="previousAnimation" class="control-button">Previous</button>
<button @click="nextAnimation" class="control-button">Next</button>
</div>
<div class="speed-control">
<label for="animation-speed">
Animation Speed: {{ animationSpeed.toFixed(1) }}
<span v-if="currentAnimationName.includes('Sneak') || currentAnimationName.includes('sneak')"
class="speed-note">
(Sneak animations play slower)
</span>
<span v-else-if="currentAnimationName.includes('Sad') || currentAnimationName.includes('sad')"
class="speed-note">
(Sad animations play slower)
</span>
</label>
<input
type="range"
id="animation-speed"
v-model.number="animationSpeed"
min="0.1"
max="2"
step="0.1"
class="slider"
/>
</div>
</div>
<div class="control-group">
<h3>Visualization</h3>
<button
@click="toggleSkeleton"
class="control-button"
:class="{ active: showSkeleton }"
>
{{ showSkeleton ? 'Hide Skeleton' : 'Show Skeleton' }}
</button>
</div>
<div class="info-panel">
<h3>Instructions</h3>
<ul>
<li>Drag to rotate view</li>
<li>Scroll to zoom</li>
<li>Right-click drag to pan</li>
<li>Use controls to change animations</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.scene-container {
position: relative;
width: 100%;
height: 100vh;
}
.three-container {
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.loading-content {
background-color: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
width: 300px;
text-align: center;
}
.loading-text {
margin-bottom: 10px;
font-weight: bold;
}
.loading-bar-container {
width: 100%;
height: 20px;
background-color: #eee;
border-radius: 10px;
overflow: hidden;
}
.loading-bar {
height: 100%;
background-color: #42b883;
transition: width 0.3s ease;
}
.close-button {
position: absolute;
top: 20px;
right: 20px;
background-color: rgba(255, 255, 255, 0.8);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
z-index: 10;
}
.close-button:hover {
background-color: rgba(255, 255, 255, 1);
}
.controls-container {
position: absolute;
top: 20px;
left: 20px;
display: flex;
flex-direction: column;
gap: 15px;
z-index: 10;
max-width: 300px;
}
.control-group {
background-color: rgba(255, 255, 255, 0.8);
border-radius: 8px;
padding: 15px;
}
.control-group h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 16px;
color: #333;
}
.animation-name {
font-weight: bold;
margin-bottom: 8px;
padding: 5px;
background-color: rgba(66, 184, 131, 0.2);
border-radius: 4px;
text-align: center;
}
.animation-controls {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.control-button {
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
flex: 1;
}
.control-button:hover {
background-color: #33a06f;
}
.control-button.active {
background-color: #2c3e50;
}
.speed-control {
display: flex;
flex-direction: column;
gap: 5px;
}
.speed-control label {
font-size: 14px;
}
.speed-note {
font-size: 12px;
color: #b86642;
display: block;
margin-top: 2px;
}
.slider {
width: 100%;
cursor: pointer;
}
.info-panel {
background-color: rgba(44, 62, 80, 0.8);
color: white;
border-radius: 8px;
padding: 15px;
}
.info-panel h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 16px;
}
.info-panel ul {
margin: 0;
padding-left: 20px;
}
.info-panel li {
margin-bottom: 5px;
font-size: 14px;
}
</style>
Let’s break down our skeleton animation component to understand each part:
Setup and Initialization
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { SkeletonHelper } from 'three';
// Component state and variables
const containerRef = ref<HTMLDivElement | null>(null);
const loadingProgress = ref(0);
const isLoading = ref(true);
const animationSpeed = ref(1.0);
const showSkeleton = ref(true);
// Three.js objects
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationFrameId: number;
let mixer: THREE.AnimationMixer;
let clock = new THREE.Clock();
let model: THREE.Group;
let skeletonHelper: SkeletonHelper;
let animations: THREE.AnimationClip[] = [];
let currentAnimation = 0;
const currentAnimationName = ref('None');
// Animation speeds customized per animation type
const animationSpeeds: Record<string, number> = {
'Sneak': 0.1, // Sneak animations play at 0.1x speed
'Sad': 0.1, // Sad animations play at 0.1x speed
'default': 1.0 // Default speed for other animations
};
This section sets up the component’s state and declares variables for Three.js objects. The animationSpeeds
object allows us to apply different default speeds to specific types of animations, providing more natural movement for certain actions.
Scene Setup
const init = () => {
if (!containerRef.value) return;
// Create scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x282c34);
// Add grid helper for spatial reference
const gridHelper = new THREE.GridHelper(10, 10, 0x555555, 0x333333);
scene.add(gridHelper);
// Create camera
camera = new THREE.PerspectiveCamera(
45, // Field of view
window.innerWidth / window.innerHeight, // Aspect ratio
0.1, // Near clipping plane
1000 // Far clipping plane
);
camera.position.set(0, 2, 5); // Position camera to view the character
// Set up WebGL renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true; // Enable shadows for realism
containerRef.value.appendChild(renderer.domElement);
// Add orbit controls for user interaction
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0); // Look at character's mid-height
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// Setup lighting for the scene
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
scene.add(directionalLight);
// Load 3D model with animations
loadModel();
// Handle window resize
window.addEventListener('resize', onWindowResize);
// Start animation loop
animate();
};
This function creates the Three.js scene with appropriate lighting, camera settings, and controls. The grid helper provides a visual reference for the character’s position and scale. We set up shadow mapping to enhance visual quality and add orbit controls so users can view the character from different angles.
Animation Loop
const animate = () => {
animationFrameId = requestAnimationFrame(animate);
const delta = clock.getDelta(); // Get time elapsed since last frame
// Update animation mixer with proper time scaling
if (mixer) {
// Get the current animation name
const currentClipName = animations[currentAnimation]?.name || '';
// Apply animation-specific speed adjustment
let baseSpeed = animationSpeed.value;
if (currentClipName.includes('Sneak') || currentClipName.includes('sneak')) {
// Apply a slower base speed for sneak animations
baseSpeed = baseSpeed * (animationSpeeds['Sneak'] / animationSpeeds['default']);
} else if (currentClipName.includes('Sad') || currentClipName.includes('sad')) {
// Apply a slower base speed for sad animations
baseSpeed = baseSpeed * (animationSpeeds['Sad'] / animationSpeeds['default']);
}
// Apply animation speed with clamping to prevent extreme values
const clampedSpeed = Math.max(0.01, Math.min(2, baseSpeed));
mixer.update(delta * clampedSpeed);
}
controls.update(); // Update orbit controls (handles damping)
renderer.render(scene, camera);
};
The animation loop is the heart of our interactive application. It:
- Requests the next animation frame
- Calculates the time elapsed since the last frame
- Applies animation-specific speed adjustments based on the animation type
- Updates the animation mixer with the adjusted time delta
- Updates the orbit controls
- Renders the scene
The speed adjustment logic allows certain animations like “Sneak” or “Sad” to play at more appropriate speeds automatically, creating more convincing movement.
Model Loading
const loadModel = () => {
const loader = new GLTFLoader();
// Load a 3D character model in GLTF format
loader.load(
'https://threejs.org/examples/models/gltf/Xbot.glb', // Model URL
(gltf) => {
model = gltf.scene;
// Configure all meshes to cast and receive shadows
model.traverse((object) => {
if ((object as THREE.Mesh).isMesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
// Center the model on the grid
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.x = -center.x;
model.position.z = -center.z;
// Add model to scene
scene.add(model);
// Create and add skeleton helper for visualization
if (model.children[0].children[0].type === 'SkinnedMesh') {
const skinnedMesh = model.children[0].children[0] as THREE.SkinnedMesh;
skeletonHelper = new THREE.SkeletonHelper(skinnedMesh.skeleton.bones[0]);
skeletonHelper.visible = showSkeleton.value;
scene.add(skeletonHelper);
}
// Set up animations
animations = gltf.animations;
if (animations && animations.length) {
mixer = new THREE.AnimationMixer(model);
playAnimation(0); // Start with first animation
} else {
currentAnimationName.value = 'No animations available';
}
isLoading.value = false; // Hide loading screen
},
// Progress callback
(xhr) => {
loadingProgress.value = Math.floor((xhr.loaded / xhr.total) * 100);
},
// Error callback
(error) => {
console.error('Error loading model:', error);
isLoading.value = false;
}
);
};
This function handles loading the 3D character model using the GLTFLoader. GLTF (GL Transmission Format) is the recommended format for Three.js as it efficiently packages geometry, materials, animations, and skeleton data.
Key steps:
- Create a GLTF loader instance
- Request the model file (in this case from Three.js examples)
- Process the loaded model:
- Configure mesh settings for shadows
- Center the model on the grid
- Add it to the scene
- Create a SkeletonHelper for visualization
- This draws the bones of the skeleton for debugging/learning
- Set up the animation system:
- Store the loaded animation clips
- Create an animation mixer for the model
- Play the first animation
Animation Controls
const playAnimation = (index: number) => {
if (!mixer || !animations || animations.length === 0) return;
// Stop all current animations
mixer.stopAllAction();
// Play the selected animation
const clip = animations[index];
const action = mixer.clipAction(clip);
action.play();
currentAnimation = index;
const clipName = clip.name || `Animation ${index + 1}`;
currentAnimationName.value = clipName;
// Show appropriate UI speed hints based on animation type
if (clipName.includes('Sneak') || clipName.includes('sneak')) {
document.getElementById('animation-speed')?.setAttribute('title',
`Actual speed: ${(animationSpeed.value * (animationSpeeds['Sneak'] / animationSpeeds['default'])).toFixed(1)}`);
} else if (clipName.includes('Sad') || clipName.includes('sad')) {
document.getElementById('animation-speed')?.setAttribute('title',
`Actual speed: ${(animationSpeed.value * (animationSpeeds['Sad'] / animationSpeeds['default'])).toFixed(1)}`);
}
};
const nextAnimation = () => {
if (!animations || animations.length === 0) return;
const nextIndex = (currentAnimation + 1) % animations.length;
playAnimation(nextIndex);
};
const previousAnimation = () => {
if (!animations || animations.length === 0) return;
const prevIndex = (currentAnimation - 1 + animations.length) % animations.length;
playAnimation(prevIndex);
};
const toggleSkeleton = () => {
if (skeletonHelper) {
skeletonHelper.visible = !skeletonHelper.visible;
showSkeleton.value = skeletonHelper.visible;
}
};
These functions control the animation playback:
playAnimation()
: Plays a specific animation clip by indexnextAnimation()
andpreviousAnimation()
: Cycle through available animationstoggleSkeleton()
: Shows or hides the skeleton visualization
The animation system in Three.js uses:
- AnimationClip: Stores keyframe data for a specific animation
- AnimationAction: Controls a specific animation’s playback (play, pause, stop, crossfade)
- AnimationMixer: Manages all animations for a specific object
Cleanup and Lifecycle
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
window.removeEventListener('resize', onWindowResize);
if (containerRef.value && renderer) {
containerRef.value.removeChild(renderer.domElement);
}
if (controls) {
controls.dispose();
}
if (mixer) {
mixer.stopAllAction();
}
// Properly dispose of all geometries and materials
if (model) {
scene.remove(model);
model.traverse((object) => {
if ((object as THREE.Mesh).isMesh) {
if ((object as THREE.Mesh).geometry) {
(object as THREE.Mesh).geometry.dispose();
}
if ((object as THREE.Mesh).material) {
const materials = Array.isArray((object as THREE.Mesh).material)
? (object as THREE.Mesh).material
: [(object as THREE.Mesh).material];
materials.forEach(material => {
material.dispose();
});
}
}
});
}
if (skeletonHelper) {
scene.remove(skeletonHelper);
}
};
onMounted(() => {
init();
});
onBeforeUnmount(() => {
cleanup();
});
Proper cleanup is essential in Three.js applications to prevent memory leaks. The cleanup()
function:
- Cancels any pending animation frames
- Removes event listeners
- Cleans up DOM elements
- Disposes of Three.js controls
- Stops all animations
- Properly disposes of all geometries and materials
These steps ensure that resources are freed when the component is unmounted.
Technical Deep Dive
Animation Mixer and Animation System
The Three.js animation system is designed to handle complex character animations efficiently. Let’s look deeper at how it works:
- Animation Clips contain keyframes that define:
- Position, rotation, and scale of bones at specific times
- Interpolation methods between keyframes
- Duration and timing information
- Animation Mixer manages animation playback:
- Tracks the current time of each animation
- Blends between multiple animations when crossfading
- Updates bone transformations based on current animation state
- Provides methods to control playback (play, pause, stop)
- Animation Actions control individual animation clips:
- Can be played, paused, or stopped
- Allow weight adjustment for blending
- Support time scaling for speed control
- Enable looping configurations
- Support crossfading between animations
In our example, we use the mixer to control animation playback with custom speed adjustments:
mixer.update(delta * clampedSpeed);
This line advances the animation by the specified time delta, adjusted by our custom speed factor.
Skeleton Helper
The SkeletonHelper
class provides a visual representation of the skeleton’s bone structure:
skeletonHelper = new THREE.SkeletonHelper(skinnedMesh.skeleton.bones[0]);
It creates a wireframe visualization of the bones, which is invaluable for:
- Debugging animation issues
- Understanding the character’s bone structure
- Verifying proper skeleton setup
- Educational purposes when learning about skeletal animation
SkinnedMesh Internals
A SkinnedMesh
contains several critical components:
- Geometry with Skinning Data:
- Vertex positions define the mesh shape
- Skin indices specify which bones influence each vertex
- Skin weights determine how much influence each bone has
- Skeleton:
- Contains the bone hierarchy
- Stores the bind pose (rest position)
- Maintains the current pose
- Bind Matrix:
- Transforms vertices from model space to bone space
- GPU-Accelerated Skinning:
- Modern Three.js uses shader-based skinning
- Calculations happen on the GPU for better performance
- Supports up to 4 bone influences per vertex by default
When the skeleton moves, the skinned mesh deforms according to the bone influences and weights, creating smooth, natural-looking animation.
Best Practices for Skeletal Animation
- Optimize Bone Count:
- Each bone adds computation overhead
- Use appropriate level of detail (fewer bones for distant or less important characters)
- Animation Blending:
- Use crossfading between animations for smooth transitions
- Implement partial blending for upper/lower body separation
- Performance Considerations:
- Limit the number of animated characters in a scene
- Consider LOD (Level of Detail) for distant characters
- Use simpler skeletons for background characters
- Memory Management:
- Dispose of animations and models when no longer needed
- Share animations between similar characters when possible
- Animation Control:
- Implement speed control for different types of animations
- Use appropriate easing for starting/stopping animations
Conclusion
Skeletal animation is a powerful technique for bringing 3D characters to life in Three.js applications. By understanding the relationship between skeletons, skinned meshes, and animations, you can create engaging interactive experiences with realistic character movement.
The example component we’ve explored demonstrates loading animated models, playing and controlling animations, and visualizing the underlying skeleton structure. These techniques can be extended to create games, interactive visualizations, virtual avatars, and other 3D web experiences.
Continue to our final tutorial in the series to tie it all together in a simple 3D scene.