作者:vivo 互聯網前端團隊 - Su Ning
為解決擬我形象在多場景展示中依賴 3D 渲染導致的性能與接入問題,本文提出將形象預先導出為視頻或動圖資源。對比三種技術路徑後,最終選擇 Puppeteer + H5 渲染幀 + FFmpeg 合成視頻 的方案,實現了渲染效果一致、服務端批量處理和低接入成本,為擬我形象的規模化應用提供了高效可擴展的技術基礎。
1分鐘看圖掌握核心觀點👇


圖1 VS 圖2,您更傾向於哪張圖來輔助理解全文呢?歡迎在評論區留言
一、背景
在擬我形象功能的實際應用中,用户完成形象配置後,普遍存在將該形象應用於多場景展示的需求 —— 例如將其設置為社交平台的動態頭像、製作專屬表情包、適配手機息屏顯示動畫,或是生成個性化壁紙等。
然而,這裏存在一個關鍵技術矛盾:擬我形象的渲染依賴 3D 運行環境,若在動態頭像、息屏顯示等多場景中均實時加載完整 3D 渲染環境,會導致設備性能過載(如移動端耗電加劇、網頁端卡頓),同時大幅提高第三方場景的接入門檻(需額外適配 3D 渲染邏輯)。
為解決這一矛盾,核心思路是將用户配置的擬我形象預先導出為指定格式的動畫資源 —— 即通過技術手段將 3D 形象轉換為輕量級的視頻或動圖文件,後續各場景僅需直接調用已生成的動畫資源即可展示,無需依賴 3D 渲染環境。這一方案能顯著降低第三方場景的接入成本,同時保證形象展示的一致性與流暢性。
但新的問題隨之產生:如何高效、高質量地完成擬我形象的動效合成?需結合各場景的需求,選擇最優的動效合成技術路徑,成為當前亟待解決的核心問題。
二、方案選擇
想要實現動效視頻的合成,可選的方案有三種:
2.1 H5生成動畫幀,H5/客户端合成視頻
擬我形象本身是一個混合開發方案,H5負責整個捏臉流程的實現,在客户端則提供包括資源緩存等基礎能力的實現。如果是輸出長度較短的單個動畫,如動態頭像,可以直接通過操作模型執行指定的動畫輸出視頻幀,再將視頻幀合成視頻。
合成視頻可以在H5端使用FFmpeg或者webcodec,但是前者的導出效率只有端側的1/20,後者存在很嚴重的兼容性問題,在移動端上甚至有概率出現黑色閃爍。所以還是選擇在客户端進行視頻的合成與上傳的操作。
2.2 blender api合成動畫
端側合成視頻實現簡單且容易維護,但是存在很強的侷限性:幀輸出的過程用户無法進行任何操作,且受限於移動端設備種類多樣,不同手機中的導出時長也不統一,導出單段動畫還好,如果是導出多段動畫,相信在動畫導出之前用户已經流失掉了。
這種情況下將渲染放到服務端進行就順理成章了,我們首先想到的是通過腳本調用blender api進行渲染。
blender -b ./avatar.blend -P render.py -o ./result/
需要將底模以及所有的服裝、髮型、配飾等部件放到同一個模型裏面,調用前置的python腳本還原用户的配置,然後輸出對應的視頻。
使用blender渲染的優點是輸出視頻的質量很高,且Eevee渲染器的渲染速度也很快,但是也會帶來一系列棘手的問題:
首先是blend文件的維護,由於不同的部件可能是不同的設計師輸出的,最終都要整合到同一個文件中,會導致額外的維護成本。相比於上面的問題,更麻煩的是前端使用的不同部位的glb文件是通過管理後台進行維護,不同環境之間的同一部件可能id、命名都不相同,也就意味着在不同環境下需要維護不同的blend文件;使用後台進行模型文件的管理本意是為了增加配置的靈活性,但是使用單個blend文件進行管理又會失去這種維護性。隨着模型和動畫的不斷更新,這個方案的維護成本很難控制。
即使不考慮開發和維護的成本,使用blender渲染的視頻和前端渲染的質量和效果也不一致,相比於H5需要考慮設備的兼容性和性能限制,使用blender渲染的動畫確實畫質更佳、細節更豐富,但是也會讓用户產生“貨不對版”的錯覺。所以統一不同設備的渲染風格也很重要。
2.3 Puppeteer訪問H5輸出動畫幀,FFmpeg合成視頻
綜合上面兩個方案,在大量動畫需要渲染的場景下,既要不阻塞用户,又要保證渲染的一致性。
如果不阻塞用户,那麼渲染行為就要放在服務端。
如果保證渲染的一致性,那麼最好是使用H5渲染。
答案呼之欲出了,那就是使用Puppeteer或Playwright這種網頁自動化工具,加載H5頁面進行渲染。結合我們的使用場景,最終選擇了Puppeteer。

三、實現思路
針對Puppeteer方案,我們設計瞭如圖的實現路徑

具體實現拆分為三個部分,分別為用於幀輸出的頁面開發、Puppeteer流程設計以及視頻合成。
3.1 用於幀輸出的頁面
為儘量降低維護成本,我們將根據配置文件加載的模型抽象為獨立模塊,並同時應用在用户訪問頁面和雲端渲染頁面中。
在頁面喚醒時,Puppeteer 會將所需的用户數據與導出的動畫名稱注入到 window 對象中。網頁在讀取並加載對應配置後,會展示一個“導出視頻”的按鈕。理論上在配置加載完成後即可直接開始幀生成,但為了方便本地開發與調試,我們仍保留了手動觸發導出的按鈕。

