形态键预热

This commit is contained in:
yinsx
2025-12-29 11:22:51 +08:00
parent 6e61fd81b3
commit b56492f80f
7 changed files with 314 additions and 86 deletions

View File

@ -6,6 +6,7 @@ class BabylonMorphTargetAdapter {
buildCache(meshes) { buildCache(meshes) {
this.morphTargetCache = {}; this.morphTargetCache = {};
this.meshes = meshes;
let totalTargets = 0; let totalTargets = 0;
meshes.forEach(mesh => { meshes.forEach(mesh => {
@ -30,6 +31,44 @@ class BabylonMorphTargetAdapter {
return totalTargets; 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) { setInfluence(name, value) {
const lowerName = name.toLowerCase(); const lowerName = name.toLowerCase();
const targets = this.morphTargetCache[lowerName]; const targets = this.morphTargetCache[lowerName];

View File

@ -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.stopAnimation(false);
this.isPlaying = true; this.isPlaying = true;
this.currentFrameIndex = 0; this.currentFrameIndex = 0;
@ -149,6 +172,7 @@ class BlendShapeAnimator {
this.streamingStallMs = 0; this.streamingStallMs = 0;
this._animateFrame(); this._animateFrame();
this.onStatusChange('info', '播放中...'); this.onStatusChange('info', '播放中...');
} }
@ -197,7 +221,8 @@ class BlendShapeAnimator {
_animateFrame() { _animateFrame() {
if (!this.isPlaying) return; 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) { if (this.streamingWaitStart !== null && this.animationFrames.length > this.currentFrameIndex + 1) {
this.streamingStallMs += now - this.streamingWaitStart; this.streamingStallMs += now - this.streamingWaitStart;
this.streamingWaitStart = null; this.streamingWaitStart = null;
@ -242,8 +267,11 @@ class BlendShapeAnimator {
? this.animationShapeNames ? this.animationShapeNames
: Object.keys(currentBlendShapes); : Object.keys(currentBlendShapes);
let updateCount = 0;
const setInfluenceStart = performance.now();
for (const key of shapeNames) { for (const key of shapeNames) {
// 跳过禁用列表中的 blendshape让空闲动画继续控制它们 // 跳过禁用列表中的 blendshape,让空闲动画继续控制它们
if (this.disabledShapesInAnimation.includes(key.toLowerCase())) { if (this.disabledShapesInAnimation.includes(key.toLowerCase())) {
continue; continue;
} }
@ -254,10 +282,21 @@ class BlendShapeAnimator {
const scaledValue = interpolatedValue * this.blendShapeScale; const scaledValue = interpolatedValue * this.blendShapeScale;
this.morphTargetAdapter.setInfluence(key, scaledValue); this.morphTargetAdapter.setInfluence(key, scaledValue);
updateCount++;
} }
const setInfluenceTime = performance.now() - setInfluenceStart;
this.currentFrameIndex = targetFrameIndex; 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; const sentenceIndex = currentFrame?.sentenceIndex ?? -1;
if (sentenceIndex !== this.currentSentenceIndex) { if (sentenceIndex !== this.currentSentenceIndex) {

View File

@ -49,6 +49,44 @@ function init() {
showStatus("警告: 未找到形态键", "error"); showStatus("警告: 未找到形态键", "error");
} else { } else {
console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`); 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) => { (message) => {
@ -105,60 +143,45 @@ async function generateAnimationBatch(text, apiUrl) {
async function generateAnimationStream(text, apiUrl) { async function generateAnimationStream(text, apiUrl) {
const url = normalizeApiUrl(apiUrl, true); 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.stopAnimation();
animator.loadAnimationFrames([]); animator.loadAnimationFrames([]);
animator.startStreaming(); animator.startStreaming();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let started = false; let started = false;
const pendingFrames = []; const pendingFrames = [];
const streamBufferMs = 300; const streamBufferMs = 1500;
const flushBatchMs = 50; const flushBatchMs = 50;
const minStartFrames = Math.max(1, Math.round(animator.dataFps * (streamBufferMs / 1000))); const minStartFrames = Math.max(1, Math.round(animator.dataFps * (streamBufferMs / 1000)));
const frameBatchSize = Math.max(1, Math.round(animator.dataFps * (flushBatchMs / 1000))); const frameBatchSize = Math.max(1, Math.round(animator.dataFps * (flushBatchMs / 1000)));
let sentenceTexts = []; // 存储句子文本 let sentenceTexts = [];
const flushFrames = (force = false) => { const flushFrames = (force = false) => {
if (pendingFrames.length === 0) { if (pendingFrames.length === 0) return;
return;
} const minFlushSize = started ? frameBatchSize : minStartFrames;
if (!force && pendingFrames.length < frameBatchSize) { if (!force && pendingFrames.length < minFlushSize) return;
return;
} const doFlush = () => {
const framesToFlush = pendingFrames.splice(0, pendingFrames.length); const framesToFlush = pendingFrames.splice(0, pendingFrames.length);
animator.appendAnimationFrames(framesToFlush); animator.appendAnimationFrames(framesToFlush);
if (!started && animator.animationFrames.length >= minStartFrames) { if (!started && animator.animationFrames.length >= minStartFrames) {
animator.playAnimation(); console.log(`开始播放,已缓冲 ${animator.animationFrames.length}`);
started = true; animator.playAnimation();
started = true;
}
};
if (force || !started) {
doFlush();
} else {
if (window.requestIdleCallback) {
window.requestIdleCallback(doFlush, { timeout: 50 });
} else {
setTimeout(doFlush, 0);
}
} }
}; };
const handleMessage = (message) => { const handleMessage = (message) => {
if (message.type === 'frame') { if (message.type === 'frame') {
pendingFrames.push(message.frame); pendingFrames.push(message.frame);
@ -170,7 +193,6 @@ async function generateAnimationStream(text, apiUrl) {
const stageMessage = message.message || 'Streaming'; const stageMessage = message.message || 'Streaming';
showStatus(stageMessage, 'info'); showStatus(stageMessage, 'info');
console.log('Stream status:', message); console.log('Stream status:', message);
// 保存句子文本并传递给动画器
if (message.sentence_texts) { if (message.sentence_texts) {
sentenceTexts = message.sentence_texts; sentenceTexts = message.sentence_texts;
animator.sentenceTexts = sentenceTexts; animator.sentenceTexts = sentenceTexts;
@ -197,52 +219,52 @@ async function generateAnimationStream(text, apiUrl) {
} }
}; };
let streamError = null; return new Promise((resolve, reject) => {
try { const worker = new Worker('streamWorker.js');
while (true) {
const { value, done } = await reader.read(); worker.onmessage = (e) => {
if (done) { const { type, message, error } = e.data;
break;
if (type === 'error') {
worker.terminate();
animator.endStreaming();
reject(new Error(message || error));
return;
} }
buffer += decoder.decode(value, { stream: true }); if (type === 'message') {
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) {
continue;
}
let message;
try { try {
message = JSON.parse(line); handleMessage(message);
} catch (err) { } 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 { worker.onerror = (err) => {
handleMessage(JSON.parse(buffer)); worker.terminate();
} catch (err) { animator.endStreaming();
// ignore trailing parse errors reject(new Error('Worker error: ' + err.message));
} };
}
} catch (err) { worker.postMessage({
streamError = err; url: url,
throw err; body: JSON.stringify({ text: text, language: 'zh-CN', first_sentence_split_size: 1 })
} finally { });
if (!streamError) { });
flushFrames(true);
if (!started && animator.animationFrames.length > 0) {
animator.playAnimation();
}
}
animator.endStreaming();
}
} }
function normalizeApiUrl(apiUrl, streamEnabled) { function normalizeApiUrl(apiUrl, streamEnabled) {
@ -343,7 +365,7 @@ function updateBlinkInterval() {
const max = parseFloat(document.getElementById('blinkIntervalMax').value) * 1000; const max = parseFloat(document.getElementById('blinkIntervalMax').value) * 1000;
animator.blinkParams.intervalMin = min; animator.blinkParams.intervalMin = min;
animator.blinkParams.intervalMax = max; animator.blinkParams.intervalMax = max;
document.getElementById('blinkIntervalValue').textContent = `${min/1000}-${max/1000}`; document.getElementById('blinkIntervalValue').textContent = `${min / 1000}-${max / 1000}`;
} }
function updateBlinkDuration(value) { function updateBlinkDuration(value) {
@ -361,7 +383,7 @@ function updateEyeLookInterval() {
const max = parseFloat(document.getElementById('eyeLookIntervalMax').value) * 1000; const max = parseFloat(document.getElementById('eyeLookIntervalMax').value) * 1000;
animator.eyeLookParams.intervalMin = min; animator.eyeLookParams.intervalMin = min;
animator.eyeLookParams.intervalMax = max; animator.eyeLookParams.intervalMax = max;
document.getElementById('eyeLookIntervalValue').textContent = `${min/1000}-${max/1000}`; document.getElementById('eyeLookIntervalValue').textContent = `${min / 1000}-${max / 1000}`;
} }
function updateEyeLookDuration() { function updateEyeLookDuration() {
@ -382,7 +404,7 @@ function updateExpressionInterval() {
const max = parseFloat(document.getElementById('expressionIntervalMax').value) * 1000; const max = parseFloat(document.getElementById('expressionIntervalMax').value) * 1000;
animator.expressionParams.intervalMin = min; animator.expressionParams.intervalMin = min;
animator.expressionParams.intervalMax = max; animator.expressionParams.intervalMax = max;
document.getElementById('expressionIntervalValue').textContent = `${min/1000}-${max/1000}`; document.getElementById('expressionIntervalValue').textContent = `${min / 1000}-${max / 1000}`;
if (window.ExpressionLibrary) { if (window.ExpressionLibrary) {
window.ExpressionLibrary.randomPlayer.intervalMin = min; window.ExpressionLibrary.randomPlayer.intervalMin = min;

View File

@ -0,0 +1,56 @@
<!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>

View File

@ -0,0 +1,68 @@
// Web Worker for handling streaming response parsing
self.onmessage = async function(e) {
const { url, body } = e.data;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
});
if (!response.ok) {
let errorMessage = `请求失败 (${response.status})`;
try {
const data = await response.json();
if (data?.error) {
errorMessage = data.error;
}
} catch (err) {
// ignore
}
self.postMessage({ type: 'error', message: errorMessage });
return;
}
if (!response.body) {
self.postMessage({ type: 'error', message: 'No response body' });
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const message = JSON.parse(line);
self.postMessage({ type: 'message', message: message });
} catch (err) {
// ignore parse errors
}
}
}
if (buffer.trim()) {
try {
const message = JSON.parse(buffer);
self.postMessage({ type: 'message', message: message });
} catch (err) {
// ignore
}
}
self.postMessage({ type: 'complete' });
} catch (err) {
self.postMessage({ type: 'error', message: err.message });
}
};

View File

@ -79,6 +79,10 @@ class TextToBlendShapesService:
yield {'type': 'error', 'message': '文本为空'} yield {'type': 'error', 'message': '文本为空'}
return return
# 测试:只处理第一句
sentences = sentences[:1]
print(f"[测试模式] 只处理第一句: {sentences[0]}")
yield { yield {
'type': 'status', 'type': 'status',
'stage': 'split', 'stage': 'split',