diff --git a/examples/3d/babylonAdapter.js b/examples/3d/babylonAdapter.js index b45ba31..4951c9b 100644 --- a/examples/3d/babylonAdapter.js +++ b/examples/3d/babylonAdapter.js @@ -6,6 +6,7 @@ class BabylonMorphTargetAdapter { buildCache(meshes) { this.morphTargetCache = {}; + this.meshes = meshes; let totalTargets = 0; meshes.forEach(mesh => { @@ -30,6 +31,44 @@ class BabylonMorphTargetAdapter { return totalTargets; } + warmupShaders(scene) { + console.log('开始shader预热...'); + const startTime = performance.now(); + + // 预热:强制触发着色器编译 + // 使用多种值组合来触发所有可能的shader变体 + const testValues = [0, 0.2, 0.4, 0.6, 0.8, 1.0]; + + for (let pass = 0; pass < testValues.length; pass++) { + const value = testValues[pass]; + for (const targets of Object.values(this.morphTargetCache)) { + targets.forEach(mt => { + mt.influence = value; + }); + } + + // 每次设置后都渲染,确保shader编译 + if (scene) { + scene.render(); + } + } + + // 重置所有影响值 + for (const targets of Object.values(this.morphTargetCache)) { + targets.forEach(mt => { + mt.influence = 0; + }); + } + + // 最后渲染一次确保重置生效 + if (scene) { + scene.render(); + } + + const elapsed = performance.now() - startTime; + console.log(`shader预热完成,耗时 ${elapsed.toFixed(2)}ms`); + } + setInfluence(name, value) { const lowerName = name.toLowerCase(); const targets = this.morphTargetCache[lowerName]; diff --git a/examples/3d/blendshapeAnimator.js b/examples/3d/blendshapeAnimator.js index ba48496..44da432 100644 --- a/examples/3d/blendshapeAnimator.js +++ b/examples/3d/blendshapeAnimator.js @@ -141,6 +141,29 @@ 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.isPlaying = true; this.currentFrameIndex = 0; @@ -149,6 +172,7 @@ class BlendShapeAnimator { this.streamingStallMs = 0; this._animateFrame(); + this.onStatusChange('info', '播放中...'); } @@ -197,7 +221,8 @@ class BlendShapeAnimator { _animateFrame() { if (!this.isPlaying) return; - const now = performance.now(); + const frameStartTime = performance.now(); + const now = frameStartTime; if (this.streamingWaitStart !== null && this.animationFrames.length > this.currentFrameIndex + 1) { this.streamingStallMs += now - this.streamingWaitStart; this.streamingWaitStart = null; @@ -242,8 +267,11 @@ class BlendShapeAnimator { ? this.animationShapeNames : Object.keys(currentBlendShapes); + let updateCount = 0; + const setInfluenceStart = performance.now(); + for (const key of shapeNames) { - // 跳过禁用列表中的 blendshape,让空闲动画继续控制它们 + // 跳过禁用列表中的 blendshape,让空闲动画继续控制它们 if (this.disabledShapesInAnimation.includes(key.toLowerCase())) { continue; } @@ -254,10 +282,21 @@ class BlendShapeAnimator { const scaledValue = interpolatedValue * this.blendShapeScale; this.morphTargetAdapter.setInfluence(key, scaledValue); + updateCount++; } + const setInfluenceTime = performance.now() - setInfluenceStart; + 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) { diff --git a/examples/3d/main.js b/examples/3d/main.js index cd3736e..dc0e597 100644 --- a/examples/3d/main.js +++ b/examples/3d/main.js @@ -49,6 +49,44 @@ function init() { showStatus("警告: 未找到形态键", "error"); } 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; + } + }); + + dummyFrames.push({ + timeCode: i / 30, + blendShapes: blendShapes + }); + } + + animator.loadAnimationFrames(dummyFrames); + animator.playAnimation(); + + setTimeout(() => { + animator.stopAnimation(); + animator.loadAnimationFrames([]); + console.log('✓ 渲染管线预热完成'); + }, 4000); } }, (message) => { @@ -105,72 +143,56 @@ async function generateAnimationBatch(text, apiUrl) { async function generateAnimationStream(text, apiUrl) { const url = normalizeApiUrl(apiUrl, true); - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: text, language: 'zh-CN', first_sentence_split_size: 1 }) - }); - - if (!response.ok) { - let errorMessage = `请求失败 (${response.status})`; - try { - const data = await response.json(); - if (data?.error) { - errorMessage = data.error; - } - } catch (err) { - // ignore json parse errors - } - throw new Error(errorMessage); - } - - if (!response.body) { - await generateAnimationBatch(text, apiUrl); - return; - } animator.stopAnimation(); animator.loadAnimationFrames([]); animator.startStreaming(); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; let started = false; const pendingFrames = []; - const streamBufferMs = 300; + const streamBufferMs = 1500; const flushBatchMs = 50; const minStartFrames = Math.max(1, Math.round(animator.dataFps * (streamBufferMs / 1000))); const frameBatchSize = Math.max(1, Math.round(animator.dataFps * (flushBatchMs / 1000))); - let sentenceTexts = []; // 存储句子文本 + let sentenceTexts = []; const flushFrames = (force = false) => { - if (pendingFrames.length === 0) { - return; - } - if (!force && pendingFrames.length < frameBatchSize) { - return; - } - const framesToFlush = pendingFrames.splice(0, pendingFrames.length); - animator.appendAnimationFrames(framesToFlush); - if (!started && animator.animationFrames.length >= minStartFrames) { - animator.playAnimation(); - started = true; + if (pendingFrames.length === 0) return; + + const minFlushSize = started ? frameBatchSize : minStartFrames; + if (!force && pendingFrames.length < minFlushSize) return; + + const doFlush = () => { + const framesToFlush = pendingFrames.splice(0, pendingFrames.length); + animator.appendAnimationFrames(framesToFlush); + if (!started && animator.animationFrames.length >= minStartFrames) { + console.log(`开始播放,已缓冲 ${animator.animationFrames.length} 帧`); + animator.playAnimation(); + started = true; + } + }; + + if (force || !started) { + doFlush(); + } else { + if (window.requestIdleCallback) { + window.requestIdleCallback(doFlush, { timeout: 50 }); + } else { + setTimeout(doFlush, 0); + } } }; - const handleMessage = (message) => { if (message.type === 'frame') { pendingFrames.push(message.frame); flushFrames(); return; } - + if (message.type === 'status') { const stageMessage = message.message || 'Streaming'; showStatus(stageMessage, 'info'); console.log('Stream status:', message); - // 保存句子文本并传递给动画器 if (message.sentence_texts) { sentenceTexts = message.sentence_texts; animator.sentenceTexts = sentenceTexts; @@ -197,52 +219,52 @@ async function generateAnimationStream(text, apiUrl) { } }; - let streamError = null; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; + return new Promise((resolve, reject) => { + const worker = new Worker('streamWorker.js'); + + worker.onmessage = (e) => { + const { type, message, error } = e.data; + + if (type === 'error') { + worker.terminate(); + animator.endStreaming(); + reject(new Error(message || error)); + return; } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.trim()) { - continue; - } - - let message; + if (type === 'message') { try { - message = JSON.parse(line); + handleMessage(message); } catch (err) { - continue; + worker.terminate(); + animator.endStreaming(); + reject(err); } + return; + } - handleMessage(message); + if (type === 'complete') { + worker.terminate(); + flushFrames(true); + if (!started && animator.animationFrames.length > 0) { + animator.playAnimation(); + } + animator.endStreaming(); + resolve(); } - } - if (buffer.trim()) { - try { - handleMessage(JSON.parse(buffer)); - } catch (err) { - // ignore trailing parse errors - } - } - } catch (err) { - streamError = err; - throw err; - } finally { - if (!streamError) { - flushFrames(true); - if (!started && animator.animationFrames.length > 0) { - animator.playAnimation(); - } - } - animator.endStreaming(); - } + }; + + worker.onerror = (err) => { + worker.terminate(); + animator.endStreaming(); + reject(new Error('Worker error: ' + err.message)); + }; + + worker.postMessage({ + url: url, + body: JSON.stringify({ text: text, language: 'zh-CN', first_sentence_split_size: 1 }) + }); + }); } function normalizeApiUrl(apiUrl, streamEnabled) { @@ -343,7 +365,7 @@ function updateBlinkInterval() { const max = parseFloat(document.getElementById('blinkIntervalMax').value) * 1000; animator.blinkParams.intervalMin = min; animator.blinkParams.intervalMax = max; - document.getElementById('blinkIntervalValue').textContent = `${min/1000}-${max/1000}`; + document.getElementById('blinkIntervalValue').textContent = `${min / 1000}-${max / 1000}`; } function updateBlinkDuration(value) { @@ -361,7 +383,7 @@ function updateEyeLookInterval() { const max = parseFloat(document.getElementById('eyeLookIntervalMax').value) * 1000; animator.eyeLookParams.intervalMin = min; animator.eyeLookParams.intervalMax = max; - document.getElementById('eyeLookIntervalValue').textContent = `${min/1000}-${max/1000}`; + document.getElementById('eyeLookIntervalValue').textContent = `${min / 1000}-${max / 1000}`; } function updateEyeLookDuration() { @@ -382,7 +404,7 @@ function updateExpressionInterval() { const max = parseFloat(document.getElementById('expressionIntervalMax').value) * 1000; animator.expressionParams.intervalMin = min; animator.expressionParams.intervalMax = max; - document.getElementById('expressionIntervalValue').textContent = `${min/1000}-${max/1000}`; + document.getElementById('expressionIntervalValue').textContent = `${min / 1000}-${max / 1000}`; if (window.ExpressionLibrary) { window.ExpressionLibrary.randomPlayer.intervalMin = min; diff --git a/examples/3d/performance-test.html b/examples/3d/performance-test.html new file mode 100644 index 0000000..75339eb --- /dev/null +++ b/examples/3d/performance-test.html @@ -0,0 +1,56 @@ + + +
+