涉及硬件的音视频能力,比如采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能
本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现,其中的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起食用,味道更棒哦
本文为该系列文章的第 5 篇,将详细讲述上一篇文章中 OpenGL 的代码为什么要那样写以及与 OpenGL 相关的部分基础原理,对应了我们 MediaPlayground 项目中 SceneVCVideoRenderOpenGLES 涉及到的内容
往期精彩内容,可参考
音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)
前言
还没看过上一篇文章的小伙伴,强烈建议把上一篇文章与本篇结合起来一起看,更能方便理解
音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)
关键细节
渲染管线
在上一篇文章的“宏观流程”章节,我们提到了渲染管线的概念,当时没有深入介绍,而是将它当做一个黑盒。在本篇中,先来详细介绍下渲染管线
有一张渲染管线的图在网络上流传很久了,就是下面这个,我第一次看到它,是在 LearnOpenGL 这个教程里
图中蓝色部分代表我们可以自定义的部分,看到顶点着色器和片段着色器,是不是很熟悉,上一篇文章讲着色器程序时就说到了这两种着色器,在之前的例子中也的确进行了自定义
用比较严谨的说法,简单一句话概括渲染管线的功能,就是接受一组 3D 坐标,然后把它们转变成 2D 像素输出,其中的每一个模块的输入都是上一个模块的输出,下面来简单介绍每个模块的功能
- 顶点着色器:进行 3D 坐标的转化,到此为止就不用深究了,从示例代码里也可以看出,没有太多复杂的东西
- 几何着色器:非必需,先不讲,非本文重点,有兴趣可自行研究
- 图元装配:将坐标点组成图形
- 光栅化:将图形映射为像素(按照上一篇文章的类比,就是把图形画在画纸上)
- 片段着色器:确定每个像素点采用什么颜色(按照上一篇文章的类比,就是在画纸上进行填色)
- 测试与混合:先不讲,非本文重点,有兴趣可自行研究
同时要牢记一个点:OpenGL 中的最基本图形是三角形,为什么呢?因为最少要用 3 个顶点才能形成 1 个平面,平面的形状也就是三角形
坐标系统
OpenGL 的顶点坐标和纹理坐标都采用归一化的形式
顶点坐标的原点在图像的中心(按照上一篇文章的类比,也就是画纸的中心)
X 取值范围从左到右是 -1.0 ~ 1.0
Y 取值范围从下到上是 -1.0 ~ 1.0
纹理坐标的原点在图像的左下角(按照上一篇文章的类比,也就是人脑中的图像的左下角)
X 取值范围从左到右是 0.0 ~ 1.0
Y 取值范围从下到上是 0.0 ~ 1.0
此时结合“最基本图形是三角形”这个规则,再看示例代码中的顶点坐标和纹理坐标,就能看出是由 2 个三角形拼接成了 1 个矩形
这里你可能要问,1 个三角形有 3 个顶点,2 个三角形应该有 6 个顶点,但是代码中只有 4 个顶点,是为什么呢?
以顶点坐标为例,看下面这张图你就明白了。因为 2 个三角形拼接成 1 个矩形的时候,2 个三角形的左下和右上 2 个顶点是重合的,因此可以通过少写一遍左下和右上 2 个顶点的方式来减少顶点数据的开销
纹理
glActiveTexture与glBindTexture
操作纹理时,经常看到 glActiveTexture 和 glBindTexture,它们分别做了什么?要理解它们的区别,先回忆下上一篇文章提到的 OpenGL 上下文的概念,每一个上下文都有一套属于自己的独立的资源
单个上下文中,多个纹理是可以同时操作,那纹理是怎么存放的呢?就需要用到 glActiveTexture 激活一个纹理单元,然后调用 glBindTexture 把对应的纹理放到这个单元里
glActiveTexture 传入的参数就是纹理单元的索引,从 GL_TEXTURE0 开始依次往上累加,总共可以有 32 个(在 OpenGL 的头文件里能看到 GL_TEXTURE0 就是个宏,对应的是一个 16 进制数)
同时用到多个纹理,而因为 OpenGL 的状态机规定同一时刻激活的纹理单元只能有一个,就需要先激活一个纹理单元、绑定纹理,再激活另一个纹理单元、绑定纹理,如此循环。上一篇文章的案例渲染 NV12 数据就用到了这个原理
// 激活纹理单元 - 0
glActiveTexture(GL_TEXTURE0);
// 绑定纹理 - 0 到 纹理单元 - 0
glBindTexture(GL_TEXTURE_2D, texture_id_0);
// 激活纹理单元 - 1
glActiveTexture(GL_TEXTURE1);
// 绑定纹理 - 1 到 纹理单元 - 1
glBindTexture(GL_TEXTURE_2D, texture_id_1);
纹理参数
设置纹理参数时,可以看到如下代码
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
仔细观察参数,可以发现给 GL_TEXTURE_MIN_FILTER 和 GL_TEXTURE_MAG_FILTER 都设置了 GL_LINEAR,给 GL_TEXTURE_WRAP_S 和 GL_TEXTURE_WRAP_T 都设置了 GL_CLAMP_TO_EDGE
先说 GL_TEXTURE_MIN_FILTER 和 GL_TEXTURE_MAG_FILTER,代表的是纹理缩小和放大时的过滤选项,而 GL_LINEAR 代表的是线性过滤,会基于纹理坐标附近的纹理像素,计算出一个插值,来近似表示该坐标对应的像素颜色,线性比较常用,可以避免一些明显的锯齿
再说 GL_TEXTURE_WRAP_S 和 GL_TEXTURE_WRAP_T,代表的是纹理坐标超出范围时,该如何计算像素颜色,GL_CLAMP_TO_EDGE 是比较常用的
纹理数据的填充
在单次渲染流程的第一步,需要将图像数据关联到纹理,有 2 种情况
如果图像数据被 CVPixelBuffer 所承载,那可以通过 CVOpenGLESTextureCacheCreateTextureFromImage 直接拿到纹理对象,该方法的参数有很多,结合代码依次来讲解
CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, GL_TEXTURE_2D, GL_LUMINANCE, pixel_width, pixel_height, GL_LUMINANCE, GL_UNSIGNED_BYTE, 0, &cv_texture);
- allocator:使用 kCFAllocatorDefault
- textureCache:使用之前创建好的 CVOpenGLESTextureCache
- sourceImage:就是现有的 CVPixelBuffer
- textureAttributes:传 NULL 即可
- target:使用 GL_TEXTURE_2D
- internalFormat:代表原始图像的格式,上一篇文章的例子中,因为 NV12 数据有 2 个平面 Y 和 UV,因此要传入各平面对应的格式,Y 对应了 GL_LUMINANCE,U 和 V 对应了 GL_LUMINANCE_ALPHA
- width:图像宽度
- height:图像高度
- format:代表这块纹理的格式,一般跟 internalFormat 保持一致即可
- type:纹理数据的类型,使用 GL_UNSIGNED_BYTE
- planeIndex:多平面 YUV 数据的平面索引;举例:NV12 的 Y 就对应了 0,UV 对应了 2
- textureOut:关联之后输出的 CVOpenGLESTextureRef,可以用它拿到 OpenGL 的纹理 id
如果原始图像数据本身已经在内存中,就需要将内存中的数据传递到之前创建好的纹理,使用的是 glTexImage2D,结合代码依次来讲解参数
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, pixel_width, pixel_height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, pixel_data);
- target:使用 GL_TEXTURE_2D
- level:使用 0
- internalformat:代表原始图像的格式,上一篇文章的例子中,因为 NV12 数据有 2 个平面 Y 和 UV,因此要传入各平面对应的格式,Y 对应了 GL_LUMINANCE,U 和 V 对应了 GL_LUMINANCE_ALPHA
- width:图像宽度
- height:图像高度
- border:使用 0
- format:代表这块纹理的格式,一般跟 internalFormat 保持一致即可
- type:纹理数据的类型,使用 GL_UNSIGNED_BYTE
- pixels:内存数据的指针
着色器
OpenGL 中的着色器用的是 GLSL,相当于是另一门编程语言了,本文不会讲太多内容,仅介绍必须要掌握的内容,因此详细的语法规则和使用技巧,可以参考这篇文章着色器 - LearnOpenGL CN
从示例代码的顶点着色器中能看到 3 个向量类型的变量
attribute vec4 vertex_coordinates;
attribute vec4 texture_coordinates;
varying vec4 passed_texture_coordinates;
attribute 修饰的是外部专门传递给顶点着色器的参数,可以看到这里传递了顶点坐标和纹理坐标
varying 修饰的是顶点着色器传递给片段着色器的参数,这里直接就用外部的纹理坐标进行赋值了
从示例代码的片段着色器中能看到 3 个变量
uniform sampler2D input_texture_0;
uniform sampler2D input_texture_1;
varying vec4 passed_texture_coordinates;
varying 修饰的 passed_texture_coordinates 很好理解,就是前面提到的从顶点着色器传递过来的纹理坐标
uniform 修饰的则是外部传递给所有着色器的参数,这里表示片段着色器接收到了外部传递的 Y 纹理和 UV 纹理,可以用这 2 块纹理进行颜色采样了
数据关联
从顶点着色器和片段着色器的代码实现能看出,顶点着色器需要外部传入顶点坐标和纹理坐标,而片段着色器需要外部传入对应的纹理,那这些数据是怎么关联起来的呢?
先看纹理是怎么传递给片段着色器的
前面讲到同时使用多个纹理时,需要把多个纹理单元依次激活并绑定纹理,那么把纹理传递给片段着色器,等价于告诉片段着色器,某个纹理变量对应的纹理放在哪个纹理单元,所以就有了下面的代码。此时 input_texture_0 对应的纹理采样器就能正确的访问到纹理单元(GL_TEXTURE0 + texture_layer)中的那块纹理
给变量赋值的过程,其实是拿到对应变量在着色器中的位置 or 索引,再通过这个位置 or 索引给着色器中的变量进行赋值。是不是觉得有点绕,没办法,OpenGL 中很多自定义参数的绑定都是这样的,只能说习惯就好
// 激活纹理单元,texture_layer 从 0 开始累加
glActiveTexture(GL_TEXTURE0 + texture_layer);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture_id);
// 找到 input_texture_0 在着色器中的位置
int location = glGetUniformLocation(shader_program_id_, "input_texture_0");
// 把 texture_layer 的值与 input_texture_0 关联
glUniform1i(location, texture_layer);
再看顶点坐标是怎么传递到顶点着色器的,纹理坐标也是类似
首先拿到坐标变量在着色器中的位置,再创建一块 buffer 来存放定义好的坐标,再根据位置进行变量的赋值
// 获取变量位置
vertex_coordinates_attribute_index_ = glGetAttribLocation(shader_program_id_, "vertex_coordinates");
// 创建 buffer
GLuint vertex_coordinates_buffer_id = 0;
glGenBuffers(1, &vertex_coordinates_buffer_id);
glBindBuffer(GL_ARRAY_BUFFER, vertex_coordinates_buffer_id);
// 往 buffer 中填充数据
glBufferData(GL_ARRAY_BUFFER, 4*2*sizeof(float_t), OpenGLESDefaultVertexCoordinates, GL_STATIC_DRAW);
//
glVertexAttribPointer(vertex_coordinates_attribute_index_, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), NULL);
glEnableVertexAttribArray(vertex_coordinates_attribute_index_);
这里要额外提一下 glBufferData 和 glVertexAttribPointer,glBufferData 用于往 buffer 中填充坐标数据,glVertexAttribPointer 用于告诉 OpenGL 如何解析传入的顶点数据,这 2 个函数的参数该怎么写,还是权威教程讲的更好,放在这结合代码一起看,会更好理解。你好,三角形 - LearnOpenGL CN
glDrawArrays
还记得前面讲坐标系统的时候,提到可以把顶点的数量精简到 4 个,那么 glDrawArrays 的参数也需要对应进行调整,也就是示例代码中展示的
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
如果你只是在做简单练习,想要更好理解一点,那也可以把坐标写成 6 个点,然后修改 glDrawArrays 的参数(注意:如果坐标点的数量发生改变,glBufferData 填充坐标数据时也要根据点的数量同步修改)
glDrawArrays(GL_TRIANGLE, 0, 6);
纹理坐标的反转
如果你看的仔细,会发现示例代码中把纹理坐标传入 buffer 之前,根据 X 轴做了反转。这是因为 OpenGL 纹理坐标的坐标原点是左下角,案例中要把渲染后的结果显示在屏幕上,而 iOS 中视图的坐标原点是左上角,如果纹理坐标不做上下反转,那么你最终看到的画面将是上下反转的
写在最后
以上就是本文的所有内容了,详细讲述了上一篇文章中 OpenGL 的代码为什么要那样写以及与 OpenGL 相关的部分基础原理。下篇文章我们将详细介绍如何在 iOS 中用 Metal 实现视频渲染
说实话,OpenGL 用起来真的很难受,习惯了面向对象的思想之后,再写 OpenGL 代码,在编码风格上会有种很割裂的感觉。在写 OpenGL 代码时,我们也时常会用面向对象的思想去封装一些常用的内容,比如纹理、着色器、渲染目标等等,让 OpenGL 用起来更舒服。后面 Apple 推荐使用的 Metal 就完全没有编码风格的困扰了,都是面向对象的思想,用起来更得心应手
本文为音视频基础能力系列文章的第 5 篇
往期精彩内容,可参考
音视频基础能力之 iOS 视频篇(四):使用OpenGL进行视频渲染(上)
后续精彩内容,敬请期待
如果您觉得以上内容对您有所帮助的话,欢迎关注我们运营的公众号声知视界,会定期的推送音视频技术、移动端技术为主轴的科普类、基础知识类、行业资讯类等文章。