项目背景
这个项目是给每个用户提供了一个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。