項目背景
這個項目是給每個用戶提供了一個avatar動態形象。用戶可以任意搭配頭發、衣服等部件。要求在在用戶保存形象的同時,生成頭像截圖,頭像背景需要自定義,跟動態形象不是同一個背景。
技術背景
整體采用Pixi js + Spine + Vue的技術棧
技術難點
一 用戶形象是動畫,如果直接截屏,會導致每次截取的形象不同,而且可能出現閉眼的情況。
比如下面的截圖:
二 給生成的頭像動態添加背景
avatar動態形象的背景是透明的,生成頭像需要特定的背景,這個背景肯定是不能直接添加在avatar形象上的,需要在截圖的時候動態添加背景。
嘗試的解決方案
一 初始化一個opacity為0的avatar動態形象,對其進行截圖
在用戶保存形象時,初始化一個opacity為0的avatar動態形象,不播放動畫,讓其停留在第一幀,對其進行截圖。就不會出現閉眼的問題。同時還可以直接給avatar添加一個背景,這樣截取的頭像就會帶有背景。解決了上面一、二兩個難點。
存在問題:從新的avatar形象被初始化開始,頁面開始嚴重掉幀,出現了性能問題。
這個作為保底方案,開始探索輕量級的截圖方案。
截取的頭像可能會閉眼是截圖時動畫播放到哪一幀不能控制導致的。動畫第一幀是正常幀,頭像截取都會需要針對這一幀來操作。
二 緩存每次動畫開始的第一幀
緩存動畫循環播放的第一幀,如果用戶在本次動畫循環中保存了形象,那么直接對緩存的第一幀進行頭像裁剪。
問題在于,用戶在動畫第一幀播放之后形象是可以被改變的,這個方案被pass掉
確定的解決方案
選擇的解決方案是用戶保存形象時,立刻開始從第一幀播放動畫。
這個是最終采用的方案,下面介紹實現細節。
一 截取當前幀
const avatarWidth = this.shotOption.area[2] * ratio
const renderer = app.renderer
const stage = app.stage
let matrix = new PIXI.Matrix()
matrix = matrix.translate(-this.shotOption.area[0], -this.shotOption.area[1])
const renderTexture = PIXI.RenderTexture.create(avatarWidth, avatarWidth)
renderer.render(stage, { renderTexture, transform: matrix })
const canvas = renderer.plugins.extract.canvas(renderTexture)
使用Pixi的RenderTexture儲存當前Canvas的數據,即動畫第一幀數據,對頭像大小和裁剪的區域有要求,可以透過Pixi的Matrix來配置。將RenderTexture渲染到新的Canvas。
二 添加頭像背景
addCanvasBg(_ctx: any, _bgColor: string) {
const tmpCtx = document.createElement('canvas').getContext('2d')
if (!tmpCtx) {
return null
}
tmpCtx.canvas.width = _ctx.canvas.width
tmpCtx.canvas.height = _ctx.canvas.height
tmpCtx.fillStyle = _bgColor
tmpCtx.fillRect(0, 0, tmpCtx.canvas.width, tmpCtx.canvas.height)
tmpCtx.drawImage(_ctx.canvas, 0, 0)
return tmpCtx.canvas
}
const newCanvas = this.addCanvasBg(canvas.getContext('2d'), this.shotOption.backgroundColor)
return newCanvas ? newCanvas.toDataURL('image/jpeg') : canvas.toDataURL('image/jpeg')
構建新的Canvas,繪制背景,將截取的頭像繪制在新的Canvas上,輸出最終的頭像
解決生成的頭像在高清屏上不清晰的問題
上面是在小米11的截圖,可以發現生成的頭像有點模糊。
這里面涉及到css虛擬像素和設備真實像素的概念。
假設給Canvas style樣式設置的寬高是100px X 100px,在dpr為3的設備上,渲染這個Canvas使用的真實像素點是300 X 300,如果Canvas內容設置的寬高是100 X 100,那么內容就會被拉伸。看起來會變模糊。
最終分析源碼發現在截取avatar第一幀圖片時傳入是css虛擬像素的寬高,是需要手動添加dpr,否則默認為1,就導致截取的圖片像素點變少了,后面構建Canvas內容寬高也是采用這個寬高,最終導致生成的頭像變模糊。
需要將
const renderTexture = PIXI.RenderTexture.create(avatarWidth, avatarWidth)
改為
const renderTexture = PIXI.RenderTexture.create(avatarWidth, avatarWidth, PIXI.SCALE_MODES.LINEAR, dpr)
效果如下:
截圖完整代碼:
public shotScreen(isCurrent = true): string | null {
if (!this.enableShot) {
throw new Error('截屏功能未開啟!')
}
if (!isCurrent) {
this.playAnimation(this.animationName)
}
const avatarWidth = this.shotOption.area[2] * ratio
const renderer = app.renderer
const stage = app.stage
let matrix = new PIXI.Matrix()
matrix = matrix.translate(-this.shotOption.area[0], -this.shotOption.area[1])
const renderTexture = PIXI.RenderTexture.create(avatarWidth, avatarWidth, PIXI.SCALE_MODES.LINEAR, dpr)
renderer.render(stage, { renderTexture, transform: matrix })
const canvas = renderer.plugins.extract.canvas(renderTexture)
const newCanvas = this.addCanvasBg(canvas.getContext('2d'), this.shotOption.backgroundColor)
if (app) {
return newCanvas ? newCanvas.toDataURL('image/jpeg') : canvas.toDataURL('image/jpeg')
}
return null
}
addCanvasBg(_ctx: any, _bgColor: string) {
const tmpCtx = document.createElement('canvas').getContext('2d')
if (!tmpCtx) {
return null
}
tmpCtx.canvas.width = _ctx.canvas.width
tmpCtx.canvas.height = _ctx.canvas.height
tmpCtx.fillStyle = _bgColor
tmpCtx.fillRect(0, 0, tmpCtx.canvas.width, tmpCtx.canvas.height)
tmpCtx.drawImage(_ctx.canvas, 0, 0)
return tmpCtx.canvas
}
后續優化方向
我們目前截圖功能是放在客戶端實現的,就導致用戶在點擊保存形象之后,會存在截圖->通過原生橋將base64保存到文件服務器->將原生返回的文件url提交到后臺 完成整個流程,就導致整個流程比較長。
未來期望把截圖上傳這塊功能放到node層來處理。
目前預想的方案有兩個:
- 采用pupteer無頭瀏覽器運行一個包含靜止avatar形象網頁,然后對網頁截圖再通過rpc提交到java后臺。
- 使用jsdom,node-canvas,嘗試直接在node運行pixi, pixi-spine,實現截圖,類似思想的開源方案有pixi-shim。