使用 OpenGL ES 实现全景播放器

全景视频在播放的时候,可以自由地旋转视角。如果结合手机的陀螺仪,全景视频在移动端可以具备更好的浏览体验。本文主要介绍如何基于 AVPlayer 实现一个全景播放器。

首先看一下最终的效果:

在上一篇文章中,我们了解了如何对视频进行图形处理。(如果还不了解的话,建议先阅读一下。传送门

一般全景视频的编码格式与普通视频并无区别,只不过它的每一帧都记录了 360 度的图像信息。全景播放器需要做的事情是,可以通过参数的设置,播放指定区域的图像。

所以,我们需要实现一个滤镜,这个滤镜可以接收一些角度相关的参数,渲染指定区域的图像。然后我们再将这个滤镜,通过上一篇文章的方式,应用到视频上,就可以实现全景播放器的效果。

一、构造球面

全景视频的每一帧图像,其实是一个球面纹理。所以,我们第一步要做的是先构造球面,然后把纹理贴上去。

首先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/// 生成球体数据
/// @param slices 分割数,越多越平滑
/// @param radius 球半径
/// @param vertices 顶点数组
/// @param indices 索引数组
/// @param verticesCount 顶点数组长度
/// @param indicesCount 索引数组长度
- (void)genSphereWithSlices:(int)slices
radius:(float)radius
vertices:(float **)vertices
indices:(uint16_t **)indices
verticesCount:(int *)verticesCount
indicesCount:(int *)indicesCount {
// (1)
int numParallels = slices / 2;
int numVertices = (numParallels + 1) * (slices + 1);
int numIndices = numParallels * slices * 6;
float angleStep = (2.0f * M_PI) / ((float) slices);

// (2)
if (vertices != NULL) {
*vertices = malloc(sizeof(float) * 5 * numVertices);
}

if (indices != NULL) {
*indices = malloc(sizeof(uint16_t) * numIndices);
}

// (3)
for (int i = 0; i < numParallels + 1; i++) {
for (int j = 0; j < slices + 1; j++) {
int vertex = (i * (slices + 1) + j) * 5;

if (vertices) {
(*vertices)[vertex + 0] = radius * sinf(angleStep * (float)i) * sinf(angleStep * (float)j);
(*vertices)[vertex + 1] = radius * cosf(angleStep * (float)i);
(*vertices)[vertex + 2] = radius * sinf(angleStep * (float)i) * cosf(angleStep * (float)j);
(*vertices)[vertex + 3] = (float)j / (float)slices;
(*vertices)[vertex + 4] = 1.0f - ((float)i / (float)numParallels);
}
}
}

// (4)
if (indices != NULL) {
uint16_t *indexBuf = (*indices);
for (int i = 0; i < numParallels ; i++) {
for (int j = 0; j < slices; j++) {
*indexBuf++ = i * (slices + 1) + j;
*indexBuf++ = (i + 1) * (slices + 1) + j;
*indexBuf++ = (i + 1) * (slices + 1) + (j + 1);

*indexBuf++ = i * (slices + 1) + j;
*indexBuf++ = (i + 1) * (slices + 1) + (j + 1);
*indexBuf++ = i * (slices + 1) + (j + 1);
}
}
}

// (5)
if (verticesCount) {
*verticesCount = numVertices * 5;
}
if (indicesCount) {
*indicesCount = numIndices;
}
}

这段代码参考自 bestswifter/BSPanoramaView 这个库。它通过分割数球半径,生成了顶点数组索引数组

现在来逐行解释代码的含义:

(1) 这部分代码是对原始图像进行分割。下面以 slices = 10 为例进行讲解:

如图,slices 表示分割的份数,横向被分割成了 10 份。numParallels 表示层数,纵向分割成 5 份。因为纹理贴到球面时,横向需要覆盖 360 度,纵向只需要覆盖 180 度,所以纵向分割数是横向分割数的一半。可以把它们想象成经纬度来帮助理解。

numVertices 表示顶点数,如图中蓝色点的个数。numIndices 表示索引数,当使用 EBO 绘制矩形的时候,一个矩形需要 6 个索引值,所以这里需要用矩形的个数乘以 6 。

angleStep 表示纹理贴到球面后,每一份分割对应的角度增量。

(2) 根据顶点数索引数申请顶点数组索引数组的内存空间。

(3) 开始创建顶点数据。这里遍历每一个顶点,计算每一个顶点的顶点坐标和对应的纹理坐标。

为了方便表示,将角 AOB 记为 α ,将角 COD 记为 β ,半径记为 r 。

ij 都为 0 的时候,表示的是图中的 G 点。实际上,第一行的 11 个点都会和 G 点重合。

对于图中的 A 点,它的坐标为:

1
2
3
x = r * sin α * sin β
y = r * cos α
z = r * sin α * cos β

由此易得出顶点坐标的计算公式。

而纹理坐标只需要根据分割数等比增长。值得注意的是,由于纹理坐标的原点在左下角,所以纹理坐标的 y 值要取反,即 G 点对应的纹理坐标是 (0, 1)

(4) 计算每个索引的值。其实很好理解,比如第一个矩形,它需要用到第一行的前两个顶点和第二行的前两个顶点,然后将这四个顶点拆成两个三角形来组合。

(5) 返回生成的顶点数组和索引数组的长度,在实际渲染的时候需要用到。因为每一个顶点有 5 个变量,所以需要乘上 5 。

将上面生成的数据进行绘制,可以看到球面已经生成:

二、透视投影

OpenGL ES 默认使用的是正射投影,正射投影的特点是远近图像的大小是一样的。

在这个例子中,我们需要使用透视投影。透视投影定义了可视空间的平截头体,处于平截头体内的物体才会被以近大远小的方式渲染。

如图,我们需要使用 GLKMatrix4MakePerspective(float fovyRadians, float aspect, float nearZ, float farZ) 来构造透视投影的变换矩阵。

fovyRadians 表示视野,fovyRadians 越大,视野越大。aspect 表示视窗的比例,nearZ 表示近平面,farZ 表示远平面。

在实际使用中,nearZ 一般设置为 0.1farZ 一般设置为 100

具体代码如下:

1
2
3
GLfloat aspect = [self outputSize].width / [self outputSize].height;
CGFloat perspective = MIN(MAX(self.perspective, kMinPerspective), kMaxPerspective);
GLKMatrix4 matrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(perspective), aspect, 0.1, 100.f);

