OpenGL ES学习实战(360全景视频播放器)

对OpenGL ES学习了一段时间,今天实现一个360度的全景视频播放器。本博客的代码可在我的github仓库下载,但如果感觉可以就start一下你们的支持是我将博客写下去的动力!本博客的demo是我之前在面试一家做VR视频的公司时写的,虽然我拿着做好的demo去面试了但还是没让我见技术人员人事就把我打发了。

注意:本博客已假定你对OpenGL ES已经具备基本知识,很多OpenGL ES基础理论不再解释,如果感觉有不理解的地方可以找别的博客进行学习。我建议对于同一个知识最好看不同的博客有比较的学习,这样才会有不一样的收获。

全景视频原理

一、拍摄设备

全景视频在拍摄时是多个摄像机同时在一个点向四面八方拍摄。下面在网上找了一个拍摄设备的图片。

摄像机.jpg
摄像机.jpeg

我在面试的那家做VR视频的公司见到了他们的拍摄设备发面只有2个摄像头,类似下面的图片里的设备,但通过只有2个摄像头的设备来拼接成的全景视频在移动视角时会有强烈的拉伸感,我在观看一些小公司的App里的全景视频时会有这样的体验,具体视频质量的好坏这里就不深入讨论了大家可以自己在各平台对比体验一下就知道了。

拍摄设备.png

如果要拍摄的是VR视频每个方向上会有2个摄像头(区分左右眼),不过这种视频很少见。个人感觉很多公司都是通过全景视频做一下处理来生成VR视频的,所以没有很强的立体感,这也是个人的感觉,如果有对VR视频了解更多的大神可以在下面评论处说明以供大家共同学习。

二、视频拼接

后期视频的制作会将视频目标当作一个球来制作成视频,也就是说最后的视频是要渲染到一个球上面的。可以想像如果用平常的播放器来播放的效果图像的上面和下面是被拉伸的,下面是一个在网上下载的全景视频在平常的播放器播放的效果,可以看出下面的路面被严重拉伸,上面其实也被拉伸了因为是黑色的所以看着不太明显。

2017-10-09 14_49_11.gif

最后这个视频是要渲染到一个球上面的,而我们的视角在是在球的中心点,这样就可以向四面八方去观看了。下面是一个我做好的播放器里播放的效果,有对比才能看出图像被拉伸的情况。

全景效果.gif

代码实现

一、生成顶点数据

从上面的介绍可知我们只要生成一个球体并将视频的每一帧渲染到球上面就可以了。怎么生成球体可以看我另一篇博客OpenGL ES学习笔记之四(创建球体),这里只简单介绍一下。生成球的顶点信息:


/**
 绘制一个球的顶点
 
 @param num 传入要生成的顶点的一层的个数(最后生成的顶点个数为 num * num)
 @return 返回生成后的顶点
 */
- (Vertex *)getBallDevidNum:(GLint) num{
    
    if (num % 2 == 1) {
        return 0;
    }
    
    GLfloat delta = 2 * M_PI / num; // 分割的份数
    GLfloat ballRaduis = 0.3; // 球的半径
    GLfloat pointZ;
    GLfloat pointX;
    GLfloat pointY;
    GLfloat textureY;
    GLfloat textureX;
    GLfloat textureYdelta = 1.0 / (num / 2);
    GLfloat textureXdelta = 1.0 / num;
    GLint layerNum = num / 2.0 + 1; // 层数
    GLint perLayerNum = num + 1; // 要让点再加到起点所以num + 1
    
    Vertex * cirleVertex = malloc(sizeof(Vertex) * perLayerNum * layerNum);
    memset(cirleVertex, 0x00, sizeof(Vertex) * perLayerNum * layerNum);
    
    // 层数
    for (int i = 0; i < layerNum; i++) {
        // 每层的高度(即pointY),为负数让其从下向上创建
        pointY = -ballRaduis * cos(delta * i);
        
        // 每层的半径
        GLfloat layerRaduis = ballRaduis * sin(delta * i);
        // 每层圆的点,
        for (int j = 0; j < perLayerNum; j++) {
            // 计算
            pointX = layerRaduis * cos(delta * j);
            pointZ = layerRaduis * sin(delta * j);
            textureX = textureXdelta * j;
            // 解决图片上下颠倒的问题
            textureY = 1 - textureYdelta * i;
            
            cirleVertex[i * perLayerNum + j] = (Vertex){pointX, pointY, pointZ, textureX, textureY};
        }
    }
    
    return cirleVertex;
}

