本文由云 + 社区发表
作者:QQ 音乐技术团队
歌词浏览已经成为音乐 app 的标配,展示和动画效果也基本上大同小异,主要是单行的逐字染色的卡拉 OK 效果和多行的滚动效果。当然,我们也不例外。
我们的目标十分明确,一是提升歌词的基础体验,二是在此基础上,能提供差异化的 VIP 特效,来吸引用户开通 VIP。
经过多次的需求评审和沟通讨论,各方在需求的目标和细节上也达成了初步的统一。 产品的希望 :效果炫酷,能实现逐字动画 (位移,翻转,渐隐渐现,模糊,粒子特效等),可配置等。开发的思考: 技术架构方案,性能挑战等,接下来我们简单介绍一下确定技术方案的过程。
这里最初的思路有两个方向,升级现有歌词组件和开发全新歌词组件。所谓知已知彼,百战不殆, 通过对移动端面主流竞品的技术方案和 PC 端类似方案的技术调研与分析。最终将技术方案锁定在以下三种:
下面简单介绍一下三种方案的原理和特点,如下表所示:
总的来说,就是在原生动画开发和帧动画方案中进行选择。
以下主要是从是否实现特效,开发的难度,方案的性能,实现的成本,跨平台等方面对比三种方案,具体细节如下表所示:
通过以上几个维度的综合考量:
最终方案也确定采用 ASS 序列帧动画方案。
前面简单介绍了一下什么 ASS 字幕和帧动画的原理。我们知道 ASS 是一种字幕文件格式,属于高级字幕,可以制作出华丽的特效字幕。所以,要想在电影或者视频上显示 ASS 效果,首先要做的是编写 ASS 特效文件,然后再将 ASS 特效文件解析成序列帧动画的位图,最后将这些位图按照特定的顺序和一定的帧率进行播放,就能看到各种特效的动画。如下图所示:
如下下图所示:,首先,需要准备展示内容 (字幕或者歌词内容),比如一个文本文件,有了最基本的文本文件,怎么转换成 ASS 解析器能解析的 ASS 文件呢?答案是打 K 值,打 K 值是指给字幕文件加上时间轴属性。而是什么 K 值呢,就是 ASS 中 K 拉 OK 的效果标签代码,即每行甚至每个字的时间坐标。有了打完 K 值的 ASS 文件,我们就可以在视频播放器中浏览,也就有了最基本的逐字染色动画。如果要开发更复杂的特效,就需要加入更多的特效标签。而这一部分,就可以通过脚本加上动画模板 (动效模板就是具有特定动画效果的 ASS 文件),将动画标签注入到打完 K 值 ASS 文件中,生成最终的 ASS 特效文件。至此,一个具有特效的 ASS 文件就诞生了。
解析的过程相对比较简单。解析一个 ASS 文件,不仅需要 ASS 文件本身,还需要知道 ASS 文件是用什么字体合成的。这里补充一下,前面合成的时候,其中的动画模板也是需要指定是使用哪种字体来合成的。因为这里会涉及到字体的大小,间距等,对动画效果和排版的影响。然后,再回到解析上来,通过 ASS 文件加上字体库就可以解析生成特定序列的帧动画位图。
3. 技术架构
最终方案的技术架构:功能上划分如下,后负责存储和合成;客户端负责解析和绘制,呈现用户最终的动画效果。
上面提到了这套方案的通用性和易复用的特点。那除了动效歌词之外,我们还可以做些什么呢?
首先,我们脱离业务对架构进行更高一层的抽象,梳理出了更通用的架构方。这里还需要补充一点,“字体库”,从字面上理解应该是一堆字体的容器,所以字体库应该是保存了一大堆的文字信息等。但其实不仅是文字也可以是图形,所以我们的动画效果可以不只是针对文字的,还可以设计一些图形动画效果。所以,这里可以有更多的想像空间。前面解析的过程我们提到,解析出一帧帧的图,就拿去直接播放了,这样我们就能实时看到动画效果。那如果把这些图片保存下来,根据业务需求在需要的时候再播放呢。这里就可以拆分出实时渲染和离线渲染两种方案。
这里的渲染提供了两种方案:
1. 实时渲染
将解析出来的位图立即绘制到屏幕上。
适用场景:实时要求高的场景。
特点: 对系统性能消耗大,需要注意当前场景的性能开销。
2. 离线渲染
将解析出来的位图保存到磁盘上,并可以此基础上建立序列帧动画的资源管理。
适用场景:适用于异步化的场景。
特点: 建议采用异步线程在后台处理,减少对主线程消耗。
大家可以根据各自业务场景和特点灵活选择或者组合使用这两种方案。
以上主要是介绍动效歌词技术方案的实现原理与架构介绍。
在开发过程中,我们遇到了两个重要的问题:一个是在运行复杂的效果时,动画效果出现了肉眼可见的卡顿;另一个则是内存的问题,即使是比较简单的效果播放以后也会占用大量的内存。本文后半部分将重点阐述 K 歌是如何解决这两个问题的。
我们选取了一个较为复杂的效果,包含了大量的烟雾、花瓣等动画元素 及 位移、形变与模糊等效果,它的每一帧画面约由 1000 个元素构成。
在三星 Note 3(Android 5.0,4 核,ARMv7)上运行起来平均只能达到 7 帧的效果。
为了解决上述问题,我们需要对 ASS 由文本文件到渲染至屏幕的整个过程有基本的认识。这里以 Android 为例(Ios 在渲染的处理上略有不同,而其它是一致的),先看 JNI 的接口:
private native int decodeFrame(long time, int[] pixels);
Java 层会传入时间戮 time 及名为 pixels 的 Int 数组,time 代表当前需要获取哪个时间点的动画效果,libass 接着会对与这一时间点有关的每一行文本进行解析,生成一个或多个的小图,从而得到一系列的图片,然后合成到一个大图里面去,最终通过像素拷贝的方式把合成后的结果输出到 pixels,回到 Java 以后,再把 pixels 设置至 Bitmap,最后交给 Canvas 进行渲染。
通过对各关键过程的打点并运行前述复杂效果,我们得到了各过程的耗时占比:解析 46%、合成 37%、输出与渲染各 8%,其它 1%。分解到每一帧并以毫秒计算则如下表:
接下来,我们将会按解析、合成、输出、渲染这样的顺序来逐步优化。
前面提到,每一行 ass 文本都会生成一个或多个的小图,这是因为一个文字会被拆解成文体、边框及背景三个部分,除此之外,libass 并不关心这些构成部分的颜色及透明度。这就导致了这样的一个问题:
Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,fx,{\pos(120,100)\1a&HFF&\blur3}全民K歌
以上 ass 文本所实现的是一个文字镂空效果:
1a&HFF&
表示文字主体是完全透明的,而这样的一个透明的元素,libass 依然会生成一个小图对它进行各种各样的处理,但这是完全没有必要的,于是我们对 libass 进行了第一点改造:不再生成无效的透明小图,提高 ass 解析效率的同时也减少了内存的分配,对后续合成的处理也有正向的影响
在合成的处理中,需要遍历小图的每一个像素并拆分为 ARGB4 个通道进行颜色的运算
dstA = (255 * 255 - (255 - k) * (255 - dstA)) / 255;
dstB = (k * b + (255 - k) * dstB) / 255;
dstG = (k * g + (255 - k) * dstG) / 255;
dstR = (k * r + (255 - k) * dstR) / 255;
与普通的图片合成不同,在歌词动效的场景中,小图由文字或点线之类的图形构成,往往存在着大量的透明像素及完全不透明像素,可通过判断来减少这部分的合成运算:
if(k == 0){ // 完全透明,跳过
continue;
} if(k == 255){ // 完全不透明,直接使用小图颜色
dst = color;
continue;
}
测试了 5 个在 K 歌上线的动效,合成时间减少了 10%~50%。
虽然通过透明度的判断减少了一定计算,但无法完全避免。以 Alpha 通道的计算为例,包含了 2 次乘法、1 次除法和 3 次的减法,而除法是特别耗时的。所以,对于这些必要的计算,我们进行了简化,先进行等式变换:
dstA = (255 * 255 - (255 - k) * (255 - dstA)) / 255;
= (255 - (255 - k) * (255 - dstA) / 255);
然后利用255 - x = ~x
及x / 255 ≈ x >> 8
进行替换,得到简化后的结果:
dstA = ~((~k) * (~dstA)) >> 8);
可见,一次计算变成了 1 次乘法与 4 次位运算,测得合成时间减少了 26%。
经过上述几项优化,合成速度快了许多,但这还不够。在合成的算法中,像素点与像素点间是没有任何联系的,所以可以通过并行计算的方式来提高合成的效率。我们采用了 NEON 的解决方案,利用 CPU 专用模块的 128 位寄存器同时对多个像素点进行计算,因 32 位色彩中 ARGB 各占 8 位,再考虑乘法处理后可能达到的 16 位,由此,可用 128 位寄存器同时处理 8 个像素点的计算,实现约 8 倍的加速效果,对 CPU 和帧率可起到明显的作用。 具体实现如下:
至此,合成的优化告一段落,每一帧的合成耗时由原来的 52ms,降到了 3ms 以内
输出的过程实际上只是做了一次像素拷贝的操作,把合成后的大图输出到 JNI 传入的 Int 数组里面去,除了耗时以后,还会产生额外的一次 Native 内存分配,于是,我们优化了这个过程,让合成直接在 Int 数组进行,这样就把原来输出的 11ms 完全去掉了
前面提到,数据到了 Java 层,还会调用 Bitmap 的 setPixels 方法把像素信息传给 Bitmap,最后才交给 Canvas 进行绘制,而这里的 setPixels 做的事跟刚刚输出的过程一样,会把像素点全都拷贝一次。所以,我们希望把这一过程的拷贝也给取消掉,但 Java 并没有提供接口给我们去获取 Bitmap 的 Buffer,也就采用了反射的方案,优化后,渲染耗时降低了 65%。
我们知道,卡顿的原因在于处理一帧的耗时太久,达不到我们想要的帧率要求,那很容易会想到,我们是否可以使用多线程同时处理多帧数据呢?结果是失败了,因为 libass 是单例的模式,同时处理多个时间点的解析合成会导致其内部一些状态的错乱,并以 crash 告终。虽然解码无法使用多线程,但渲染与 libass 无关,还是可以拿出来放到一个单独的线程去处理的。这就引入了一个新的问题,解码与渲染两个线程都会操作同一块内存,一边在写、一边在读,数据容易出错。于是,我们多申请了一块内存,一个解码用,一个渲染用,每次解码完成时进行交换,我们的双缓冲异步渲染方案就这样出现了
这一实现让 libass 不需要等待渲染的完成就可以进行下一帧数据的解码,有效地提高了动效的帧率
经历上述各项优化后,前述复杂动效在低端机 Note 3 上由原来的 7 帧达到 15 帧
在不干预内存的情况下,在一个 3 分多钟的作品上播放了 K 歌线上的一个普通效果,期间内存的变化见下图:
内存增量达到了 180M,且主要是 Native 层的内存,这是我们面临的一个很严重的问题,有 OOM 的风险,系统也有可能因此产生频繁的 GC 而引起卡顿
通过对 libass 源码的阅读,我们了解到了更为详细的 ASS 解析过程
每一行动效文本在 libass 中被定义一个事件,先是对事件中的动画标签及参数进行解析,得到某一瞬间的所有属性值后创建文字或图形的轮廓;接着是对它进行栅格化的处理,后续还有拼接、模糊等处理,最终生成小图并进行重排,就得到了卡顿问题中所说的一系列小图。
在这样的一个过程中,内存分配主要消耗在栅格化和拼接这 2 个过程中,且 libass 内部已经实现了一套完整的缓存管理机制,只是其默认缓存较大,分别为 128M 和 64M,总大小达到了 192M,再加上些其它的内存分配,最大会占用超过 200M 的内存才会趋于平稳。除此之外,libass 还提供了接口给我们设置缓存的大小,但只能设置总的缓存大小,不能自定义 Bitmap 和 Composite Bitmap 分别是多少,其内部会按 2:1 进行分配。
有了对 libass 的认识,内存问题也就变成了:如何寻找一个合适的缓存总大小 及 内存的 2:1 分配是否适合我们的场景。
统计动效在一次播放的过程中查询缓存的次数 M,查询后命中的次数为 N,从而得到缓存命中率 N/M。下图横轴表示了我们给 libass 设置的缓存总大小,纵轴则是 2 类缓存的命中率
通过上面的曲线,我们可以得到 2 个结论:1. 随着缓存总大小的增加,新增内存所获得的收益逐渐变小,对于 K 歌的场景,设置 4M~16M 比较合理; 2. Bitmap 与 Composite Bitmap 的分配不合理,可将更多的内存用于 Composite Bitmap。
从 K 歌线上的 10 几个动效中,随机选取了 5 个,统计各个动效处理 1500 帧数据对 2 类缓存的访求并制成了表格
通过表格的数据可以看到,Composite Bitmap 需要更大的缓存,平均约为 Bitmap 的 1.8 倍,于是我们把 libass 内 2:1 的分配规则调整为了 1:1.8,最终使用 8M 的内存基本上达到了原来 16M 的效果
设置缓存大小后,内存增长得到了控制且处于稳定状态;而调整分配比例提高了缓存命中率,减少了 CPU 在内存分配与栅格化等处理上的耗时。
本文主要介绍了动效歌词开发的关键技术和优化策略。技术方案经历了数次讨论和预研,采用了并行计算大幅减少运算时间,优化了编译策略解决了跨平台问题。在架构设计上,也充分考虑性能,跨平台,可扩展,组件化,复用性等各方面的因素。在该方案的落地实现过程中,团队的 John、Harvey、Wing、 Comic,、Jerry、rey 等同学通力合作,付出了不懈的努力!
此文已由腾讯云 + 社区在各渠道发布
获取更多新鲜技术干货,可以关注我们腾讯云技术社区 - 云加社区官方号及知乎机构号