418 lines
11 KiB
JavaScript
418 lines
11 KiB
JavaScript
|
|
/**
|
||
|
|
* 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
|
||
|
|
};
|