优酷高性能弹幕渲染引擎的目标,是在全平台实现对弹幕内容的高效渲染。渲染的内容包括文本、emoji、普通图片、apng动图和3D mesh等元素,并且支持节奏弹幕、燃弹幕、弹幕穿人、流光弹幕等各种特效玩法。下面,将对优酷高性能弹幕渲染引擎所涉及的技术做一次大揭秘。
一、背景
1. 弹幕是什么
弹幕这个词最早出现在军事领域,是一个炮兵术语,指使用密集火力对某一区域进行覆盖。同一时间内炮弹非常多,在天空中形成幕布,我们就称之为弹幕。现在的弹幕主要是指在视频上层滚动的评论性字幕。如下图所示:
优酷主客里的弹幕丰富多彩,展示元素包括纯白色文本、彩色文本、渐变色文本、边框静图、背景静图、avatar静图、apng动图、emoji表情等等,展现形式包括普通从右到左弹幕、置顶弹幕、点赞动效弹幕、节奏弹幕等等。
2. 弹幕的价值
- 对用户而言
弹幕提供一个相对更加自由和宽容的平台来发表用户的观点,能有效解决无法跟随剧情实时评论的痛点,让用户能跟随剧情实时评论,具有更强的实时互动效果。弹幕能有效消除用户在观影时的孤独感,使用户之间产生更多的共鸣。
- 对产品而言
(1)弹幕天然具有某种意义上的社区属性,能有效提高产品的粘性。
(2)弹幕是一条非常重要的实时反馈通道。我们从弹幕文字中能提取到不少用户给产品的建议或与观影相关的问题。
3. 高性能弹幕渲染基本要求
弹幕是如此重要,以至于现在基本成了视频厂商的标配。除了优爱腾和B站这几家头部厂商之外,其他一些较小的厂商也基本都在跟进并上线弹幕功能了。
由于弹幕自身的特点,要使弹幕展示得更加平滑、用户体验更好,我们对弹幕的渲染技术提出以下几个基本要求:
(1) 渲染帧率必须高于40fps,否则就能明显感觉到弹幕滚动过程中有卡顿感,用户体验不好。
(2) 渲染间隔必须均匀。否则即使总帧率大于40fps,但连续帧与帧之间一帧渲染耗时低于10毫秒,另一帧耗时超过30甚至40毫秒,这种不均匀的渲染间隔也能让用户很明显地感知到弹幕的卡顿。
(3) 耗电量必须低。弹幕使用一段时间之后手机发烫、掉电快等类似现象是不可接受的。
二、业界现状
1. 开源方案
目前知名的Android开源方案是DanmakuFlameMaster,中文名是烈焰弹幕使。iOS端也有类似的开源方案。烈焰弹幕使这个方案好多厂商都在商用,有些会根据自己的业务需求做深度定制开发。其流程图如下:
1.R2LDanmaku:承载一条普通的从右到左弹幕所有信息,包括文本、图片等资源,以及对应的起止坐标、字体、颜色等属性信息。
2.DanmakuView:自定义的view,用于承载弹幕渲染的绘制容器。
3.DrawHandler:通过异步弹幕消息的方式, 控制整个弹幕的显示逻辑。
4.CacheManagingDrawTask和DrawingCacheHolder:在DrawHandler里起一个异步线程CacheManagingDrawTask,维护需要绘制的弹幕列表,控制弹幕缓存逻辑。CacheManagingDrawTask通过DrawingCacheHolder,来完成弹幕资源到bitmap缓存对象的转换,将文本、图片、动图这些元素全部绘制到一个bitmap对象中,最后做缓存。
5.DanmakuRenderer:弹幕的测量、布局和渲染等操作,通过调用Displayer里的draw方法实现将缓存的bitmap对象绘制到View中。
6.Displayer:持有canvas画布,绘制弹幕,实现上屏操作。
出于性能考虑,整个流程分为两个线程。除绘制上屏等涉及UI的操作必须在主线程实现之外,将耗时的弹幕资源转bitmap对象和缓存等操作剥离到另一个异步线程中。上述两个线程里所有的操作几乎都在CPU中实现,由于弹幕要求帧率很高,所以很容易触达渲染性能瓶颈。当CPU负载过重的时候,就很容易出现卡顿等现象。
2. 弹幕渲染引擎痛点
业界方案的最大的痛点主要表现在以下两点:
第一:卡顿。弹幕卡顿的原因主要是因为性能跟不上。因为性能跟不上,导致渲染不均匀,体现在这种需要快速移动的弹幕上就特别的明显。表象就是会卡,一顿一顿的,当帧率低于40的时候,基本上肉眼可见的卡顿就比较明显了。
第二:功耗高,手机发烫。弹幕开久了,会导致手机发烫。原因是,CPU弹幕渲染的功耗太高。
3. GPU渲染流程
要实现2D/3D渲染能力,除了使用canvas之外,我们也可以通过OpenGL/vulkan/metal等api来使用GPU做渲染,并且效率要比canvas高很多。我们也了解到,某些视频厂商也在使用GPU来做弹幕的渲染。
我把使用GPU做渲染的流程简化为如下3个步骤:
1. 资源准备
文本、图片、动图 -> bitmap对象,所有的纹理要传入GPU,在CPU中都得先转为bitmap,然后把bitmap通过OpenGL、metal或vulkan API上传到GPU中。
2. 数据交互
Shader的编译和链接,顶点数据的上传,纹理数据的上传等操作。
3. GPU图形渲染
通过步骤2中同一个context,设置视口,绑定buffer、texture,最后调glDrawElement类似方法实现渲染、上屏。
上述3个步骤是一个串行过程,步骤1全部在CPU中完成,步骤2涉及到CPU与GPU的交互,步骤3全部在GPU中完成。由于OpenGL自身的限制,不支持多线程操作,上述流程所有的操作都在一个线程中,所以这里其实隐藏了一个很大的性能隐患:只要其中某一个步骤花时间过久,稍有延迟的话,就会导致整体有延迟,从而产生卡顿。
三、优酷引擎架构实现
1. 模块图
优酷弹幕引擎分以下4个层次
(1)接入层
Android和iOS端分别封装了OprBarrageView和OprBarrageLayer,除了封装弹幕的相关操作之外,把展示的view容器也封装在内。
这样做的好处主要有两点:
a. 接入简单
接入方只用关心一个对象,不用同时持有view和operation两个对象,不用同时关心view的处理逻辑和弹幕相应的功能操作。只需把OprBarrageView或OprBarrageLayer加入到自己的view层级中,调用insert传递弹幕所需的文本、bitmap、动图等资源或链接,还有对应的起止位置,要显示的时长等属性,后面的资源解析、上屏、移动等操作完全不用关心。
b. 模块高内聚、低耦合
view相关的处理逻辑和弹幕相应的功能,全部在OPR内部完成闭环,避免频繁的OPR与接入层之间的交互,性能更优,代码耦合度更低,开发和调试效率也更高。
(2)worker层
主要完成两大块的工作:
(1) OpenGL和metal环境的初始化,surface的传递、config和渲染上下文的初始化等操作。
(2) 完成弹幕的增删改查等操作。对应弹幕最主要的插入功能,要完成的工作包括资源解析、初始化,bitmap上传到纹理中,加入到node tree对应节点,等待渲染。
(3)render层
渲染线程,主要完成纹理的渲染上屏操作。我们会起一个timer线程,按指定帧率去执行渲染上屏和移动等操作。
(4)协议封装层
目前移动三端分别对接的是OpenGL ES和metal,我们会对相关实现进行封装。
(5)公共模块
opr_platform:平台相关的东西,比如Android的jni封装,iOS的DisplayLink等。
opr控件封装:OPR实现的一些常见的控件,包括文本、图片、动图等元素对应的控件。
opr基础tool:基础封装,包括mutex、thread、timer等基础单元的封装。
2. 弹幕渲染流程
(1)Insert操作
对接接入方接口调用,转到我们的worker线程去做初始化及纹理上传等操作。文本、图片、动图我们都有对应的控件,我们传入这些控件所需资源去做初始化,新建纹理并上传纹理到GPU,初始化program,顶点坐标,然后加入到node tree中,为后续的遍历渲染做准备。
(2)渲染操作
通过定时器timer,按60fps的频率去定时做位置、纹理等数据的更新,弹幕的渲染等工作。主要流程分以下三大部分:BeginFrame,Draw,EndFrame
a. BeginFrame
帧准备阶段:获取context,设置viewport,更新子元素顶点和纹理数据。先通过eglMakeCurrent来获取共享的context,然后设置viewport,遍历node,按zorder和local order的大小来排序,调各node的Draw(render)方法;如果对应的是danmaku的话,调opr_danmaku_label的Draw()方法。这些Node的Draw方法主要是计算步长,更新顶点坐标和纹理数据。
我们的渲染是通过一个渲染场景树来管理,最上层是scene,scene下面套多层node,node能继续套其他node。通过这种嵌套的层级关系,按有向无环图DAG的形式,来管理渲染节点的层级和渲染先后顺序。node加入到command queue里,等待绘制。
b. Draw
Draw阶段:遍历command queue,调用draw call放入command buffer来实现绘制。
c. EndFrame
帧结束阶段:通过eglSwapBuffers实现前后double buffer的置换,完成最终的上屏操作。
3. 渲染性能提升抓手
我们通过以下这几个性能抓手来提升渲染的性能:
(1)并行渲染
由于metal天然支持多线程渲染,所以这里的并行渲染,我们仅仅以OpenGL来举例。刚才提到,OpenGL不支持多线程渲染,OpenGL的操作都需要一个context来存储相关信息。这个context是线程强相关的,在某线程创建了context之后,所有的OpenGL操作都必须转到此线程来执行。这就限定了所有的OpenGL操作都只能在这个单线程中完成,大大限制了渲染的性能。
如何解决这个难题呢?我们可以通过多线程共享context的方式,来间接实现多线程渲染。流程如下:
第一个线程先做正常的初始化,包括初始化egl config、surface、创建context等操作,第二个线程在初始化调eglCreateContext的时候可以传入刚才的context,这样就得到一个共享的context。在某一线程需要调用OpenGL操作时,先调用eglMakeCurrent来切换上下文,就可以正常调用OpenGL的API来操作GPU了。
这样我们就把work和render作为两个线程,我们可以把耗时的操作全部放在work线程,与渲染线程完全隔离开。这样就间接实现多线程渲染了。实践证明,这对渲染性能的提升来说,是一个大杀器。我们实测数据显示,通过上述并行渲染方案,渲染性能能有效提升60%以上。
当然,使用共享context来间接实现多线程渲染也不是万能的,它也有如下两个弊端:
1. eglMakeCurrent切换上下文,也是有性能损耗的。如果调用过于频繁,就会抵消共享context带来的多线程收益,也会达到性能上限而出现卡顿等性能问题。
2. 生命周期、数据的同步问题,锁的使用等问题,也给产品的稳定性带来一定的挑战。
(2)预加载和缓存
文本、图片的预加载,特别是apng图片,预先解析和上传到纹理中,然后缓存。apng图片一般由20~80张png图片组成,解析和解码都非常耗时。我们通过预加载和缓存来降低耗时。
(3)延迟提交
针对GPU program参数的提交仅由渲染驱动,在渲染间隙的参数变动都被缓存在内存中。我们这种延迟提交技术,来避免过多的CPU/GPU交互,减少缓存遍历和操作。
(4)共享渲染单元
- 共享program、shader:避免频繁的程序编译、链接过程,减少切换program带来的损耗。
- 共享texture:特别是针对弹幕这种texture创建后就基本不变的场景,特别适用。
- 共享buffer:尽管在弹幕移动过程中顶点buffer在不断变化,但是索引buffer不变,所以索引buffer是可以复用的。
(5)批量任务
主要是指弹幕的移除操作,要移除时仅执行色通知visible标志操作,写入可移除队列由渲染线程批量删除。
(6)减少draw call次数
这个比较好理解,OpenGL/metal draw call次数越少,画的次数越少,性能越好。我们会合并draw call,使次数尽可能少。
(7)工程上的优化
工程层面上的优化,包括纯native实现的引擎、raw指针和智能指针、hashmap的使用和遍历、最小粒度的锁等等。
4. 优酷弹幕引擎收益
(1)通过线上数据的监测,我们发现弹幕帧率基本稳定在60左右。
(2)渲染间隔稳定在16毫秒左右,不会由于渲染间隔的不均匀导致卡顿。
(3)大部分操作都在GPU中,CPU功耗低,手机掉电慢。
通过以上这么多优化策略,我们才能顺滑地实现对如下满屏最小字体、最快速度弹幕的渲染。如下图所示:
另外,我们分别拿了Android和iPhone的一款中端机型做了性能压力测试,结果如下:
Android用红米K20 pro,CPU打满的前提下,保持渲染帧率60帧,可以同时渲染1200条;iPhone端iPhone 8P,1600条。这是远远超出整屏所需上屏的弹幕数量的。
5. 创新玩法
正是由于我们弹幕引擎的高性能特性,所以我们能很好地支撑各种创新互动玩法。
(1)节奏弹幕
节奏弹幕在一条弹幕周围渲染相同文本但不同颜色的多条辅弹幕,并且辅弹幕与主弹幕的移动距离是会随着声音音量的强度变化实时更新的。节奏弹幕通过这种方式来达到这种多彩弹幕跳动的酷炫效果。优酷也在近期热播的综艺《这!就是街舞4》上打开节奏弹幕功能。从用户的弹幕反馈可以看到,用户对这种展现形式是感到很惊艳的。
(2)燃弹幕
燃弹幕在屏幕中间显示“燃”字等特效,并且满屏的弹幕内容也会参与颜色的变化和抖动,在视频内容高燃时发一个“燃”弹幕,能给人一种画面震撼感,带来极大的感官冲击。
四、展望
依赖优酷高性能弹幕渲染引擎,我们给弹幕诸多创新特效玩法都提供了有力的技术支撑,如:节奏弹幕、燃弹幕、弹幕穿人、流光弹幕等等。后续我们将持续优化和迭代该系统,给用户提供更多弹幕的新玩法和新体验,敬请期待!