/**
 生成球体的顶点索引数组

 @param num 每一层顶点的个数
 @return 返回生成好的数组
 */
- (GLuint *)getBallVertexIndex:(GLint)num{
    
    // 每层要多原点两次
    GLint sizeNum = sizeof(GLuint) * (num + 1) * (num + 1);
    
    GLuint * ballVertexIndex = malloc(sizeNum);
    memset(ballVertexIndex, 0x00, sizeNum);
    GLint layerNum = num / 2 + 1;
    GLint perLayerNum = num + 1; // 要让点再加到起点所以num + 1
    
    for (int i = 0; i < layerNum; i++) {
        
        if (i + 1 < layerNum) {
            
            for (int j = 0; j < perLayerNum; j++) {
                
                // i * perLayerNum * 2每层的下标是原来的2倍
                ballVertexIndex[(i * perLayerNum * 2) + (j * 2)] = i * perLayerNum + j;
                // 后一层数据
                ballVertexIndex[(i * perLayerNum * 2) + (j * 2 + 1)] = (i + 1) * perLayerNum + j;
            }
        } else {
            
            for (int j = 0; j < perLayerNum; j++) {
                // 后最一层数据单独处理
                ballVertexIndex[i * perLayerNum * 2 + j] = i * perLayerNum + j;
            }
        }
    }
    return ballVertexIndex;
}

/**
 设置VBO
 */
- (void)setupVertexVBO {
    
    // 生成顶点数据(包括纹理顶点)
    Vertex * vertex = [self getBallDevidNum:kDivisionNum];
    // 生成顶点数据对应的索引数据
    GLuint * indexes = [self getBallVertexIndex:kDivisionNum];
    
    // 设置顶点数据的VBO缓存
    GLuint vertexBufferVBO;
    glGenBuffers(1, &vertexBufferVBO);
    glBindBuffer(GL_ARRAY_BUFFER, vertexBufferVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * (kDivisionNum + 1) * (kDivisionNum / 2 + 1), vertex, GL_STATIC_DRAW);
    
    // 设置顶点索引数据的VBO缓存
    GLuint indexBufferVBO;
    glGenBuffers(1, &indexBufferVBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBufferVBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLuint) * (kDivisionNum + 1) * (kDivisionNum + 1), indexes, GL_STATIC_DRAW);
    
    // 设置顶点数据在从VBO中读取和传递的指针设置
    glVertexAttribPointer(_myPositionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (GLvoid *)NULL);
    glEnableVertexAttribArray(_myPositionSlot);
    
    glVertexAttribPointer(_myTextureCoordsSlot, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (GLfloat *)NULL + 3);
    glEnableVertexAttribArray(_myTextureCoordsSlot);

    free(vertex);
    free(indexes);
}

这里提一下,在我另一篇博客里生成的球体视角是在球体的外面,想让视角在球体内可将projectionMatrix传的值中让其沿Z轴偏移的代码删除即可,因为我们生成的球体默认视角就在球体的中心,我将projectionMatrix沿Z轴偏移了-3才让视角变成了外面。

二、生成纹理数据

首先我们要知道视频其实就是一帧一帧的图片,我们只要将每一帧图片在相应时间点渲染到球体上就可以实现视频播放了。首先我们获取视频数据:

/**
 设置播放数据
 */
- (void)setupPlayerData{
    
    NSString * path = [[NSBundle mainBundle] pathForResource:@"demo.mp4" ofType:nil];
    // 获取视频资源信息
    _myAsset = [AVAsset assetWithURL:[NSURL fileURLWithPath:path]];
    
    _myPlayerItem = [[AVPlayerItem alloc] initWithAsset:_myAsset];
    // 创建视频播放器
    _myPlyaer = [[AVPlayer alloc] initWithPlayerItem:_myPlayerItem];
    // 播放视频
    [_myPlyaer play];
    
    // 设置视频格式信息
    NSDictionary * dic = [NSDictionary dictionaryWithObjectsAndKeys:@(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), kCVPixelBufferPixelFormatTypeKey, nil];
    // 创建视频输出,后面会从_myPlayerOutput里读取视频的每一帧图像信息
    _myPlayerOutput = [[AVPlayerItemVideoOutput alloc] initWithOutputSettings:dic];
    [_myPlayerItem addOutput:_myPlayerOutput];
}