當幀生成完成後,系統會將所有圖片打包壓縮為一個 ZIP 文件並保存到本地。隨後,頁面會展示一個指定 ID 的 DOM 元素,Puppeteer 檢測到該元素後,即視為幀輸出已完成,隨後關閉頁面並進入後續流程。
3.2 Puppeteer流程設計
作為一個常駐服務,Puppeteer只需要初始化一次瀏覽器,隨服務的啓動即創建。所有的任務都作為標籤頁運行在這個瀏覽器下,每新建一個導出任務都會新建一個標籤頁,在導出任務完成之後關閉相應的頁面。
// 創建瀏覽器,並禁用沙箱,不禁用沙箱會導致運行在鏡像環境中報錯
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox', // 禁用沙箱
'--disable-setuid-sandbox', // 禁用 setuid 沙箱
]
});
function exportAnimate(){
// 創建新的標籤頁
const page = await browser.newPage();
// do something...
// 關閉標籤頁
await page.close()
}
由於用户配置對應的靜態資源全部都是遠程鏈接,如果不做資源的本地緩存會導致每次訪問頁面都會重新請求,造成帶寬的浪費,網絡請求也會影響到用户配置的還原速度,所以我們通過監聽page的request和response事件對資源進行緩存與攔截。
// 啓用請求攔截
await page.setRequestInterception(true);
// 監聽網絡請求,如果本地有緩存的資源則直接返回本地緩存的內容,反之則繼續正常返回
page.on('request', async (request) => {
const url = request.url();
// 判斷文件類型是否支持緩存
if (isCacheableFile(url)) {
const fileName = getFileNameFromUrl(url);
const cacheFilePath = path.join(cacheDir, fileName);
// 檢查緩存是否存在
if (fs.existsSync(cacheFilePath)) {
console.log(`從緩存加載文件: ${fileName}`);
const cachedContent = fs.readFileSync(cacheFilePath);
console.log(`緩存文件大小: ${cachedContent.length} bytes`);
await request.respond({
status: 200,
contentType: getContentType(fileName),
headers: {
'Access-Control-Allow-Origin': '*'
},
body: cachedContent
});
return;
}
}
// 繼續正常請求
request.continue();
});
// 監聽響應事件
page.on('response', async (response) => {
const url = response.url();
if (isCacheableFile(url) && response.status() === 200) {
const fileName = getFileNameFromUrl(url);
const cacheFilePath = path.join(cacheDir, fileName);
// 如果緩存不存在,則保存
if (!fs.existsSync(cacheFilePath)) {
console.log(`正在緩存文件: ${fileName}`);
try {
// 使用 axios 重新下載文件
const axiosResponse = await axios({
method: 'GET',
url: url,
responseType: 'arraybuffer',
timeout: 60000
});
fs.writeFileSync(cacheFilePath, axiosResponse.data);
console.log(`緩存文件成功: ${fileName}, 大小: ${axiosResponse.data.length} bytes`);
} catch (error) {
console.error(`緩存文件失敗 ${url}: ${error.message}`);
}
}
}
});
由於網頁導出幀以後會將zip包保存到本地,所以需要指定下載的目錄便於讀取文件,為了防止併發請求下載的文件命名混亂,在每個方法執行一開始生成一個唯一id,並將這個id作為文件的下載名。
// 設置唯一的taskid
const taskId = nanoid();
// 指定文件的下載目錄
const client=await page.createCDPSession();
await client.send('Page.setDownloadBehavior',{
behavior:'allow',
downloadPath:path.resolve('./temp/')
});
// 訪問指定的頁面並將數據注入到網頁的window對象中
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 0 });
await page.evaluate((data,id,animate) => {
window.__INIT_DATA__=data
window.__TASKID__=id
window.__ANIMATE__=animate
},config,taskId,animate);
在準備工作做完以後就可以監聽網頁的按鈕狀態,執行對應的操作了。
const btn=await page.waitForSelector('#export-btn', {
timeout: 10000// 10秒超時
});
await btn.click();
await page.waitForSelector('#exported', {
timeout: 30000
});
// 在檢測到#exported這個dom出現以後,意味着文件導出完成已經開始下載,但是無法獲取到文件下載的狀態,由於本地文件下載速度很快,所以這裏僅設置一個2s的等待,不做其他的監聽操作
await newPromise(resolve => setTimeout(resolve, 2000));
3.3 視頻合成
現在我們獲取到視頻幀的壓縮包了,接下來需要將壓縮包進行解壓操作併合成視頻或者gif,合成完將內容上傳到靜態資源庫,最終返回資源的url。視頻合成使用FFmpeg,這裏以輸出mp4文件為例。
// 構建文件名是數字序列的輸入模式
const inputPattern = path.join(framesDir, frames[0].replace(/\d+/, '%d'));
// 輸入參數
const ffmpegArgs = [
'-framerate', fps.toString(),
'-start_number', '0',
'-i', inputPattern,
'-vf', `scale=${width}:${height}`,
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '23',
'-pix_fmt', 'yuv420p',
'-f', 'mp4',
'-y',
outputPath
];
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs);
ffmpegProcess.on('close', (code) => {
if (code === 0) {
// 視頻合成完成
}
});
四、結語
通過對比多種動效合成路徑,最終選用 Puppeteer + H5 渲染幀 + FFmpeg 合成視頻 的方案,在保證渲染一致性的同時,兼顧了服務端異步處理與多場景複用的需求。該方案有效解決了擬我形象在多場景應用中存在的性能瓶頸和一致性問題,大幅降低了接入門檻,也為後續規模化生成和分發提供了技術基礎。