背景

弹幕作为国内直播平台必不可少的功能之一,其性能的好坏直接影响用户观看视频的体验。企鹅电竞使用的是B站的弹幕组件(修改了排版的逻辑),它的最后一次维护也是在两年前了,更重要的是随着电竞业务的发展,该组件不论是在功能还是性能方面都无法满足我们的业务需求,我们准备实现一套满足电竞业务的弹幕组件。

实现

在开始介绍新的弹幕组件之前,我们先看一下B站弹幕组件的原理及问题。 B站弹幕支持

View

,

TextureView

,

SurfaceView

三种方式进行绘制,其中

TextureView

,

SurfaceView

都是采用

lockCanvas()

的方式,该方式本质都是向

Surface

申请一块Buffer,再向Buffer写数据,该操作是软绘制,所以我们项目中实际使用的是基于

View

的绘制方案。每次添加弹幕首先会复用一个大小合适(没有时创建)的Bitmap,使用异步线程利用CPU将弹幕内容绘制到Bitmap中,等到了上屏的时机利用

View

onDraw(...)

进行绘制,通常情况下这都是使用硬件绘制,那么这会存在什么问题呢?Android的View的硬件绘制的流程是在主线程构建Display List,然后在Render Thread进行指令合并与执行纹理上传渲染(OpenGL),在大量弹幕的情况下会出现大量纹理上传,纹理上传过程中通常主线程会wait保证数据一致,这会导致主线线卡顿已经以及Render Thread绘制超时,另外弹幕占用的内存显存也是非常的大。为此我们希望可以优化卡顿,内存/显存的占用。 新弹幕组件采用一张Bitmap承载多条弹幕的方式,减少多Bitmap纹理上传的的次数,在OpenGL中渲染每一条弹幕的时候,在从纹理中扣取每条弹幕的位置信息(纹理坐标)进行渲染。要使用OpenGL我们考虑使用TextureView或是SurfaceView(GLSurfaceView),由于业务中弹幕处于View的中间层,SurfaceView无法嵌入,所以使用的时候我们会使用TextureView(SurfaceView绘制也实现了)。由于一批弹幕生成的BItmap尺寸相对于单条弹幕大了很多,所以在做纹理上传的时候需要考虑非渲染线程异步执行。

!https://www.kutear.com/post-images/1584686473976.png

优化

动态弹幕的支持

使用上面的方案我们可以很容易的完成所有静态弹幕的展示,但是业务需求中通常会出现一些高级弹幕,这种弹幕通常自身就带有动效(比如背景是张webp等动图),这是如果还是走基于一张Bitmap的方案,就的将动图的每一帧预先绘制到Bitmap并需要自己控制显示的时机,这样操作过于复杂且扩展性不够,因此排除这个方案。我们采用最直接方式-支持View插入到弹幕系统中,这样我们不需要控制View的内容到底是怎样个动法,我们只关心他在整个弹幕系统中的位置,我们的排版逻辑依旧是可用的,我们只需要添加一种展示方式,为此我们将弹幕展示分为静态弹幕展示层与动态弹幕展示层。

内存/显存的占用

使用这个方案实现弹幕组件之后,FPS以及CPU的占用上面都优于B站弹幕库,但是在显存的占用上面却比B站弹幕多出一部分。

!https://www.kutear.com/post-images/1584672574953.png

主要多出来显存开销其实来源于TextureView自身环境的开销,TextureView运行会创建Surface,Surface有一个双缓冲或三缓冲的Buffer,他的大小和TextureView的大小以及Buffer的数量有关,可以使用

adb shell dumpsys meminfo pkg

来进行查看(下图中的

EGL mtrack

),这个值初始化好之后就是固定的,对于我们业务侧很难去减少这块显存的占用,所以我们的优化主要都集中在我们自己的纹理/内存上面。

Bitmap填充率

我们采用一张大的Bitmap绘制弹幕,那么这张Bitmap的利用率是非常重要的,他不仅影响在native的大小,更重要的是影响显存,毕竟显存占用是我们需要突破的一个点。我们需要每一批弹幕近可能的占用更小的Bitmap(Bitmap的利用率提升),考虑到Bitmap的复用问题,我们不可能每次都创建一个恰好合适的Bitmap,我们对选取的Bitmap的尺寸进行理合到多个尺寸上。

