Files
a2f-service/examples/3d/blendshapeAnimator.js
2025-12-26 15:51:59 +08:00

572 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// BlendShape Animation SDK - Engine Agnostic
class BlendShapeAnimator {
constructor(config = {}) {
this.morphTargetAdapter = null;
this.animationFrames = [];
this.animationShapeNames = [];
this.isPlaying = false;
this.currentFrameIndex = 0;
this.currentSentenceIndex = -1;
this.animationStartTime = 0;
this.idleAnimations = {};
this.blendShapeScale = config.blendShapeScale || 1.0;
this.dataFps = config.dataFps || 30;
this.isStreaming = false;
this.streamingComplete = true;
this.streamingWaitStart = null;
this.streamingStallMs = 0;
this.sentenceTexts = []; // 句子文本列表
// 空闲动画参数
this.blinkParams = config.blinkParams || {
intervalMin: 2000,
intervalMax: 5000,
duration: 150,
speed: 100
};
this.eyeLookParams = config.eyeLookParams || {
intervalMin: 2000,
intervalMax: 6000,
durationMin: 1000,
durationMax: 2500,
speed: 250
};
this.expressionParams = config.expressionParams || {
intervalMin: 3000,
intervalMax: 8000,
speed: 400
};
this.enabledExpressions = new Set();
this.expressionDurations = {};
// 播放动画时禁用的 blendshape 列表(由空闲动画控制)
this.disabledShapesInAnimation = config.disabledShapesInAnimation || [
'eyeblinkleft', 'eyeblinkright', // 眨眼
'browdownleft', 'browdownright', // 眉毛下
'browinnerup', // 眉毛内上
'browouterupleft', 'browouterupright' // 眉毛外上
];
// 状态标志
this.isBlinkEnabled = false;
this.isEyeLookEnabled = false;
this.isExpressionEnabled = false;
// 定时器
this.blinkInterval = null;
this.eyeLookInterval = null;
// 回调
this.onStatusChange = config.onStatusChange || (() => {});
// 启动空闲动画循环
this._updateIdleAnimations();
}
// 设置形态键适配器
setMorphTargetAdapter(adapter) {
this.morphTargetAdapter = adapter;
}
// 加载动画帧数据
loadAnimationFrames(frames) {
this.animationFrames = frames || [];
this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames);
}
appendAnimationFrames(frames) {
if (!Array.isArray(frames) || frames.length === 0) {
return;
}
this.animationFrames.push(...frames);
const newNames = this._collectAnimationShapeNames(frames);
if (newNames.length === 0) {
return;
}
const existingNames = new Set(this.animationShapeNames);
newNames.forEach(name => {
if (!existingNames.has(name)) {
existingNames.add(name);
this.animationShapeNames.push(name);
}
});
}
startStreaming() {
console.log('Starting streaming mode');
this.isStreaming = true;
this.streamingComplete = false;
this.streamingWaitStart = null;
this.streamingStallMs = 0;
}
endStreaming() {
console.log('Ending streaming mode');
this.streamingComplete = true;
this.isStreaming = false;
this.streamingWaitStart = null;
this.streamingStallMs = 0;
}
// 播放动画
playAnimation() {
if (this.animationFrames.length === 0) {
this.onStatusChange('error', '请先加载动画数据');
return;
}
if (!this.morphTargetAdapter) {
this.onStatusChange('error', '未设置形态键适配器');
return;
}
// 停止并立即重置眼球移动
if (this.isEyeLookEnabled) {
this._stopRandomEyeLook();
this._immediateResetEyeLook();
}
// 停止并立即重置随机表情
if (this.isExpressionEnabled && window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.stop();
this._immediateResetExpressions();
}
// 注意:不停止眨眼,让眨眼继续运行
this.stopAnimation(false);
this.isPlaying = true;
this.currentFrameIndex = 0;
this.animationStartTime = performance.now();
this.streamingWaitStart = null;
this.streamingStallMs = 0;
this._animateFrame();
this.onStatusChange('info', '播放中...');
}
// 停止动画
stopAnimation(resumeExpressions = true) {
this.isPlaying = false;
this._resetAnimationInfluences();
// 恢复眼球移动
if (resumeExpressions && this.isEyeLookEnabled) {
this._startRandomEyeLook();
}
// 恢复随机表情
if (resumeExpressions && this.isExpressionEnabled && window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.start();
}
this.onStatusChange('info', '已停止');
}
_collectAnimationShapeNames(frames) {
const names = new Set();
frames.forEach(frame => {
const blendShapes = frame?.blendShapes;
if (!blendShapes) return;
Object.keys(blendShapes).forEach(name => names.add(name));
});
return Array.from(names);
}
_resetAnimationInfluences() {
if (!this.morphTargetAdapter || this.animationShapeNames.length === 0) {
return;
}
this.animationShapeNames.forEach(name => {
this.morphTargetAdapter.setInfluence(name, 0);
});
}
// 内部动画帧处理
_animateFrame() {
if (!this.isPlaying) return;
const now = performance.now();
if (this.streamingWaitStart !== null && this.animationFrames.length > this.currentFrameIndex + 1) {
this.streamingStallMs += now - this.streamingWaitStart;
this.streamingWaitStart = null;
}
const frameDuration = 1000 / this.dataFps;
const elapsed = now - this.animationStartTime - this.streamingStallMs;
const exactFrame = elapsed / frameDuration;
const targetFrameIndex = Math.floor(exactFrame);
if (targetFrameIndex >= this.animationFrames.length) {
if (this.isStreaming && !this.streamingComplete) {
if (this.streamingWaitStart === null) {
this.streamingWaitStart = now;
console.log(`Waiting for more frames... (current: ${this.animationFrames.length}, target: ${targetFrameIndex})`);
}
const waitTime = now - this.streamingWaitStart;
if (waitTime > 30000) {
console.warn('Streaming timeout after 30s, stopping animation');
this.stopAnimation();
return;
}
if (waitTime > 1000 && Math.floor(waitTime / 1000) !== Math.floor((waitTime - 16) / 1000)) {
console.log(`Still waiting... ${Math.floor(waitTime / 1000)}s`);
}
requestAnimationFrame(() => this._animateFrame());
return;
}
console.log(`Animation complete. Total frames: ${this.animationFrames.length}`);
this.stopAnimation();
return;
}
const currentFrame = this.animationFrames[targetFrameIndex];
const nextFrame = this.animationFrames[Math.min(targetFrameIndex + 1, this.animationFrames.length - 1)];
const currentBlendShapes = currentFrame?.blendShapes || {};
const nextBlendShapes = nextFrame?.blendShapes || {};
const frameProgress = exactFrame - targetFrameIndex;
const smoothProgress = this._easeOutQuad(frameProgress);
const shapeNames = this.animationShapeNames.length > 0
? this.animationShapeNames
: Object.keys(currentBlendShapes);
for (const key of shapeNames) {
// 跳过禁用列表中的 blendshape让空闲动画继续控制它们
if (this.disabledShapesInAnimation.includes(key.toLowerCase())) {
continue;
}
const currentValue = currentBlendShapes[key] || 0;
const nextValue = nextBlendShapes[key] || 0;
const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress);
const scaledValue = interpolatedValue * this.blendShapeScale;
this.morphTargetAdapter.setInfluence(key, scaledValue);
}
this.currentFrameIndex = targetFrameIndex;
// 更新当前句子显示
const sentenceIndex = currentFrame?.sentenceIndex ?? -1;
if (sentenceIndex !== this.currentSentenceIndex) {
this.currentSentenceIndex = sentenceIndex;
this._updateCurrentSentenceDisplay();
}
requestAnimationFrame(() => this._animateFrame());
}
// 设置空闲动画
setIdleAnimation(name, target, duration = 200, easing = 'easeOutQuad') {
this.idleAnimations[name] = {
target: target,
duration: duration,
easing: easing,
startTime: null,
startValue: this.idleAnimations[name]?.target || 0
};
}
// 更新空闲动画
_updateIdleAnimations() {
const now = performance.now();
for (const name in this.idleAnimations) {
const anim = this.idleAnimations[name];
if (!this.morphTargetAdapter) continue;
if (!anim.startTime) {
anim.startTime = now;
anim.startValue = this.morphTargetAdapter.getInfluence(name) || 0;
}
const elapsed = now - anim.startTime;
const duration = anim.duration || 200;
let progress = Math.min(elapsed / duration, 1.0);
progress = this._applyEasing(progress, anim.easing);
const currentValue = this._lerp(anim.startValue, anim.target, progress);
this.morphTargetAdapter.setInfluence(name, currentValue);
if (progress >= 1.0) {
if (anim.target === 0) {
delete this.idleAnimations[name];
} else {
anim.startTime = null;
}
}
}
requestAnimationFrame(() => this._updateIdleAnimations());
}
// 眨眼控制
toggleBlink(enabled) {
this.isBlinkEnabled = enabled;
if (enabled) {
this._startRandomBlink();
this.onStatusChange('success', '随机眨眼已开启');
} else {
this._stopRandomBlink();
this.onStatusChange('info', '随机眨眼已关闭');
}
}
_startRandomBlink() {
this._stopRandomBlink();
const scheduleNext = () => {
const delay = this.blinkParams.intervalMin + Math.random() * (this.blinkParams.intervalMax - this.blinkParams.intervalMin);
this.blinkInterval = setTimeout(() => {
if (this.isBlinkEnabled) {
this._doBlink();
const holdDuration = Math.max(0, this.blinkParams.duration || 0);
this.blinkInterval = setTimeout(() => {
if (this.isBlinkEnabled) {
scheduleNext();
}
}, holdDuration);
}
}, delay);
};
scheduleNext();
}
_stopRandomBlink() {
if (this.blinkInterval) {
clearTimeout(this.blinkInterval);
this.blinkInterval = null;
}
}
_doBlink() {
const blinkShapes = ['eyeblinkleft', 'eyeblinkright'];
blinkShapes.forEach(name => {
this.setIdleAnimation(name, 1.0, this.blinkParams.speed, 'easeOutQuad');
});
setTimeout(() => {
blinkShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.blinkParams.speed + 20, 'easeInOutQuad');
});
}, this.blinkParams.duration);
}
// 眼球移动控制
toggleEyeLook(enabled) {
this.isEyeLookEnabled = enabled;
if (enabled) {
this._startRandomEyeLook();
this.onStatusChange('success', '眼球移动已开启');
} else {
this._stopRandomEyeLook();
this._resetEyeLook();
this.onStatusChange('info', '眼球移动已关闭');
}
}
_startRandomEyeLook() {
this._stopRandomEyeLook();
const scheduleNext = () => {
const delay = this.eyeLookParams.intervalMin + Math.random() * (this.eyeLookParams.intervalMax - this.eyeLookParams.intervalMin);
this.eyeLookInterval = setTimeout(() => {
if (this.isEyeLookEnabled) {
const holdDuration = this._doRandomEyeLook();
const waitDuration = Math.max(0, holdDuration || 0);
this.eyeLookInterval = setTimeout(() => {
if (this.isEyeLookEnabled) {
scheduleNext();
}
}, waitDuration);
}
}, delay);
};
scheduleNext();
}
_stopRandomEyeLook() {
if (this.eyeLookInterval) {
clearTimeout(this.eyeLookInterval);
this.eyeLookInterval = null;
}
}
_doRandomEyeLook() {
const directions = [
{ shapes: {} },
{ shapes: { 'eyelookupleft': 0.3, 'eyelookupright': 0.3 } },
{ shapes: { 'eyelookdownleft': 0.3, 'eyelookdownright': 0.3 } },
{ shapes: { 'eyelookinleft': 0.4, 'eyelookoutright': 0.4 } },
{ shapes: { 'eyelookoutleft': 0.4, 'eyelookinright': 0.4 } }
];
const direction = directions[Math.floor(Math.random() * directions.length)];
const eyeLookShapes = [
'eyelookupleft', 'eyelookupright',
'eyelookdownleft', 'eyelookdownright',
'eyelookinleft', 'eyelookinright',
'eyelookoutleft', 'eyelookoutright'
];
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 0.8, 'easeInOutQuad');
});
for (const [name, value] of Object.entries(direction.shapes)) {
this.setIdleAnimation(name, value, this.eyeLookParams.speed, 'easeInOutQuad');
}
const holdDuration = this.eyeLookParams.durationMin + Math.random() * (this.eyeLookParams.durationMax - this.eyeLookParams.durationMin);
setTimeout(() => {
if (this.isEyeLookEnabled) {
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 1.2, 'easeInOutQuad');
});
}
}, holdDuration);
return holdDuration;
}
_resetEyeLook() {
const eyeLookShapes = [
'eyelookupleft', 'eyelookupright',
'eyelookdownleft', 'eyelookdownright',
'eyelookinleft', 'eyelookinright',
'eyelookoutleft', 'eyelookoutright'
];
eyeLookShapes.forEach(name => {
this.setIdleAnimation(name, 0, this.eyeLookParams.speed, 'easeInOutQuad');
});
}
_immediateResetEyeLook() {
const eyeLookShapes = [
'eyelookupleft', 'eyelookupright',
'eyelookdownleft', 'eyelookdownright',
'eyelookinleft', 'eyelookinright',
'eyelookoutleft', 'eyelookoutright'
];
if (!this.morphTargetAdapter) return;
eyeLookShapes.forEach(name => {
this.morphTargetAdapter.setInfluence(name, 0);
delete this.idleAnimations[name];
});
}
_immediateResetExpressions() {
if (!this.morphTargetAdapter || !window.ExpressionLibrary) return;
// 获取所有表情的 blendshape 名称并立即重置
const expressions = window.ExpressionLibrary.expressions;
for (const exprKey in expressions) {
const expr = expressions[exprKey];
if (expr.blendShapes) {
for (const shapeName in expr.blendShapes) {
this.morphTargetAdapter.setInfluence(shapeName, 0);
delete this.idleAnimations[shapeName];
}
}
}
}
// 随机表情控制
toggleRandomExpression(enabled) {
if (!window.ExpressionLibrary) {
this.onStatusChange('error', '表情库未加载');
return;
}
this.isExpressionEnabled = enabled;
if (enabled) {
window.ExpressionLibrary.randomPlayer.intervalMin = this.expressionParams.intervalMin;
window.ExpressionLibrary.randomPlayer.intervalMax = this.expressionParams.intervalMax;
if (!this.isPlaying) {
window.ExpressionLibrary.randomPlayer.start();
}
this.onStatusChange('success', '随机表情已开启');
} else {
window.ExpressionLibrary.randomPlayer.stop();
this.onStatusChange('info', '随机表情已关闭');
}
}
// 缓动函数
_easeOutQuad(t) {
return t * (2 - t);
}
_lerp(start, end, t) {
return start + (end - start) * t;
}
_updateCurrentSentenceDisplay() {
const sentenceDiv = document.getElementById('currentSentence');
const sentenceText = document.getElementById('sentenceText');
if (!sentenceDiv || !sentenceText) return;
if (this.currentSentenceIndex >= 0 && this.currentSentenceIndex < this.sentenceTexts.length) {
sentenceDiv.style.display = 'block';
sentenceText.textContent = this.sentenceTexts[this.currentSentenceIndex];
console.log(`[前端调试] 显示句子 ${this.currentSentenceIndex}: ${this.sentenceTexts[this.currentSentenceIndex]}`);
} else {
sentenceDiv.style.display = 'none';
}
}
_applyEasing(t, type) {
switch(type) {
case 'easeOutQuad':
return t * (2 - t);
case 'easeInOutQuad':
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
case 'easeInOutCubic':
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
case 'easeOutCubic':
return 1 - Math.pow(1 - t, 3);
case 'linear':
default:
return t;
}
}
// 更新配置
updateConfig(key, value) {
if (key === 'blendShapeScale') {
this.blendShapeScale = value;
} else if (this[key]) {
this[key] = value;
}
}
}
// 导出到全局
if (typeof window !== 'undefined') {
window.BlendShapeAnimator = BlendShapeAnimator;
}