使用 OpenGL ES 实现全景播放器

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

首先看一下最终的效果:

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

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

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

一、构造球面

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

首先来看一段代码:

/// 生成球体数据
/// @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 点,它的坐标为:

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

具体代码如下:

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 信息,它们被称为 欧拉角

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

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

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

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

第一步: Y 轴取反

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

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

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

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

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

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

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 才能计算出正确的旋转矩阵。

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

四、镜头平滑移动

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

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

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

五、渲染参数传递

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

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

// 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 数组,再遍历赋值。

// MFPanoramaVideoCompositionInstruction.m
- (void)setPerspective:(CGFloat)perspective {
    _perspective = perspective;
    self.panoramaFilter.perspective = perspective;
}

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

六、避免后台渲染

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

NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
           selector:@selector(willResignActive:)
               name:UIApplicationWillResignActiveNotification
             object:nil];

- (void)willResignActive:(NSNotification *)notification {
    if (self.state == MFPanoramaPlayerStatePlaying) {
        [self pause];
    }
}