410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
// BlendShape Animation SDK - Engine Agnostic
|
|
class BlendShapeAnimator {
|
|
constructor(config = {}) {
|
|
this.morphTargetAdapter = null;
|
|
this.animationFrames = [];
|
|
this.animationShapeNames = [];
|
|
this.isPlaying = false;
|
|
this.currentFrameIndex = 0;
|
|
this.animationStartTime = 0;
|
|
this.idleAnimations = {};
|
|
this.blendShapeScale = config.blendShapeScale || 1.0;
|
|
this.dataFps = config.dataFps || 30;
|
|
|
|
// 空闲动画参数
|
|
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 = {};
|
|
|
|
// 状态标志
|
|
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);
|
|
}
|
|
|
|
// 播放动画
|
|
playAnimation() {
|
|
if (this.animationFrames.length === 0) {
|
|
this.onStatusChange('error', '请先加载动画数据');
|
|
return;
|
|
}
|
|
|
|
if (!this.morphTargetAdapter) {
|
|
this.onStatusChange('error', '未设置形态键适配器');
|
|
return;
|
|
}
|
|
|
|
// 停止随机表情
|
|
if (this.isExpressionEnabled && window.ExpressionLibrary) {
|
|
window.ExpressionLibrary.randomPlayer.stop();
|
|
}
|
|
|
|
this.stopAnimation(false);
|
|
this.isPlaying = true;
|
|
this.currentFrameIndex = 0;
|
|
this.animationStartTime = performance.now();
|
|
|
|
this._animateFrame();
|
|
this.onStatusChange('info', '播放中...');
|
|
}
|
|
|
|
// 停止动画
|
|
stopAnimation(resumeExpressions = true) {
|
|
this.isPlaying = false;
|
|
this._resetAnimationInfluences();
|
|
|
|
// 恢复随机表情
|
|
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();
|
|
const frameDuration = 1000 / this.dataFps;
|
|
const elapsed = now - this.animationStartTime;
|
|
const exactFrame = elapsed / frameDuration;
|
|
const targetFrameIndex = Math.floor(exactFrame);
|
|
|
|
if (targetFrameIndex >= 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) {
|
|
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;
|
|
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();
|
|
scheduleNext();
|
|
}
|
|
}, 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) {
|
|
this._doRandomEyeLook();
|
|
scheduleNext();
|
|
}
|
|
}, 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);
|
|
}
|
|
|
|
_resetEyeLook() {
|
|
const eyeLookShapes = [
|
|
'eyelookupleft', 'eyelookupright',
|
|
'eyelookdownleft', 'eyelookdownright',
|
|
'eyelookinleft', 'eyelookinright',
|
|
'eyelookoutleft', 'eyelookoutright'
|
|
];
|
|
|
|
eyeLookShapes.forEach(name => {
|
|
this.setIdleAnimation(name, 0, this.eyeLookParams.speed, 'easeInOutQuad');
|
|
});
|
|
}
|
|
|
|
// 随机表情控制
|
|
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;
|
|
}
|
|
|
|
_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;
|
|
}
|