估计是全网第一篇--VAP动画iOS源码解读

零、前言

VAP(Video Animation Player)是由腾讯的企鹅电竞团队开发的一套用于播放酷炫动画的实现方案。大概在 2019 年中,VAP 还未在 Github 正式对外开源时,因为项目需要,我开始接触到 VAP。也见证了 VAP 从只能常规地去播动画,到实现了融合特效,再到在 Github 开源的过程。

在最初 VAP 还未完善时,我们也与电竞的同事一起发现、解决了一些 bug;同时在我们自己项目使用的过程中,偶尔也有对 VAP 进行二次开发以适应我们自己的需要。最终这也使我对 VAP 的源码较为了解,让我获益匪浅。

关于 VAP 的原理,在 Github 以及 KM 中都已经有较多讲解的文章了,但目前还没有源码级别的解读,而我作为 VAP 的第一批开发者(算是吧),也就尝试写一篇文章来记录一下。

一、基础实现

1.1、原理概述

总体上,VAP 可以说是通过播放一个 MP4 来实现动画的展示的。但传统的 H264 里并没有记录其透明度信息,因此 VAP 通过在视频帧里额外开辟一块区域,用于保存透明度的信息,如下:

然后渲染时再将透明度区域(Alpha 区)的信息读取出来,与正常的视频区域的颜色混合一起,便得到了带透明度的视频画面了。

1.2、帧读取

首先讲讲视频帧的读取。

MP4 中的数据是以一个个 BOX 的形式存放的,其中视频帧媒体数据保存在 mdat 这个 BOX 中。而我们要读取帧的数据,要知道每一帧在 mdat 中存放的位置以及长度。VAP 也定义了一个承载帧数据的类 QGMP4Sample,每一帧就是一个 Sample。

1
2
3
4
5
6
7
8
@interface QGMP4Sample : NSObject

...
@property (nonatomic, assign) uint32_t sampleSize;
@property (nonatomic, assign) uint32_t streamOffset;
...

@end

那如何得到每个 Sample 的这两个信息呢?则需要 stts、stsz、stsc、stco 几个 BOX 一同解出。

通过遍历 stts 中的 entry,根据 entry 中记录的 Sample 数量得知整个视频总共有多少帧,创建出相应数量的 QGMP4Sample,并且从 stsz 中得到了每个 Sample 的数据长度。简单来说 stts、stsz 中的数据形式如下:

另外从本节第 2 张图里还能看到 Samplestts.entry 里读到了 sampleDelta。这个值代表这个 Sample 需要播放的时长(同一个 entry 中所有的 Sample 的播放时长是相同的),但实际上这个值在后续并没有用到,这个后面再说。

现在已经得到 Sample 的长度(sampleSize)了,接下来还需要得到它的位置。而 Sample 是以 Chunk 的形式组织起来存放的。

因此要获取一个 Sample 的位置,可以通过获取到它所在 Chunk 的起始位置,加上它在 Chunk 内的相对偏移得到。

回到上一段代码的后面,接下来通过 stsc 得到了整个视频的 Chunk 数量,创建出相应的 QGChunkOffsetEntry,并且通过 stco 得到每个 Chunk 的起始位置。

最后就如上面说的,通过 Sample 所在 Chunk 的起始位置 + Sample 在 Chunk 内的相对偏移,就能得到每个 Sample 的位置(streamOffset)了。stsc、stco 的格式也简单画一下:

经过这一步,目前已经得到每个 Sample 的起始位置长度了,因此每一帧数据都能读取到了。

1.3、播放驱动

得到了帧数据后,再看看播放的驱动方式。

这里是动画触发播放的入口。可以看到播放的触发机制是一个 while 循环,每次通过往主线程抛一个 [self hwd_displayNext] 调用去播放一帧,然后通过将线程 sleep 一段时间来等待下一帧的播放。

这个等待下一帧的时长我们看到取的是 nextFrame.duration/1000.0,而这个 nextFrame.duration 则是通过以下方法算出:

这里有两个 fps,一个是业务方调用播放接口时传入的 self.hwd_fps,另一个 frame.defaultFps 则是在视频文件中通过 总帧数/视频时长 算出的。当业务方传入的 fps 合法(小于60,大于0),则取用它;否则取用从视频文件数据中算出的值。

除此之外,回到本节第 1 张图,还能看到得到 duration 后,还做了一个减法来“追回时间”:

1
duration -= ((currentTimeInterval-lastRenderingInterval) - lastRenderingDuration);

1
((当前时间 - 上次渲染的时间) - 上一帧需要展示多久)

就是如果当前时间 > 上一帧开始时间 + 上一帧展示时间,则代表当前时间比预计要晚了,需要将当前这一帧缩短一点,追回晚了的时间。从而避免因性能原因导致视频播放的时长发生了变化。

1.4、解码

结下来看看视频的解码。从上一节看到播放触发的入口是:

1
hwd_renderVideoRun() -> hwd_displayNext()

而接下来 hwd_displayNext() 将调用以下方法 取出已解码的一帧进行渲染,并开启下一帧的解码

最终解码的逻辑位于以下这个方法:

1
- (void)_decodeFrame:(NSInteger)frameIndex

解码具体的实现就是直接使用 VideoToolBox 了。取出待解码的一帧数据,再加上从 avcC BOX 中取到的 PPS、SPS 一同送给 VideoToolBox 进行硬解。这里不多讲了。

1.5、渲染

VAP 在 iOS 这边的画面渲染是通过 Metal 实现的。因此在讲渲染的代码前,先简单讲一点 Metal 的基础。