通过上面的步骤我们可以获取视频每一帧的CVPixelBufferRef信息,原来我是用普通的图片转纹理的方式实现的,但运行的时候总出错后来查了一下资料发现CoreVideo库里有专门转OpenGL纹理的方法(CVOpenGLESTextureCacheCreateTextureFromImage),后来就用该方法解决了问题。方法如下,由于学习时间不长下面的参数注解只是我个人见解,可能会有错误这里供大家做个参考吧。

CVOpenGLESTextureCacheCreateTextureFromImage(
    CFAllocatorRef CV_NULLABLE allocator,  // 分配纹理对象,可能为NULL,
    CVOpenGLESTextureCacheRef CV_NONNULL textureCache, // 纹理缓存
    CVImageBufferRef CV_NONNULL sourceImage, // 传入的图像数据,用于生成相应纹理
    CFDictionaryRef CV_NULLABLE textureAttributes, // 创建纹理的属字典,可传NULL
    GLenum target, // 渲染的目标,可为GL_TEXTURE_2D和GL_RENDERBUFFER
    GLint internalFormat, // 图片的色彩空间信息,如GL_RGBA、GL_LUMINANCE、GL_RGBA8_OES、 GL_RG和 GL_RED 
    GLsizei width, // 图片的宽
    GLsizei height, // 图片的高
    GLenum format, // 每个像素数据的色彩空间,如GL_RGBA、GL_LUMINANCE
    GLenum type, // 每个像素数据的类型
    size_t planeIndex, // 视频数据buffer里哪一个平面的数据
    CVOpenGLESTextureRef  * CV_NONNULL textureOut // 最终输出的纹理信息
) 

我们获取的视频数据是YUV(其实是YCbCr的色彩空间)格式的,该种格式主要是用于图像数据的的压缩。该种格式将像素信息分为1个亮度通道和2个色度通道。由于人眼对色度的感知不太敏感而对亮度感知很敏感,当我们减弱色度通道的信息时人眼也很难察觉到前后图像的变化。通过这一原理就可以通过删除部分像素的色度通道而使用相邻像素色度数据的方式来显示图像,这样就能实现图像的压缩。更详细的解释可查看其他资料,这里只做简单说明。YUV的buffer数据一般会有两个平面,一个是亮度一个是色度(2个色度通道混在一个平面里),我们都要将其传给着色器:

/**
 设置视频数据转纹理
 */
- (void)setupVideoTexture{
    
    CMTime time = [_myPlayerItem currentTime];
    
    // 通过时间获取相应帧的图片数据
    CVPixelBufferRef pixelBuffer = [_myPlayerOutput copyPixelBufferForItemTime:time itemTimeForDisplay:nil];
    
    if (pixelBuffer == nil) {
        return;
    }
    
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);
    
    CVReturn result;
    
    GLsizei textureWidth = (GLsizei)CVPixelBufferGetWidth(pixelBuffer);
    GLsizei textureHeight = (GLsizei)CVPixelBufferGetHeight(pixelBuffer);
    
    if (_cache == nil) {
        
        NSLog(@"no video texture cache");
    }
    
    _lumaTexture = nil;
    _chromaTexture = nil;
    // 刷新缓冲区保证上次数据正常提交
    CVOpenGLESTextureCacheFlush(_cache, 0);
    
    glActiveTexture(GL_TEXTURE0);
    result = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                          _cache,
                                                          pixelBuffer,
                                                          nil,
                                                          GL_TEXTURE_2D,
                                                          GL_RED_EXT,
                                                          textureWidth,
                                                          textureHeight,
                                                          GL_RED_EXT,
                                                          GL_UNSIGNED_BYTE,
                                                          0,
                                                          &_lumaTexture);
    if (result != 0) {
        NSLog(@"create CVOpenGLESTextureCacheCreateTextureFromImage failure 1 %d", result);
    }
    // 绑定纹理
    glBindTexture(CVOpenGLESTextureGetTarget(_lumaTexture), CVOpenGLESTextureGetName(_lumaTexture));
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
    // UV
    glActiveTexture(GL_TEXTURE1);
    result = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                          _cache,
                                                          pixelBuffer,
                                                          nil,
                                                          GL_TEXTURE_2D,
                                                          GL_RG_EXT,
                                                          textureWidth/2,
                                                          textureHeight/2,
                                                          GL_RG_EXT,
                                                          GL_UNSIGNED_BYTE,
                                                          1,
                                                          &_chromaTexture);
    if (result != 0) {
        NSLog(@"create CVOpenGLESTextureCacheCreateTextureFromImage failure 2 %d", result);
    }
    // 绑定纹理
    glBindTexture(CVOpenGLESTextureGetTarget(_chromaTexture), CVOpenGLESTextureGetName(_chromaTexture));
    
    CFRelease(_lumaTexture);
    CFRelease(_chromaTexture);
    
    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
    CVPixelBufferRelease(pixelBuffer);
    
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
三、YUV转RGBA

