这个公众号会路线图式的遍历分享音视频技术:音视频基础 → 音视频工具 → 音视频工程示例 → 音视频工业实战。关注一下成本不高,错过干货损失不小 ↓↓↓
这篇文章是音视频基础专栏系列关于渲染的第一篇文章,我们来聊一聊 OpenGL,希望能做到让没接触过 OpenGL 的同学能比较容易的建立起一个初步的印象。
这篇文章的内容包括:
提到移动设备的图形渲染,我们经常会听到 OpenGL、OpenGL ES、Metal、Vulkan 等方案,它们有什么差别呢?
这些渲染方案之间还有着一定的历史渊源:
OpenGL 已经发展了 25 年以上,不断满足着行业需求。但是,随着 CPU、GPU 等硬件技术的发展和 3D 等更复杂场景对性能的需要,OpenGL 已经逐渐满足不了行业的需要了。后来在 2013 年,AMD 主导开发了 Mantle
。Mantle 是面向 3D 游戏的新一代图形渲染接口,可以让开发人员直接操作 GPU 硬件底层,从而提高硬件利用率和游戏性能,效果显著。Mantle 很好的带动了图形行业发展,微软参考 AMD Mantle 的思路开发了 DirectX 12
,苹果则提出了 Metal
。但是因为 AMD 行业影响力和领导力不足,Mantle 没有发展成为全行业的标准。2015 年,AMD 宣布不在维护 Mantle,Mantle 功成身退。Khronos 接过 AMD 手中的接力棒,在 Mantle 的基础上推出了 Vulkan
,Khronos 最先把 Vulkan API 称为『下一代 OpenGL 行动(glNext)』,但在正式宣布 Vulkan 之后这些名字就没有再使用了。
2014 年之前苹果一直是使用 OpenGL ES 来处理底层渲染,之后慢慢的把渲染框架迁移到了 Metal。到 iOS 12 苹果已经开始弃用 OpenGL,完全使用 Metal 实现底层渲染。不过 OpenGL 是跨平台的且相当稳定,目前 Metal 还只是用于苹果体系。
谷歌则是从 2016 年的 Android N(安卓 7.0)开始支持 Vulkan API。当然 OpenGL ES 也仍是持续支持的。
可以看到移动设备的渲染方案基本上都是从 OpenGL 的思想上继承和发展而来的,所以了解 OpenGL 就变得很有必要,我们接着往下讲。
要了解 OpenGL,首先可以看看它在一个应用程序中的位置和角色。
OpenGL 不能开发程序、构建后台,它只是一套处理图形图像的统一规则。它在一个图形应用程序中的角色大致如下图所示:
OpenGL 在图形应用中的角色(iOS)
上图是基于 iOS 平台的,图中的 Core Graphics
、Core Animation
、Core Image
是 iOS 平台封装的绘制相关的上层 API,在 Android 平台则是其他的 API,这里不必深究。
在日常开发中,开发者一般通过使用上层 API 来构建和绘制界面,而调用 API 时系统最终还是通过 OpenGL/Metal/Vulkan 来实现视图的渲染。开发者也可以直接使用 OpenGL/Metal/Vulkan 来驱动 GPU 芯⽚⾼效渲染图形图像以满足一些特殊的需求。
知道了 OpenGL 在整个应用程序中的定位和角色后,那它在内部是怎么实现串联上下游的呢?这就涉及到其渲染架构的设计了。
OpenGL 的渲染架构是 Client/Server 模式:Client(客户端)指的是我们在 CPU 上运行的一些代码,比如我们会编写 OC/C++/Java 代码调用 OpenGL 的一些 API;而 Server(服务端)则对应的是图形渲染管线,会调用 GPU 芯片。我们开发的过程就是不断用 Client 通过 OpenGL 提供的通道去向 Server 端传输渲染指令,来间接的操作 GPU 芯片。
OpenGL 渲染架构
渲染架构的 Client 和 Server 是怎么通信和交互的呢?这又涉及到 C/S 通道的设计,下面我们来接着介绍,不过这里会提到一些你可能不太熟悉的名词,可以先不用深究,有个印象就可以了。
OpenGL 提供了 3 个通道来让我们从 Client 向 Server 中的顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)传递参数和渲染信息,如下图所示:
OpenGL 渲染架构及数据交互通道
这 3 个通道分别是:
需要注意的是,这 3 个通道中 Uniform 通道和 Texture Data 通道都可以直接向顶点着色器和片元着色器传递参数,但是 Attribute 只能向顶点着色器传递参数,因为 OpenGL 架构在最初设计的时候,Attribute 属性通道就是顶点着色器的专用通道。片元着色器中是不可能有 Attribute 的,但是我们可以使用 GLSL 代码,通过顶点着色器把 Attribute 信息间接传递到片元着色器中。
另外,虽然 Texture Data 通道能直接向顶点着色器传递纹理数据,但是向顶点着色器传递纹理数据本身是没有实质作用的,因为顶点着色器并不处理太多关于纹理的计算,纹理更多是在片元着色器中进行计算。
参考:了解 OpenGL 渲染架构[1]
在 Client/Server 的渲染架构下,OpenGl 的渲染流程其实是基于一个状态机来工作的。
我们先举个例子说明什么是状态机。我们都坐过电梯,一般来说电梯有这样几个状态:开门
、关门
、运行(上升/下降)
、静止
。
它们有什么特点呢?
电梯只有静止的时候才能开门,只有开门之后才能关门,只有关门之后才可以运动,只有运动之后才可以静止,所以,可以说电梯的各个状态是有依赖关系的,换种更专业的说法,就是各种状态可以通过有向图来表示。
电梯状态图
电梯不能随意从一个状态跳转到另一个状态,比如:不能在运动过程中开门。
关于 OpenGL 状态机,Learn OpenGL[2] 中有概述:
OpenGL 自身是一个巨大的状态机(State Machine):一系列的变量描述 OpenGL 此刻应当如何运行。OpenGL 的状态通常被称为 OpenGL 上下文(Context)。我们通常使用如下途径去更改 OpenGL 状态:设置选项,操作缓冲。最后,我们使用当前 OpenGL 上下文来渲染。 假设当我们想告诉 OpenGL 去画线段而不是三角形的时候,我们通过改变一些上下文变量来改变 OpenGL 状态,从而告诉 OpenGL 如何去绘图。一旦我们改变了 OpenGL 的状态为线段绘制模式,下一个绘制命令就会画出线段而不是三角形。 当使用 OpenGL 的时候,我们会遇到一些状态设置函数(State-changing Function),这类函数将会改变上下文。以及状态使用函数(State-using Function),这类函数会根据当前 OpenGL 的状态执行一些操作。只要你记住 OpenGL 本质上是个大状态机,就能更容易理解它的大部分特性。
基于上面的理解,我们来看一段 OpenGL 的代码:
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
初看这段代码,我们最深的印象可能是各种 glBind...
,字面上是绑定的意思,如果从状态机的角度理解,其实 glBind...
就意味着进入了某个状态。
所以我们可以用状态图来表示上面的代码如下:
示例代码状态图
不过 OpenGL 的状态是可以嵌套的,所以细看上面的代码,我们还能看到这里状态存在包含关系,因为一个 VBO 会被绑定于一个 VAO 中,所以用下图来看会更加直观:
状态嵌套示例
通俗来说就是,执行了绑定 X 到解绑 X 之间的任何操作,都会影响到 X。
明白了上面的状态机机制后,相信后面学习 OpenGL 的代码就能降低不少难度了。
参考:OpenGL 工作机制[3]
一个一个状态的切换以及在不同状态中的渲染逻辑和数据处理构成了 OpenGL 的渲染管线。
什么是管线?其实也可理解为一个流程。理解图像渲染管线前,我们可以想象一下如果让你在屏幕上绘制一个三角形,你要怎么做呢?
第一步,可能是先确定三角形三个顶点的位置:
三角形绘制流程 1
第二步,自然是将三个点用线段连起来:
三角形绘制流程 2
第三步,你可能觉得这样的三角形太过于单调,于是准备给三角形上色,因为是在屏幕上的,而屏幕本质用是一个个像素来显示颜色的,所以上色之前要先确定好哪些像素是属于三角形的,于是你叫计算机把属于三角形内部的像素一个个圈出来:
三角形绘制流程 3
第四步,你想画一个带渐变色的炫酷三角形,所以需要给每个像素都上不同的颜色,于是你给一个个像素精心上色:
三角形绘制流程 4
这样下来,一个漂亮的三角形就画出来了。回想这个过程,其实就像工厂的流水线一样,将整个工作拆解成一步一步实现即可。
OpenGL 的渲染管线其实也是类似的一个过程,它的工序包括:顶点着色器 → 图元装配 → 几何着色器 → 光栅化 → 片段着色器 → 测试与混合。
OpenGL 渲染管线
这些工序是将输入的 3D 的坐标,转化为显示在屏幕上的 2D 的像素的一个处理流程。
早期的 OpenGL 使用立即渲染模式(Immediate Mode,也就是固定渲染管线)。这种模式下绘制图形很方便,OpenGL 的大多数功能都被库隐藏起来,是一种配置化(Configurable)的管线,开发者很少有控制 OpenGL 如何进行计算的自由。而随着需求场景变的多样和复杂,开发者迫切希望能有更多的灵活性。随着时间推移,规范越来越灵活,开发者对绘图细节有了更多的掌控,现代 OpenGL 转变为可编程(Programmable)渲染管线,而这里的编程语言就是 GLSL 语言
,它是一种类 C 的语言,专为图形计算量身定制,包含了一些针对向量和矩阵操作的有用特性,我们用它编写我们自己的顶点着色器和片段着色器。
上面的介绍中我们多次提到了一个词:着色器(Shader),它是什么呢?
着色器就是一段运行在 GPU 中的程序,这段程序由开发者编写,所以说为开发者提供了很大的灵活度和可掌控度。现在 OpenGL 主要有三种着色器:顶点着色器、几何着色器、片段着色器,其中顶点着色器和片段着色器为开发者必须提供,几何着色器为可选提供。
下面我们介绍一下 OpenGL 渲染管线的几个重要工序:
1)顶点着色器(Vertex Shader)
顶点着色器主要用于确定绘制图形的形状,以及接收开发者传入的数据并传给后面阶段。接收外部传入的顶点数据,根据需要对顶点数据进行变换处理之后,再将顶点数据传入下一个阶段图元装配。另外顶点着色器也接收外部传进来的颜色值以及纹理采样器,然后再传递给下一个阶段进行图元装配处理。
每个顶点着色器只接收处理一个顶点坐标,有多少个顶点就会执行多少次。
2)图元装配
图元装配阶段是接收顶点着色器的输出数据,将顶点着色器传来的顶点数据组装为图元。就如上面画三角形中所说的将三角形三个顶点连接起来,具体连接方式需要开发者指定。所谓图元,指的就是点、线、三角形等最基本的几何图形,再复杂的图形也离不开这些基本图形的组成。另外,图元装配阶段还会将超出屏幕的顶点坐标进行裁剪,裁剪之后,顶点坐标被转化为屏幕坐标,之后将图元数据传递给管线的下一个阶段进行光栅化(几何着色器为非必须阶段,这里就暂时不讲了)。
下图是 OpenGL 支持的图元类型:
OpenGL 图元类型
3)光栅化
拿到图元装配传递过来的图元数据,光栅化要做的就是将一个图元转化为一张二维的图片。而这张图片由若干个片段(fragment)组成(可以当做将这张图拆解为一个个类似屏幕上像素的小片段),片段可以近似看成像素,但是又略有不同,一个片段包含渲染该片段所需要的位置、颜色和深度的全部信息。光栅化完成之后,就把每个片段传给片段着色器。
4)片段着色器(Fragment Shader)
接下来的阶段是片段着色器,这是另外一个必须有的重要着色器,也是最后一个可以通过编程来控制屏幕是上显示颜色的阶段(后面的混合测试阶段还可以改变片段的颜色),在这个阶段主要是计算片段的颜色。这里每个片段着色器接收一个片段数据的输入,所以有几个片段就会执行所少次,根据具体需要灵活设置该片段的颜色。然后片段数据就被传递到下一个阶段:测试与混合。
5)测试和混合
这个阶段的测试是专门用来丢弃一些不需要显示的片段,其中测试主要包含深度测试和模板测试。
深度测试是在显示 3D 图形的时候,根据片段的深度来防止被阻挡的面渲染到其它面的前面。这里是 OpenGL 内部维护一个深度缓冲,保存这一帧中深度最小的片段的深度,然后对屏幕同一个位置的其他片段的深度再进行比较,深度比缓冲中大的片段则丢弃,直到找到深度最小的片段,就将其显示出来。
深度测试
上图中每个方格表示一个片段,片段上的数值表示当前片段的深度,R 则表示深度无限,加号表示 2 个图形叠加一起,则由下面部分的图可知,当 2 个图形叠加在一起的时候,同一个位置的片段总是显示深度较小的那一个。
模板缓冲区是用于控制屏幕需要显示的内容,屏幕大小决定了模板缓冲区大小;模板测试基于模板缓冲区,从而让我们完成想要的效果。模板测试类似于与运算:
模板测试
上图可以看出,模板就是每个片段位置有 0 也有 1,然后和缓冲中的图像数据对应片段进行类似与运算,也类似与拿一个遮罩罩住,只留下 1 的对应片段显示出来。
混合则是计算带有透明度的片段的最终颜色,在这个阶段会与显示在它背后的片段的颜色按照透明度进行叠加行成新的颜色,通俗讲就是形成透明物体的效果。
混合
由图可以看出,通过混合,右边的窗户既有部分自己的颜色,又有窗户里面物体的部分颜色,就是两者透明度按照比例叠加的结果。
于是走完整个渲染管线流程,我们的渲染工作就算是告一段落了。
我们再来回顾一下这条渲染管线做了哪些事情:
首先我们传入了图形的顶点数据,然后 OpenGL 内部会按照指定的图元类型自动将顶点连成图形,然后再将图形内的区域切成一个个小片段,然后给每个小片段自由上色,最后把被挡住的或者我们不想显示的区域的下片段丢弃,并且对有透明度的片段进行前后片段颜色的混合。
参考:图形渲染管线的那些事[4]
到此,我们基本上就对 OpenGL 有个初步的认识了,至于更细节的知识则需要在实践中去学习和领悟了。
[1]
了解 OpenGL 渲染架构: https://www.jianshu.com/p/51be4551d36f
[2]
Learn OpenGL: https://learnopengl-cn.github.io/01%20Getting%20started/01%20OpenGL/
[3]
OpenGL 工作机制: https://juejin.cn/post/7121525553491869703
[4]
图形渲染管线的那些事: https://juejin.cn/post/7119135465302654984