真实接口预热

This commit is contained in:
yinsx
2026-01-04 09:58:26 +08:00
parent b56492f80f
commit 2bd183463d
10 changed files with 386 additions and 207 deletions

View File

@ -0,0 +1,8 @@
{
"hash": "7a1c4e14",
"configHash": "ff0b84f2",
"lockfileHash": "e3b0c442",
"browserHash": "3dd711d0",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -27,6 +27,9 @@ BlendShapeAnimator 是一个引擎无关的形态键动画SDK可以与任何3
```javascript
const config = {
blendShapeScale: 1.0, // 形态键强度缩放
minBlendShapeValue: 0.1, // ignore values below this to avoid spikes
deltaThreshold: 0.002, // skip setInfluence when delta is tiny
prewarmFrameCount: 30, // number of frames used for warmup
dataFps: 30, // 动画数据帧率
onStatusChange: (type, msg) => {
// 状态变化回调
@ -145,5 +148,7 @@ animator.playAnimation();
- 形态键名称会自动转换为小写进行匹配
- SDK使用 `requestAnimationFrame` 进行动画更新
- Blendshape values below 0.1 are zeroed to reduce CPU load
- Startup prewarms prewarmFrameCount frames to hide the first-frame hitch
- 空闲动画会在主动画播放时自动暂停
- 所有形态键值范围为 0-1

View File

@ -31,10 +31,48 @@ class BabylonMorphTargetAdapter {
return totalTargets;
}
async warmupInvisible(scene) {
console.log('开始预热...');
const startTime = performance.now();
const allTargets = Object.values(this.morphTargetCache).flat();
const totalTargets = allTargets.length;
console.log(`预热 ${totalTargets} 个 morph targets`);
// 多轮预热,用不同值组合
const rounds = 10;
for (let r = 0; r < rounds; r++) {
const val = (r % 2 === 0) ? 1.0 : 0;
allTargets.forEach(mt => mt.influence = val);
scene.render();
await new Promise(r => requestAnimationFrame(r));
}
// 重置
allTargets.forEach(mt => mt.influence = 0);
scene.render();
// 等待几帧让 GPU 完全稳定
for (let i = 0; i < 5; i++) {
await new Promise(r => requestAnimationFrame(r));
}
console.log(`预热完成,耗时 ${(performance.now() - startTime).toFixed(2)}ms`);
}
warmupShaders(scene) {
console.log('开始shader预热...');
const startTime = performance.now();
// 强制同步更新所有 morph target managers
this.meshes?.forEach(mesh => {
const mtm = mesh.morphTargetManager;
if (mtm) {
mtm.enableNormalMorphing = true;
mtm.enableTangentMorphing = true;
}
});
// 预热:强制触发着色器编译
// 使用多种值组合来触发所有可能的shader变体
const testValues = [0, 0.2, 0.4, 0.6, 0.8, 1.0];

View File

@ -6,6 +6,7 @@ class BabylonSceneManager {
this.scene = null;
this.camera = null;
this.onModelLoaded = null;
this.onBeforeRender = null; // 渲染前回调
}
init() {
@ -28,6 +29,10 @@ class BabylonSceneManager {
this.scene.createDefaultEnvironment();
this.engine.runRenderLoop(() => {
// 在渲染前调用动画更新
if (this.onBeforeRender) {
this.onBeforeRender();
}
this.scene.render();
});

View File

@ -2,6 +2,7 @@
class BlendShapeAnimator {
constructor(config = {}) {
this.morphTargetAdapter = null;
this.scene = null;
this.animationFrames = [];
this.animationShapeNames = [];
this.isPlaying = false;
@ -16,6 +17,17 @@ class BlendShapeAnimator {
this.streamingWaitStart = null;
this.streamingStallMs = 0;
this.sentenceTexts = []; // 句子文本列表
this.minBlendShapeValue = typeof config.minBlendShapeValue === 'number'
? config.minBlendShapeValue
: 0.1; // Skip tiny blendshape inputs to avoid stalls
this.deltaThreshold = typeof config.deltaThreshold === 'number'
? config.deltaThreshold
: 0.002; // Skip re-applying nearly identical values
this.prewarmFrameCount = typeof config.prewarmFrameCount === 'number'
? config.prewarmFrameCount
: 30;
this.lastFrameBlendShapes = {};
this._hasPrewarmed = false;
// 空闲动画参数
this.blinkParams = config.blinkParams || {
@ -63,8 +75,15 @@ class BlendShapeAnimator {
// 回调
this.onStatusChange = config.onStatusChange || (() => {});
// 启动空闲动画循环
this._updateIdleAnimations();
// 不再启动独立的 RAF 循环,由外部渲染循环调用 tick()
}
// 每帧调用,由外部渲染循环驱动
tick() {
if (this.isPlaying) {
this._animateFrameOnce();
}
this._updateIdleAnimationsOnce();
}
// 设置形态键适配器
@ -76,6 +95,8 @@ class BlendShapeAnimator {
loadAnimationFrames(frames) {
this.animationFrames = frames || [];
this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames);
this.lastFrameBlendShapes = {};
// 不重置 _hasPrewarmed因为全局预热只需要做一次
}
appendAnimationFrames(frames) {
@ -85,6 +106,7 @@ class BlendShapeAnimator {
this.animationFrames.push(...frames);
const newNames = this._collectAnimationShapeNames(frames);
// 不重置 _hasPrewarmed
if (newNames.length === 0) {
return;
@ -116,7 +138,8 @@ class BlendShapeAnimator {
}
// 播放动画
playAnimation() {
async playAnimation() {
console.log('=== playAnimation 开始 ===');
if (this.animationFrames.length === 0) {
this.onStatusChange('error', '请先加载动画数据');
return;
@ -141,45 +164,24 @@ class BlendShapeAnimator {
// 注意:不停止眨眼,让眨眼继续运行
// 预演前10帧以避免首次播放卡顿
console.log('预演前10帧...');
const framesToPreview = Math.min(10, this.animationFrames.length);
for (let i = 0; i < framesToPreview; i++) {
const frame = this.animationFrames[i];
const blendShapes = frame?.blendShapes || {};
for (const key in blendShapes) {
if (this.disabledShapesInAnimation.includes(key.toLowerCase())) {
continue;
}
this.morphTargetAdapter.setInfluence(key, blendShapes[key] * this.blendShapeScale);
}
}
// 重置为0
for (let i = 0; i < framesToPreview; i++) {
const frame = this.animationFrames[i];
const blendShapes = frame?.blendShapes || {};
for (const key in blendShapes) {
this.morphTargetAdapter.setInfluence(key, 0);
}
}
console.log('预演完成');
this.stopAnimation(false);
this.stopAnimation(false, false);
this.isPlaying = true;
this.currentFrameIndex = 0;
this.animationStartTime = performance.now();
this.streamingWaitStart = null;
this.streamingStallMs = 0;
this._animateFrame();
this._primeFirstFrame();
this._scheduleAnimationStart();
this.onStatusChange('info', '播放中...');
}
// 停止动画
stopAnimation(resumeExpressions = true) {
stopAnimation(resumeExpressions = true, resetBlendShapes = true) {
this.isPlaying = false;
this._resetAnimationInfluences();
if (resetBlendShapes) {
this._resetAnimationInfluences();
}
// 恢复眼球移动
if (resumeExpressions && this.isEyeLookEnabled) {
@ -196,12 +198,17 @@ class BlendShapeAnimator {
_collectAnimationShapeNames(frames) {
const names = new Set();
const threshold = Math.min(this.minBlendShapeValue, 0.02);
frames.forEach(frame => {
const blendShapes = frame?.blendShapes;
if (!blendShapes) return;
Object.keys(blendShapes).forEach(name => names.add(name));
Object.entries(blendShapes).forEach(([name, value]) => {
if (Math.abs(value || 0) >= threshold) {
names.add(name);
}
});
});
return Array.from(names);
@ -215,10 +222,107 @@ class BlendShapeAnimator {
this.animationShapeNames.forEach(name => {
this.morphTargetAdapter.setInfluence(name, 0);
});
this.lastFrameBlendShapes = {};
}
// 内部动画帧处理
_animateFrame() {
_prewarmAnimation() {
// 同步预热已移除,改用异步预热
this._hasPrewarmed = true;
}
// 异步分帧预热 - 预热所有 morph targets确保 GPU 真正渲染
async prewarmAsync(scene) {
if (!this.morphTargetAdapter) {
return;
}
// 获取所有可用的 morph targets而不只是当前动画用到的
const allShapes = this.morphTargetAdapter.morphTargetCache
? Object.keys(this.morphTargetAdapter.morphTargetCache)
: this.animationShapeNames;
const shapes = allShapes.filter(
name => !this.disabledShapesInAnimation.includes(name.toLowerCase())
);
if (shapes.length === 0) return;
console.log(`异步预热 ${shapes.length} 个 morph targets...`);
// 用不同的值组合预热,触发所有可能的 shader 变体
const testValues = [0.2, 0.5, 0.8, 1.0];
for (const val of testValues) {
shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, val));
if (scene) scene.render();
await new Promise(r => requestAnimationFrame(r));
}
// 重置并等待几帧确保稳定
shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, 0));
for (let i = 0; i < 5; i++) {
if (scene) scene.render();
await new Promise(r => requestAnimationFrame(r));
}
this._hasPrewarmed = true;
console.log('异步预热完成');
}
_primeFirstFrame() {
if (!this.morphTargetAdapter || this.animationFrames.length === 0) {
return;
}
const firstFrame = this.animationFrames[0];
const blendShapes = firstFrame?.blendShapes || {};
this.lastFrameBlendShapes = {};
for (const key in blendShapes) {
const lower = key.toLowerCase();
if (this.disabledShapesInAnimation.includes(lower)) {
continue;
}
const scaledValue = (blendShapes[key] || 0) * this.blendShapeScale;
if (Math.abs(scaledValue) < this.minBlendShapeValue) {
this.morphTargetAdapter.setInfluence(key, 0);
this.lastFrameBlendShapes[key] = 0;
continue;
}
this.morphTargetAdapter.setInfluence(key, scaledValue);
this.lastFrameBlendShapes[key] = scaledValue;
}
}
_touchAnimationShapes() {
if (!this.morphTargetAdapter || !Array.isArray(this.animationShapeNames) || this.animationShapeNames.length === 0) {
return;
}
const touchValue = Math.max(this.minBlendShapeValue * 1.2, 0.05);
const touched = [];
this.animationShapeNames.forEach(name => {
this.morphTargetAdapter.setInfluence(name, touchValue);
touched.push(name);
});
touched.forEach(name => {
this.morphTargetAdapter.setInfluence(name, 0);
});
}
_scheduleAnimationStart() {
// 只设置开始时间,动画由统一的 RAF 循环驱动
this.animationStartTime = performance.now();
this.streamingWaitStart = null;
this.streamingStallMs = 0;
}
// 内部动画帧处理(单次调用,不递归)
_animateFrameOnce() {
if (!this.isPlaying) return;
const frameStartTime = performance.now();
@ -232,6 +336,7 @@ class BlendShapeAnimator {
const elapsed = now - this.animationStartTime - this.streamingStallMs;
const exactFrame = elapsed / frameDuration;
const targetFrameIndex = Math.floor(exactFrame);
const frameProgress = exactFrame - targetFrameIndex;
if (targetFrameIndex >= this.animationFrames.length) {
if (this.isStreaming && !this.streamingComplete) {
@ -243,12 +348,7 @@ class BlendShapeAnimator {
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}`);
@ -260,7 +360,6 @@ class BlendShapeAnimator {
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
@ -268,8 +367,6 @@ class BlendShapeAnimator {
: Object.keys(currentBlendShapes);
let updateCount = 0;
const setInfluenceStart = performance.now();
for (const key of shapeNames) {
// 跳过禁用列表中的 blendshape,让空闲动画继续控制它们
if (this.disabledShapesInAnimation.includes(key.toLowerCase())) {
@ -281,30 +378,36 @@ class BlendShapeAnimator {
const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress);
const scaledValue = interpolatedValue * this.blendShapeScale;
const previousValue = this.lastFrameBlendShapes[key] ?? 0;
if (Math.abs(scaledValue) < this.minBlendShapeValue) {
if (Math.abs(previousValue) >= this.minBlendShapeValue) {
this.morphTargetAdapter.setInfluence(key, 0);
this.lastFrameBlendShapes[key] = 0;
updateCount++;
}
continue;
}
if (Math.abs(scaledValue - previousValue) < this.deltaThreshold) {
continue;
}
this.morphTargetAdapter.setInfluence(key, scaledValue);
this.lastFrameBlendShapes[key] = scaledValue;
updateCount++;
}
const setInfluenceTime = performance.now() - setInfluenceStart;
this._lastFrameTime = now;
this.currentFrameIndex = targetFrameIndex;
// 性能监控前100帧
if (targetFrameIndex < 100) {
const totalFrameTime = performance.now() - frameStartTime;
if (totalFrameTime > 16.67) {
console.warn(`${targetFrameIndex}耗时${totalFrameTime.toFixed(2)}ms (setInfluence: ${setInfluenceTime.toFixed(2)}ms, 调用${updateCount}次)`);
}
}
// 更新当前句子显示
const sentenceIndex = currentFrame?.sentenceIndex ?? -1;
if (sentenceIndex !== this.currentSentenceIndex) {
this.currentSentenceIndex = sentenceIndex;
this._updateCurrentSentenceDisplay();
}
requestAnimationFrame(() => this._animateFrame());
// 不再递归调用 RAF由统一循环驱动
}
// 设置空闲动画
@ -326,8 +429,8 @@ class BlendShapeAnimator {
};
}
// 更新空闲动画
_updateIdleAnimations() {
// 更新空闲动画(单次调用,不递归)
_updateIdleAnimationsOnce() {
const now = performance.now();
for (const name in this.idleAnimations) {
@ -359,8 +462,6 @@ class BlendShapeAnimator {
}
}
}
requestAnimationFrame(() => this._updateIdleAnimations());
}
// 眨眼控制

View File

@ -27,6 +27,10 @@ function init() {
});
animator.setMorphTargetAdapter(morphAdapter);
animator.scene = sceneManager.scene;
// 把动画更新挂到 Babylon 渲染循环
sceneManager.onBeforeRender = () => animator.tick();
// 导出全局变量供表情库使用
window.animator = animator;
@ -41,7 +45,7 @@ function init() {
// 加载3D模型
sceneManager.loadModel('head_a01.glb',
(meshes) => {
async (meshes) => {
showStatus("模型加载成功", "success");
const totalTargets = morphAdapter.buildCache(meshes);
@ -50,43 +54,39 @@ function init() {
} else {
console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`);
// 预热着色器以避免首次动画卡顿
console.log('预热着色器中...');
morphAdapter.warmupShaders(sceneManager.scene);
console.log('✓ 着色器预热完成');
// 播放120帧预热动画4秒
console.log('预热渲染管线中...');
const dummyFrames = [];
const allBlendShapes = Object.keys(morphAdapter.morphTargetCache);
console.log(`使用 ${allBlendShapes.length} 个blendshapes进行预热`);
for (let i = 0; i < 120; i++) {
const blendShapes = {};
const factor = Math.sin(i * 0.12) * 0.5 + 0.5;
allBlendShapes.forEach((name, index) => {
const phase = (i + index * 5) * 0.18;
const value = Math.sin(phase) * 0.5 + 0.5;
if (value > 0.25) {
blendShapes[name] = value * factor * 0.7;
// 预热:直接调用生成动画流程(和用户点击按钮完全一样)
showStatus("预热中...", "info");
const apiUrl = document.getElementById('apiUrl').value;
const streamEnabled = document.getElementById('streamEnabled')?.checked;
console.log('预热 apiUrl:', apiUrl, 'stream:', streamEnabled);
if (apiUrl) {
try {
// 和 generateAnimation 走完全一样的路径
if (streamEnabled) {
await generateAnimationStream('你好', apiUrl);
} else {
await generateAnimationBatch('你好', apiUrl);
playAnimation();
}
});
dummyFrames.push({
timeCode: i / 30,
blendShapes: blendShapes
});
// 等播放完
await new Promise(resolve => {
const check = () => animator.isPlaying ? requestAnimationFrame(check) : resolve();
check();
});
// 完全重置状态
animator.stopAnimation();
animator.loadAnimationFrames([]);
animator.endStreaming();
morphAdapter.resetAll();
console.log('✓ 预热完成');
} catch (e) {
console.warn('预热失败:', e);
}
} else {
console.warn('预热跳过: apiUrl 为空');
}
animator.loadAnimationFrames(dummyFrames);
animator.playAnimation();
setTimeout(() => {
animator.stopAnimation();
animator.loadAnimationFrames([]);
console.log('✓ 渲染管线预热完成');
}, 4000);
showStatus("就绪", "success");
}
},
(message) => {
@ -95,6 +95,44 @@ function init() {
);
}
// 偷偷调接口预热 - 完全走一遍生成动画流程
async function warmupWithApi() {
console.log('=== warmupWithApi 开始 ===');
const apiUrl = document.getElementById('apiUrl').value;
console.log('apiUrl:', apiUrl);
try {
// 完全走一遍 generateAnimationBatch 流程
console.log('调用 generateAnimationBatch...');
await generateAnimationBatch('你', apiUrl);
console.log('generateAnimationBatch 完成, frames:', animator.animationFrames.length);
// 播放动画
console.log('调用 playAnimation...');
animator.playAnimation();
// 等待播放完成
await new Promise(resolve => {
const checkDone = () => {
if (!animator.isPlaying) {
resolve();
} else {
requestAnimationFrame(checkDone);
}
};
requestAnimationFrame(checkDone);
});
// 清空
animator.loadAnimationFrames([]);
morphAdapter.resetAll();
console.log('✓ 预热完成');
} catch (e) {
console.warn('预热失败:', e.message, e);
}
}
async function generateAnimation() {
const text = document.getElementById('textInput').value.trim();
const apiUrl = document.getElementById('apiUrl').value;
@ -185,6 +223,10 @@ async function generateAnimationStream(text, apiUrl) {
const handleMessage = (message) => {
if (message.type === 'frame') {
pendingFrames.push(message.frame);
// 每100帧打印一次进度
if (pendingFrames.length % 100 === 0) {
console.log(`[前端] 已接收 ${pendingFrames.length} 帧, sentenceIndex=${message.frame.sentenceIndex}`);
}
flushFrames();
return;
}

View File

@ -1,56 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>性能测试</title>
<style>
body { font-family: monospace; padding: 20px; }
.log { margin: 5px 0; }
.warn { color: orange; }
.error { color: red; }
</style>
</head>
<body>
<h3>动画性能监控</h3>
<div id="logs"></div>
<script>
const logs = document.getElementById('logs');
// 监控 requestAnimationFrame 性能
let lastTime = performance.now();
let frameCount = 0;
let maxFrameTime = 0;
function monitorFrame() {
const now = performance.now();
const delta = now - lastTime;
if (frameCount > 0 && delta > 20) {
const log = document.createElement('div');
log.className = delta > 50 ? 'log error' : 'log warn';
log.textContent = `${frameCount}: ${delta.toFixed(2)}ms (目标: 16.67ms)`;
logs.appendChild(log);
if (delta > maxFrameTime) {
maxFrameTime = delta;
}
}
lastTime = now;
frameCount++;
if (frameCount < 300) {
requestAnimationFrame(monitorFrame);
} else {
const summary = document.createElement('div');
summary.className = 'log';
summary.textContent = `\n总结: 最大帧时间 ${maxFrameTime.toFixed(2)}ms`;
logs.appendChild(summary);
}
}
console.log('性能监控已启动将监控前300帧');
requestAnimationFrame(monitorFrame);
</script>
</body>
</html>