纹理上传时机选择

因为弹幕预排版的缘故,一批弹幕添加到系统可能被排在离屏M的位置,在这个时候我们依旧把它绘制到Bitmap上面并进行了纹理的上传,但是这个时候其实没有这个必要,因为这批弹幕这个时候并没有需要上屏渲染,还需要(M/V)的时间才会出现在屏幕之上,这时绘制和上传之后,这个纹理的存活周期被拉长了,导致一定时间内存在的纹理的数量增加。 有了这个想法之后,我们可以修改Bitmap绘制和纹理上传的时机,把这个过程移到该批弹幕快要上屏的时刻在执行,这个具体的阈值距离可以在运行中统计Bitmap绘制+纹理上传的时间来进行动态调整,只要在保证到达屏幕边缘的时候可以有纹理可用就行。

重复弹幕复用

在直播场景中经常会出现同一时刻出现很多相同弹幕的场景(游戏胜利/抽奖),弹幕量大并且弹幕的内容相同,这是如果依然把每条弹幕绘制到Bitmap上再进行上传展示将浪费CPU内存显存资源。为了应对这种情况,我们复用相同弹幕,对于相同的弹幕我们只在Bitmap上面绘制一次,其他相同的弹幕都引用这同一块区域,然后在OpenGL绘制的时候,在根据纹理坐标绘制复原出多条弹幕。当然,在此基础之后,还可以做“子序列”类型的弹幕复用,不过实现相对麻烦,没有实现。

Bitmap通道复用

在采用 了以上方案之后,可以缓解部分问题,是否还存在更大的优化空间呢?这是我们想到是否可以采用只在Bitmap上面绘制透明度,将具体的着色的效果交由Shader完成,这样我们在Bitmap的创建以及纹理的上传只需要只有透明通道的Bitmap,这样理论上内存/显存大小可以降低到原来的4倍(采用ARGB_4444的也会被强制转为ARGB_8888),但是采用单个透明通道绘制出来的多颜色弹幕(如弹幕中含有表情😊)的彩色信息会被丢弃掉。为此我们还是选择ARGB4通道的Bitmap来进行绘制,对于多颜色弹幕还是按照普通的绘制方案,对于单一颜色的弹幕我们将他的透明度透射到ARGB中的一个通道。经过对我们外网弹幕的分析,大约

70%

的弹幕都属于单一颜色弹幕,为此理想情况下会有减少

3倍

以上的内存显存占用(0.3 * 1 + 0.7 * 4)。 如何才能实现不同弹幕在同一像素上的不同通道呢,其实Android已经已经有相关的方案,在绘制Bitmap的时候通过给paint设置

Xfermode

可以实现不同的叠加效果,寻找一圈就会发现

PorterDuff.Mode.ADD

基本满足我们的需求。

!https://www.kutear.com/post-images/1584674381264.png

对于两次叠加他的Alpha通道和Color通道都取值大的,但在实际操作中发现对于全透明的情况是会被忽略掉,比如(FF000000和00FF0000叠加还是FF000000,不是我们所期望的FFFF0000),所以Alpha通道的复用行不通,都得填充FF。基于这个样的操作,我们将弹幕原本的Alpha通道绘制到Bitmap的RGB通道之一中去,就可以得到如下的Bitmap。

!https://www.kutear.com/post-images/1584670990231.png

使用工具分离出ARGB各自的信息,可以得到各个通道的图像信息。

!https://www.kutear.com/post-images/1584704657139.png

有了这些信息,我们在Shader中就可以利用弹幕的真正颜色(RGB)和纹理中的某个通道(A)合并成像素点的真实颜色信息。 注:该实现因为Bitmap的Alpha通道无法使用,不能给弹幕添加阴影效果,如果有阴影会退化成多颜色弹幕处理。

后记

做了这些内存/显存上的优化,在弹幕量大的情况下,显存的占用已经低于B站弹幕,但在少数弹幕的场景依然有改进空间,在弹幕量少的情况下,弹幕本身就不可能铺满全屏,可以采用可以通过减少Surface的默认高度来进行调整,这个方案初步验证是可以降低EGL mtrack的大小,后面会继续探索。

参考

附录

  • imagemagick分离ARGB为独立图片 shell convert input.png -channel RGBA -separate channels_%d.png