Metal 是一个和 OpenGL ES 类似的面向底层的图形编程接口,对比 OpenGL ES 性能更强悍,但不支持跨平台。因此目前只有 iOS 端使用了 Metal,而 Android 端的 VAP 则是通过 OpenGL ES 实现的。

通过 Metal 来画画面,跟平常通过 UIKit 来画画面其实一样,主要回答两个问题:

  1. 要渲染到哪里
  2. 要渲染什么上去

对于第一个问题,Metal 有自己的一套与 UIKit 不一样的坐标系:

UIKit 下 frame 的坐标跟手机型号有关,原点在左上角。但 Metal 下顶点的坐标系取值范围是 [-1, 1],其算的是一个比例。假设现在的画布是整个屏幕,那么这个屏幕的左下角的坐标就是 (-1, -1),右上角是 (1, 1),屏幕正中间就是 (0, 0)。

对于第二个问题,当前我们要渲染的是一帧帧的画面,这些画面在渲染前将被转成 纹理。而对于纹理的访问,也有一套坐标体系:

纹理的坐标系范围是 [0, 1],原点在左上角。当我们要将一个纹理贴到画布上的时候,就需要指明要怎么去贴。例如我们要将下面这一帧的左上角贴到画布的左上角,那么我们需要取得 Alpha 区域的左上角(0, 0)、RGB 区域的左上角(0.5, 0),混合后画在画布左上角(-1, 1)。

VAP 也就是这样做的。其定义了一个 struct:

三个变量分别代表画布上的顶点、RGB 纹理的顶点、Alpha 区纹理的顶点。利用这一个结构体将这三个信息一同记录下来,以便后续的处理。

接下来来到着色器这里。

逻辑也很清晰,就如同原理概述一节说到,从 RGB 区域取得画面的 RGB 值,再从 Alpha 区取得取出保存在红色通道中的值当作透明度,构成 RGBA 返回,从而合成了包含透明度的画面。

1.6、音频动画

由于 VAP 的底层播放逻辑就是播放一个 MP4,因此对于音频动画是天然支持的。因此其实现逻辑很简单。首先在一开始解 BOX 时检测是否存在音轨,如果存在就用 MP4 文件的路径创建出 AVAudioPlayer。

然后在播放时判断当前播放到第 0 帧,就触发这个 AVAudioPlayer 的播放。

1
2
3
4
5
6
7
- (QGMP4AnimatedImageFrame *)hwd_displayNext {
...
if (nextIndex == 0) {
[self.hwd_decodeManager tryToStartAudioPlay];
}
...
}

播放一个基础的动画整体的逻辑差不多就是上面这样了。接下来讲一讲新特性融合特效

二、融合特效

2.1、原理概述

前面说的动画展示都是直接将 MP4 的内容展示出来。如果除了 MP4 本身的内容之外,还需要像上图这样往动画中插入自定义图片、文案的动画特效,就称之为融合特效

简单来说,其实现的方式是在原本渲染每一帧的同时,将配置了的自定义内容也计算好其显隐情况一并渲染上去。

2.2、布局信息

先看一眼支持融合特效之后的 MP4 资源文件。对比原本的资源文件,新的资源文件的 Alpha 区域缩小了,同时在节省下来的位置放入了几个长得跟需要贴入自定义内容的区域大小相似的色块。从这里可以提出几个问题:

  1. RGB 区域与 Alpha 区域的位置如何对应上
  2. 自定义内容要展示在原视频的哪个位置
  3. 自定义内容的透明度怎么定义

在上面 1.5 节有讲到,我们需要找到 RGB 区域中的点与 Alpha 区域的点的对应关系,而现在 Alpha 区域所在的位置、大小变得不是固定的,因此需要将这些信息记录下来,方便读取。而 VAP 的方案是将这些信息保存在 MP4 文件的一个自定义BOX(vapc)里。

里面的信息总结一下,主要是这些:

  1. info:基础信息
    • RGB 区在整个视频中的 frame
    • Alpha 区在整个视频中的 frame
    • 视频实际展示时的宽高
  2. src:融合特效列表
    • 融合特效 id
    • 融合特效宽高
    • 特效标签
    • 资源类型(img / text)
  3. frame:每一帧(如果有)里包含的融合特效及其位置
    • 融合特效在实际展示视频中的 frame
    • 融合特效遮罩区域在视频文件中的 frame
    • 融合特效 id

有了这些信息,前面的问题都能回答了。首先利用 1.info ,分别算出 RGB 区、Alpha 区位于整个视频的百分比,即得到了在纹理中两者坐标的对应关系:

而融合特效要展示在哪里,也可以通过 1.info 中视频实际展示时的宽高、 3.frame 中的融合特效实际展示时的 frame,算出融合特效的顶点:

自定义内容的透明度则通过自定义内容素材的 Alpha(source.a),乘以本节第 1 张图中看到的自定义内容区域色块的红色通道值(mask.r)来算出:

2.3、融合素材

基本逻辑都清晰了,最后再说下自定义内容的素材获取。在上一节有提到, vapc 里面保存了一个 融合特效列表。在播放开始前,VAP 将往业务层抛回调,将其中包含的融合特效标签传过去,业务层通过这个标签标识其业务,并返回对应的一个 String:

接下来会再次遍历这些从业务层获取到的值,如果自定义内容是文案,则利用 CoreGraphics 将其绘制成图片;如果是图片,则将上次取得的值再次调到业务层去取真正要展示的图片:

因此最终所有这些自定义信息都变成了图片,最后统一将这些图片转成纹理(MTLTexture),等待后续渲染时用即可。

融合特效包含的内容也大概讲到这里。

三、结语

VAP 目前已经成为业内优秀的特效动画实现方案,在 Github 拥有 1.7k 的 Stars。很高兴能和 VAP 一同成长,祝 VAP 越来越好。