Ai_Assistant/client/_archive/AnimationBlendingExample.js

418 lines
11 KiB
JavaScript
Raw Normal View History

2026-05-24 13:31:30 +02:00
/**
* 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
};