因为摄像机的默认坐标是 (0, 0, 0),而球面的半径是 1,处于 0.1 ~ 100 这个范围内。所以通过透视投影的矩阵变换后,看到的是从球面的内部,由平截头体截出来的图像。

因为是球面内部的图像,所以是镜像的(这个问题后面一起解决)。

三、视角移动

手机设备内置有陀螺仪,可以实时获取到设备的 rollpitchyaw 信息,它们被称为欧拉角

但凡使用过欧拉角,都会遇到一个万向节死锁问题,它可以用四元数来解决。所以我们这里不直接读取设备的欧拉角,而是使用四元数,再把四元数转成旋转矩阵。

幸运的是,系统也提供四元数的直接访问接口:

1
CMQuaternion quaternion = self.motionManager.deviceMotion.attitude.quaternion;

但是得到的四元数还不能直接使用,需要做三步变换:

第一步: Y 轴取反

1
matrix = GLKMatrix4Scale(matrix, 1.0f, -1.0f, 1.0f);

考虑到前面 X 轴镜像的问题,所以这一步实际上是:

1
matrix = GLKMatrix4Scale(matrix, -1.0f, -1.0f, 1.0f);

第二步: 顶点着色器 y 分量取反

1
2
// Panorama.vsh
gl_Position = matrix * vec4(position.x, -position.y, position.z, 1.0);

第三步: 四元数 x 分量取反

1
2
3
4
5
6
CMQuaternion quaternion = self.motionManager.deviceMotion.attitude.quaternion;
double w = quaternion.w;
double wx = quaternion.x;
double wy = quaternion.y;
double wz = quaternion.z;
self.desQuaternion = GLKQuaternionMake(-wx, wy, wz, w);

然后通过 self.desQuaternion 才能计算出正确的旋转矩阵。

1
2
GLKMatrix4 rotation = GLKMatrix4MakeWithQuaternion(self.desQuaternion);
matrix = GLKMatrix4Multiply(matrix, rotation);

四、镜头平滑移动

我们在不断地移动手机时,self.desQuaternion 会不断地变化。由于移动手机的速度是变化的,所以 self.desQuaternion 的增量是不固定的。这样导致的结果是画面卡顿。

所以需要做平滑处理,在当前四元数目标四元数之间,根据一定的增量进行线性插值。这样能保证镜头的移动不会发生突变。

1
2
float distance = 0.35;   // 数字越小越平滑,同时移动也更慢
self.srcQuaternion = GLKQuaternionNormalize(GLKQuaternionSlerp(self.srcQuaternion, self.desQuaternion, distance));

五、渲染参数传递

在实际的渲染过程中,外部可以进行渲染参数的调整,来修改渲染的结果。

比如以 perspective 为例,看一下在修改视野大小的时候,具体的参数是怎么传递的。

1
2
3
4
5
6
7
8
// MFPanoramaPlayerItem.m
- (void)setPerspective:(CGFloat)perspective {
_perspective = perspective;
NSArray *instructions = self.videoComposition.instructions;
for (MFPanoramaVideoCompositionInstruction *instruction in instructions) {
instruction.perspective = perspective;
}
}

MFPanoramaPlayerItem 中,当 perspective 修改时,会从当前的 videoComposition 中获取到 MFPanoramaVideoCompositionInstruction 数组,再遍历赋值。

1
2
3
4
5
// MFPanoramaVideoCompositionInstruction.m
- (void)setPerspective:(CGFloat)perspective {
_perspective = perspective;
self.panoramaFilter.perspective = perspective;
}

MFPanoramaVideoCompositionInstruction 中,修改 perspective 会给 panoramaFilter 赋值。然后 MFPanoramaFilter 开始渲染的时候,在 startRendering 方法中,会根据 perspective 属性,生成新的变换矩阵。

六、避免后台渲染

由于 OpenGL ES 不支持后台渲染,所以要注意,在 APP 切换到后台前,应该暂停播放。

1
2
3
4
5
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(willResignActive:)
name:UIApplicationWillResignActiveNotification
object:nil];
1
2
3
4
5
- (void)willResignActive:(NSNotification *)notification {
if (self.state == MFPanoramaPlayerStatePlaying) {
[self pause];
}
}

源码

请到 GitHub 上查看完整代码。

参考