diff --git a/examples/3d/.vite/deps/_metadata.json b/examples/3d/.vite/deps/_metadata.json new file mode 100644 index 0000000..53188be --- /dev/null +++ b/examples/3d/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "7a1c4e14", + "configHash": "ff0b84f2", + "lockfileHash": "e3b0c442", + "browserHash": "3dd711d0", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/examples/3d/.vite/deps/package.json b/examples/3d/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/examples/3d/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/examples/3d/SDK_USAGE.md b/examples/3d/SDK_USAGE.md index 42a28a5..53564d0 100644 --- a/examples/3d/SDK_USAGE.md +++ b/examples/3d/SDK_USAGE.md @@ -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 diff --git a/examples/3d/babylonAdapter.js b/examples/3d/babylonAdapter.js index 4951c9b..b2bf58a 100644 --- a/examples/3d/babylonAdapter.js +++ b/examples/3d/babylonAdapter.js @@ -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]; diff --git a/examples/3d/babylonScene.js b/examples/3d/babylonScene.js index f1a57e8..34fdc10 100644 --- a/examples/3d/babylonScene.js +++ b/examples/3d/babylonScene.js @@ -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(); }); diff --git a/examples/3d/blendshapeAnimator.js b/examples/3d/blendshapeAnimator.js index 44da432..d8daa07 100644 --- a/examples/3d/blendshapeAnimator.js +++ b/examples/3d/blendshapeAnimator.js @@ -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()); } // 眨眼控制 diff --git a/examples/3d/main.js b/examples/3d/main.js index dc0e597..84ced5d 100644 --- a/examples/3d/main.js +++ b/examples/3d/main.js @@ -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; } diff --git a/examples/3d/performance-test.html b/examples/3d/performance-test.html deleted file mode 100644 index 75339eb..0000000 --- a/examples/3d/performance-test.html +++ /dev/null @@ -1,56 +0,0 @@ - - -
-