图像显示的数据是RGBA格式的而我们传的并不是RGBA格式的而是YUV,所以我们还要将YUV数据转成RGBA数据。在网上找了一个YUV转RGB的公式,在查找资料的时候发现公式有好几个,其中只是系数的细微差别。公式已在下面列出,其中公式一和公式二都可正常使用,对于公式三可适用于普通的图片资料的转换对于视频格式是不适用的,因为视频类的色彩通道的取值范围不同与图片详情可看音视频开发:RGB与YUV相互转换问题这篇博客。

// YUV转GRB公式一、
R = 1.164 * (Y - 16) + 1.596 * (V - 128)
G = 1.164 * (Y - 16) - 0.39 * (U - 128) - 0.813 * (V - 128)
B = 1.164 * (Y - 16) + 2.018 * (U - 128)

// YUV转GRB公式二、
R = 1.164 * (Y - 16) + 1.793 * (V - 128)
G = 1.164 * (Y - 16) - 0.213 * (U - 128) - 0.533 * (V - 128)
B = 1.164 * (Y - 16) + 2.112 * (U - 128)

// YUV转GRB公式三、
R = Y + 1.402V
G = Y - 0.344U - 0.714V
B = Y + 1.772U

对于色彩空间的转换要在着色器里进行,我们将片元着色器的代码写成如下形式:

precision mediump float;
// 亮度通道纹理
uniform sampler2D myTexture;
// 色度通道纹理
uniform sampler2D samplerUV;

varying vec2 myTextureCoordsOut;

void main()
{
    mediump vec3 yuv;
    lowp vec3 rgb;
    
    yuv.x = texture2D(myTexture, myTextureCoordsOut).r;;
    yuv.yz = texture2D(samplerUV, myTextureCoordsOut).rg;
    
    rgb.r = 1.164 * (yuv.x - 16.0 / 255.0) + 1.793 * (yuv.z - 128.0 / 255.0);
    rgb.g = 1.164 * (yuv.x - 16.0 / 255.0) - 0.213 * (yuv.y - 128.0 / 255.0) - 0.533 * (yuv.z - 128.0 / 255.0);
    rgb.b = 1.164 * (yuv.x - 16.0 / 255.0) + 2.112 * (yuv.y - 128.0 / 255.0);
    
    gl_FragColor = vec4(rgb, 1.0);
}

上面的代码我们还可以简化一下,将上面的公式转换成矩阵乘法因为GUP更适合矩阵的计算,代码修改如下:

precision mediump float;
// 亮度通道纹理
uniform sampler2D myTexture;
// 色度通道纹理
uniform sampler2D samplerUV;

varying vec2 myTextureCoordsOut;

void main()
{
    // 用一个矩阵来简化后面YUV转GRB的计算公式
    mat3 conversionColor = mat3(1.164, 1.164, 1.164,
                                 0.0, -0.213, 2.112,
                                 1.793, -0.533, 0.0);
    
    mediump vec3 yuv;
    lowp vec3 rgb;
    
    yuv.x = texture2D(myTexture, myTextureCoordsOut).r - (16.0/255.0);
    yuv.yz = texture2D(samplerUV, myTextureCoordsOut).rg - vec2(0.5, 0.5);
    
    rgb = conversionColor * yuv;
    
    gl_FragColor = vec4(rgb, 1.0);
}

到这里播放器的主要代码基本已经完成其他细节可在我github代码里查看,上面需要一个定时器来不停的调用渲染方法以实现播放不同的帧数据,播放效果的图以在上面给出过这里不再占地方了。

小优化

这里存在一个问题,球体的外面和里面都会渲染我们从外面看一下效果。

球体的外面.gif

在播放全景视频的时候我们是不会看外面的图像,这样就会造成性能消耗上的浪费。所以我们要把外面剔除不让其渲染,在原代码里添加以下代码:

// 在渲染方法这前写以下代码
// 面剔除以提高性能
    glEnable(GL_CULL_FACE); // 开启面剔除
    glCullFace(GL_BACK); // 剔除背面
    glFrontFace(GL_CW); // 设置顺时针为前面

其他的方法都很简单这里就不啰嗦了。这里已把主要的实现说明了其他细节可以在我的代码里查看。写的有点仓促如果有不妥的地方还请各位大神指正。

推荐阅读更多精彩内容