/** * AnimationBlendingExample.js * * Advanced animation blending examples for VRM characters. * Demonstrates additive blending, crossfading, and layered animations. * * NOW SUPPORTS: FBX (Mixamo) and GLB/GLTF formats * * Based on Three.js examples: * - webgl_animation_skinning_additive_blending * - webgl_animation_walk */ import * as THREE from 'three'; import { loadMixamoAnimation } from './loadMixamoAnimation.js'; export class AnimationBlendingExample { constructor(vrm, scene) { this.vrm = vrm; this.scene = scene; this.mixer = new THREE.AnimationMixer(vrm.scene); // Animation storage this.animations = { base: null, // Base animation (idle/walk) additive: [] // Additive animations (gestures, expressions) }; // Action references this.actions = { base: null, additive: new Map() }; // Settings for live adjustment this.settings = { 'base weight': 1.0, 'idle weight': 0.0, 'walk weight': 1.0, 'run weight': 0.0, 'additive wave': 0.0, 'additive point': 0.0, 'additive nod': 0.0, 'modify time scale': 1.0 }; // State tracking this.currentBase = null; this.blendDuration = 0.5; // seconds } /** * Load animation - auto-detects FBX or GLB/GLTF * @param {string} url - Path to animation file * @param {string} name - Name to store animation as * @param {string} type - 'base' or 'additive' */ async loadAnimation(url, name, type = 'base') { try { let clip; // Detect file format const extension = url.split('.').pop().toLowerCase(); if (extension === 'fbx') { // Load Mixamo FBX animation console.log(`📦 Loading Mixamo animation: ${name}`); clip = await loadMixamoAnimation(url, this.vrm); } else if (extension === 'glb' || extension === 'gltf') { // Load GLTF animation console.log(`📦 Loading GLTF animation: ${name}`); const loader = new THREE.GLTFLoader(); const gltf = await loader.loadAsync(url); clip = gltf.animations[0]; if (!clip) { console.error(`No animation found in ${url}`); return false; } } else { console.error(`Unsupported format: ${extension}. Use .fbx, .glb, or .gltf`); return false; } // Create action const action = this.mixer.clipAction(clip); action.setLoop(THREE.LoopRepeat); action.clampWhenFinished = false; action.enabled = true; if (type === 'additive') { // Setup additive blending action.blendMode = THREE.AdditiveAnimationBlendMode; action.setEffectiveWeight(0); this.actions.additive.set(name, { action: action, clip: clip, weight: 0.0 }); console.log(`✅ Loaded additive: ${name} (${extension})`); } else { // Setup base animation this.actions[name] = action; this.animations[name] = clip; console.log(`✅ Loaded base: ${name} (${extension})`); } return true; } catch (err) { console.error(`Failed to load ${name}:`, err); return false; } } /** * Load base animation (idle, walk, run, etc.) * Supports both FBX and GLB/GLTF */ async loadBaseAnimation(url, name = 'walk') { return await this.loadAnimation(url, name, 'base'); } /** * Load additive animation (gestures, poses) * Additive animations blend on top of base animations * Supports both FBX and GLB/GLTF */ async loadAdditiveAnimation(url, name) { return await this.loadAnimation(url, name, 'additive'); } /** * Crossfade between base animations * Example: smooth transition from walk to run */ crossFadeTo(targetAnimName, duration = this.blendDuration) { const targetAction = this.actions[targetAnimName]; if (!targetAction) { console.warn(`Animation ${targetAnimName} not found`); return; } // Already playing this animation if (this.currentBase === targetAnimName) { return; } // Prepare target animation targetAction.reset(); targetAction.setEffectiveTimeScale(1.0); targetAction.setEffectiveWeight(1.0); targetAction.enabled = true; targetAction.play(); // Crossfade from current if (this.currentBase && this.actions[this.currentBase]) { const currentAction = this.actions[this.currentBase]; currentAction.crossFadeTo(targetAction, duration, true); } this.currentBase = targetAnimName; console.log(`🔄 Crossfading to: ${targetAnimName}`); } /** * Set weight of additive animation (0.0 to 1.0) * 0.0 = no effect, 1.0 = full effect */ setAdditiveWeight(name, weight) { const additive = this.actions.additive.get(name); if (!additive) { console.warn(`Additive animation ${name} not found`); return; } // Clamp weight weight = THREE.MathUtils.clamp(weight, 0.0, 1.0); // Update weight additive.weight = weight; additive.action.setEffectiveWeight(weight); // Play/stop based on weight if (weight > 0 && !additive.action.isRunning()) { additive.action.play(); } else if (weight === 0 && additive.action.isRunning()) { additive.action.stop(); } // Update settings for debugging this.settings[`additive ${name}`] = weight; } /** * Gradually fade in additive animation */ fadeInAdditive(name, duration = 0.5) { const additive = this.actions.additive.get(name); if (!additive) return; const startWeight = additive.weight; const startTime = Date.now(); const animate = () => { const elapsed = (Date.now() - startTime) / 1000; const progress = Math.min(elapsed / duration, 1.0); const newWeight = startWeight + (1.0 - startWeight) * progress; this.setAdditiveWeight(name, newWeight); if (progress < 1.0) { requestAnimationFrame(animate); } }; animate(); } /** * Gradually fade out additive animation */ fadeOutAdditive(name, duration = 0.5) { const additive = this.actions.additive.get(name); if (!additive) return; const startWeight = additive.weight; const startTime = Date.now(); const animate = () => { const elapsed = (Date.now() - startTime) / 1000; const progress = Math.min(elapsed / duration, 1.0); const newWeight = startWeight * (1.0 - progress); this.setAdditiveWeight(name, newWeight); if (progress < 1.0) { requestAnimationFrame(animate); } }; animate(); } /** * Play a temporary additive gesture that auto-fades * Perfect for one-off gestures like waving or pointing */ playGesture(name, duration = 2.0, fadeInTime = 0.3, fadeOutTime = 0.5) { return new Promise((resolve) => { // Fade in this.fadeInAdditive(name, fadeInTime); // Hold setTimeout(() => { // Fade out this.fadeOutAdditive(name, fadeOutTime); // Resolve after fade out setTimeout(() => { resolve(); }, fadeOutTime * 1000); }, (duration - fadeOutTime) * 1000); }); } /** * Set time scale for all animations * > 1.0 = faster, < 1.0 = slower */ setTimeScale(scale) { scale = Math.max(0.1, scale); // Prevent negative or zero // Set for base animation if (this.currentBase && this.actions[this.currentBase]) { this.actions[this.currentBase].setEffectiveTimeScale(scale); } // Set for additive animations this.actions.additive.forEach((additive) => { additive.action.setEffectiveTimeScale(scale); }); this.settings['modify time scale'] = scale; } /** * Synchronize additive animation with base animation * Useful for keeping gestures in sync with walk cycles */ synchronizeAdditive(additiveName) { const additive = this.actions.additive.get(additiveName); const base = this.actions[this.currentBase]; if (!additive || !base) return; // Match time const baseTime = base.time; const baseProgress = baseTime / base.getClip().duration; const additiveClip = additive.clip; additive.action.time = baseProgress * additiveClip.duration; } /** * Update loop - call every frame */ update(deltaTime) { this.mixer.update(deltaTime); } /** * Stop all animations */ stopAll() { this.mixer.stopAllAction(); this.currentBase = null; } /** * Get current state for debugging */ getState() { return { currentBase: this.currentBase, additiveWeights: Array.from(this.actions.additive.entries()).map( ([name, data]) => ({ name, weight: data.weight }) ), settings: { ...this.settings } }; } /** * Dispose and cleanup */ dispose() { this.stopAll(); this.mixer.uncacheRoot(this.vrm.scene); this.actions.additive.clear(); } } // ============ USAGE EXAMPLES ============ /** * Example 1: Basic Setup with Mixamo FBX */ async function exampleMixamoSetup(vrm, scene) { const blender = new AnimationBlendingExample(vrm, scene); // Load Mixamo FBX animations await blender.loadBaseAnimation('/animations/Idle.fbx', 'idle'); await blender.loadBaseAnimation('/animations/Walking.fbx', 'walk'); await blender.loadBaseAnimation('/animations/Running.fbx', 'run'); await blender.loadAdditiveAnimation('/animations/Waving.fbx', 'wave'); // Start with idle blender.crossFadeTo('idle'); return blender; } /** * Example 2: Mixed Format Setup (FBX + GLB) */ async function exampleMixedFormats(vrm, scene) { const blender = new AnimationBlendingExample(vrm, scene); // Load FBX from Mixamo await blender.loadBaseAnimation('/animations/Walking.fbx', 'walk'); // Load GLB/GLTF from other sources await blender.loadBaseAnimation('/animations/custom_idle.glb', 'idle'); await blender.loadAdditiveAnimation('/animations/custom_wave.glb', 'wave'); blender.crossFadeTo('idle'); return blender; } /** * Example 3: Walk and Wave with Mixamo */ async function exampleMixamoWalkAndWave(blender) { // Start walking blender.crossFadeTo('walk', 0.5); // Wait a moment, then wave await new Promise(resolve => setTimeout(resolve, 1000)); await blender.playGesture('wave', 3.0); } /** * Example 4: Running with Gestures */ async function exampleRunWithGestures(blender) { // Start running blender.crossFadeTo('run', 0.3); // Add wave at 50% weight (subtle while running) blender.setAdditiveWeight('wave', 0.5); await new Promise(resolve => setTimeout(resolve, 2000)); // Fade out wave blender.fadeOutAdditive('wave', 0.5); } export { exampleMixamoSetup, exampleMixedFormats, exampleMixamoWalkAndWave, exampleRunWithGestures };