OpenGL 简介
GPU 接口规范
对于刚接触 OpenGL
的初学者,常常会有这样一个疑问: OpenGL
的源码在哪里,如何编译?
然而实际上 OpenGL
并不是一个软件实现,更多的是一个标准协议; OpenGL
更像是一种显卡驱动标准,由各个硬件厂家适配,各个硬件厂商根据 OpenGL
接口规范编撰对应的驱动.
换句话说,对于各个硬件厂商
OpenGL
确实是一个基于GPU
的软件实现,但是对于普通的应用层开发者OpenGL
就是一个由硬件厂商提供的驱动程序罢了,也就是为什么你找不到OpenGL
源代码实现的原因了.
OpenGL
是操作 GPU
的其中一种方法,但绝不是唯一的途径; OpenGL
由 Khronos
进行管理,在 Khronos
的官网下,你可以找到下面这张图:
在这里,你可能看到一些熟悉的名称,比如 EGL
、glTF
、OpenCL
、OpenGL
以及 Vulkan
等等,这里定义了许多标准规范;这里我们仅仅谈及真正直接操作GPU
的标准协议,
也就是下图中 3D Graphics
的部分:
然后呢,这就是全部了吗? 实际并不是, Khronos
更多像是一个联盟,有着许多成员如 Apple
、Intel
、AMD
、Google
、ARM
、Qualcomm
、Nvidia
等等成员.
所以,也就会出现"分道扬镳"的现象,比如:
微软下的 Direct X
(D3D9、D3D11、D3D12),对标 OpenGL
:
苹果下的 Metal
, 还是对标 OpenGL
:
以及英伟达大名鼎鼎的 CUDA
, 对标 OpenCL
:
可以看到关于一个 GPU 接口规范的指定,出现了很多不同的角色比如硬件产商、操作系统提供者等等;为什么会出现这么多种规范呢?其中一部分原因来自于某一种标准实现并不适用于某些场景,但还有其他一部分原因就是跟为什么有这么多中编程语言类似的原因.
GPU 接口分类
GPU
(Graphic Processing Unit)按我的理解可以分为两大类 GL
(Graphic Language) 和 CL
(Compute Language):
本文讨论的重点在 OpenGL
, 故实际上处于 GPU
的 GL
分支.
题外话
实际上很多人会发现
VULKAN
和OpenGL
非常的相似,虽然VULKAN
一方面是为了解决OpenGL
设计之初留下的问题,但VULKAN
个人认为另一个积极的意义是在于给予行业一个新的标准化机会;例如VULKAN
的协议制定苹果就挺上心的 (毕竟"分道扬镳"对于开发者而言意味着成倍的开发成本,对于厂商则很难形成一个开发者愿意投入的生态).
OpenGL 设计结构
如果用一句话来描述 OpenGL
的话,我想应该是基于C/S结构设计的模板模式(设计模式里的那个);在 图形渲染管线 这节,主要介绍的是其模板设计,在 OpenGL
里我们称之为 PipeLine
;在 C/S结构 这节,则介绍 OpenGL
C/S 结构给 OpenGL
带来的一些对于初学者看起来可能觉得奇奇怪怪的东西.
图形渲染管线
下图是出自于 es_spec_3.2.pdf
中 Dataflow Model
:
这张图描述的东西挺多,但对于初学 OpenGL
只需要关心红色标注部分即可.其他部分例如 Framebuffer
的一部分 (Default Framebuffer) 在本章中由 EGL
负责; 而 Compute Shader
则是 OpenGL
提供的 CL
功能,目前讲述的是 GL
的部分,故省略.
上图就是 OpenGL
渲染管线整体的数据流图,仔细观察图中的左下方可以发现, OpenGL
按照颜色对不同的部分进行了区分,例如 Fixed Function Stage
以及 Programmable Stage
等等; 对于大部分的开发者而言需要关注 Fixed Function Stage
中的 Vertex Shader
以及 Fragement Shader
,以及理解 Fixed Function Stage
中的 Rasterization
、Per-Fragement Operations
以及 Tessellation Primitive Gen
等步骤.某些 Fixed Function Stage
对于开发者而言属于不可控制的节点,但是只有充分理解其行为才能在其他可编程部分写出自己想要的程序.
题外话: 为什么要引入 Pipeline 的概念呢?
Each pipeline is controlled by a monolithic object created from a description of all of the shader
stages and any relevant fixed-function stages. Linking the whole pipeline together allows the
optimization of shaders based on their input/outputs and eliminates expensive draw time state
validation. (from vulkan spec)
C/S结构
谈起 OpenGL
C/S 结构,就不得不提 OpenGL
对于开发者的一个强约束限制,就是: 只能在 OpenGL
所在的 context
线程里操作 OpenGL
的接口.
语言上可能很难说清楚这个 C/S 到底是什么结构,但是图例可能会相对清楚,例如使用 EGL
来创建 OpenGL context
时,在 eglInitialize
调用前后,可以看到这样有意思的现象:
在前后两个接口调用前,程序的堆栈发生了明显的变化,这些多出来的线程主要就是用来辅助 OpenGL Server
运行的;实际上直到调用EGL
接口eglMakeCurrent
前,都是不能调用 OpenGL
接口的.其实也很好理解, Server
都没运行起来, Client
怎么能请求呢?
实际上,很多人说创建 OpenGL context
是为了给 OpenGL
提供一个渲染使用的画布;在我看来这种说法固然没有什么错误,不过我觉得更大一部分的贡献是在于运行起 OpenGL Server
. 创建 OpenGL context
有很多种方式,例如使用 EGL
、SDL
、GLFW
、QT
等等非常多种;为什么存在这种多种创建方式的主要原因在于: OpenGL
希望自己与硬件无关,而创建 OpenGL context
往往需要涉及到具体的硬件信息,比如这个 context
是运行在哪一个 Display
上 (OpenGL
标准里有针对于此详细的信息,有兴趣可以自己翻阅下).
在嵌入式上, EGL
、OpenGL ES
以及 GLSL ES
往往是一起使用的三剑客; 在这里实际上已经简单涉及到了 EGL
了,受限于篇幅本章就不再展开 EGL
相关的细节.
由于
OpenGL
只是一个规范而非实现,所以上面的现象在不同硬件平台,不同版本里面的现象可能是不同的;OpenGL
标准里也有明确写明,这要能保证接口调用的结果是符合预期的,OpenGL
不关注具体实现.
那这种 C/S 结构对于我们使用 OpenGL
有什么影响呢? 最大的影响就是在于我们只能在 OpenGL context
所在的线程调用 OpenGL
的接口,详细阅读 EGL
标准你会知道所有 OpenGL
接口都存在一个阴式的入参,就是 OpenGL Context
; 这种接口设计广泛存在于 OpenGL
的各种接口,在深入学习 OpenGL
之后应该会更加有感触,这里由于篇幅也不再展开了.
渲染管线
在这节里,会对 图形渲染管线 小结中部分出现的环节进行描述,旨在理解一个完整 OpenGL
渲染管线的运行流程以及其逻辑.
背景补充
在真正描述渲染管线前,需要知道以下几个常识:
- 两点确定一条直线
- 三点确定一个三角形
- n边形可以由 (n-2) 个三角形组成
在 OpenGL
中,所有图形均有点(point)、线(Line Segment)、三角形(Triangle)组成;换句话说对于 OpenGL
而言最基础的元素是点,在 OpenGL
里称之为顶点(Vertex),对应的可编程节点则是 Vertex Shader
.
而另外一个可编程节点则是 Fragment Shader
;在非常早期的时候 Fragment Shader
其实是叫做 Pixel Shader
; OpenGL
将 Pixel
替换为了 Fragment
的一个原因是在于 Pixel
强调的是一个点的概念而 Fragment
则是代表一个面的概念;在实际屏幕显示时,实际上对应的是一块块像素块,而像素块并不一定是正方形,所以后来修改为 Fragment
强调面的概念.对于初学者而言,可能 Pixel Shader
会更加容易理解.
GLSL
OpenGL
整个渲染管线中存在可编程节点, OpenGL
将它们统称为 Shader
. 值得注意的是,并不是所有可编程节点都是需要的;在大多数场景下,我们只需要处理 Vertex Shader
和 Fragment Shader
就足够了.
为了实现可编程的这个目标, OpenGL
需要搭配一门 GPU
编程语言使用,即 GLSL
(OpenGL Shading Language); 实际上对于大部分 GPU
开发者而言,最终的主要任务就是编写和优化 GLSL
, 高效的 GLSL
能够节约 GPU
的使用资源以及缩短 GPU
的处理时间.
在本章中由于篇幅也不打算展开 GLSL
的细节描述,在之后以案例的感性形式给出一个 GLSL
的使用.这里给出一个示例的 GLSL
写法,对应亮度擦除(转场)的实现:
# vertex
attribute vec3 Position;
attribute vec2 iUv;
varying vec2 oUV;
uniform mat4 WorldViewProj;
void main(void)
{
oUV = iUv;
gl_Position = WorldViewProj * vec4(Position, 1.0);
}
# fragment
varying vec2 oUV;
uniform sampler2D TexA;
uniform sampler2D TexB;
uniform sampler2D TexL;
uniform float Progress;
void main(void)
{
float softness = 0.03f;
float luma = texture(TexL, oUV).x;
float time = mix(0.0f, 1.0f + softness, Progress);
vec4 acolor = texture(TexA, oUV);
vec4 bcolor = texture(TexB, oUV);
if (luma <= time - softness)
{
gl_FragColor = bcolor;
}
else if (luma >= time)
{
gl_FragColor = acolor;
}
else
{
float alpha = (time - luma) / softness;
gl_FragColor = mix(acolor, bcolor, alpha);
}
}
在 GLSL
中,变量简单地将可以分为 attribute
、varying
和 uniform
三种. 由于历史的原因 attribute
也称之为 in
,而 varying
则称之为 output
.可以在 GLSL
标准中找到下列表述:
Not all language constructs present in earlier versions of the language are available in later
versions e.g. attribute and varying qualifiers are present in v1.00 but not v3.00. However, the
functionality of GLSL ES 3.20 is a super-set of GLSL ES 3.10.
可以看到目前 GLSL
更推崇使用 in
代替废弃的 attribute
,而使用 out
去代替废弃的 varying
;但是个人认为它们还是尤其意义的,尤其是对于初学者而言,例如 OpenGL
可只有 glBindAttribLocation
接口,可没有 glBindInputLocation
;为了做一下兼容处理,一般会在 GLSL
程序前写入一段宏定义:
#if __VERSION__ >= 130
#define attribute in
#define varying out
#endif
如果我们将 Shader
理解为一个函数,那么就存在入参和出参,分别对应于 attribute
和 varying
;这里可以抛出一个问题, attribute
入参是如何传递进去的? 要回答这个问题,需要回到 OpenGL Pipeline
的数据流图:
在整个 PipeLine
运行之初存在一个 Vertex Puller
的环节,Vetex Shader
中的 attribute
就是从这里传递进去的;那么 Fragment Shader
的 attribute
呢? 仔细观察上面 vertex
和 fragment
的代码以及结合 varying
这个单词的含义,就能找到答案.注意对你而言,只有 Vetex Shader
的入参是你真正自己传递进去的.
在 GLSL
中除了 attribute
和 varying
,还存在一个广泛使用的类型 uniform
, 一般我们可以将其理解为常量即可; uniform
可以分为两大类,详细可看下 OpenGL ES
标准中的 7.6 Uniform Variables
:
- Named uniform blocks
- Default uniform block
其中特别指出 Uniforms in the default uniform block are program object-specific state.
, 其实就是再说有些 uniform
类似于一个句柄,我们只能通过 GLSL
内置接口去操作,例如上面出现的 sampler2D
去操作;而另外的 uniform
而是开发者传递进去的.
其实这里我个人觉得将 uniform
描述为常量在初期是会造成一些理解上的偏差的. 例如 uniform
都是一个常量了,为什么我不能直接将其内嵌入代码即可? 这实际是在于 uniform
只是在一次 Pipeline
过程中才是常量;例如在上面的例子中,存在 uniform
变量 Progress
, 实际程序运行的过程中它以 60hz 的速率,从 0.0f 到 1.0f 以 1.0f/60 步进的速率发生着改变,不然图像怎么会出现变化呢?
然后不同 Shader
存在着不同的内置变量,例如 Vertex
存在内置变量 gl_Position
对应着一个顶点的坐标, 而 Fragment
则存在内置变量 gl_FragColor
对应着一个像素点信息.
OpenGL
的坐标分为 w、y、z、w, 即非笛卡尔坐标,当 w 为 1 则可以理解为笛卡尔坐标系; 此外,坐标的取值分为为 [-1.0f, 1.0f], (0.0, 0.0) 代表中心坐标,也是为什么你会经常看到一个类似于WorldViewProj
的uniform
变量, 世界坐标系投影.
这里又引出一个很有趣的问题,假设我们需要绘制一张 1080P 的图片; 那么需要两个三角形,对应6个坐标顶点, gl_Position
需要被赋值 6 次; 而 gl_FragColor
则需要被赋值 1080*1920 次. 这里可以停下来思考一个问题, vertex
中的 oUV
和 fragment
中的 oUV
数量是一致的吗? uniform
变量 Progress
到底存在几个?
回答得了这个问题,就打开了 GPU GL 并行处理的大门; 这个问题将会在 Rasterization
栅格化中解释.
栅格化
对于开发者而言,传入的只是几个顶点坐标,而实际上渲染时肯定针对的是一块像素区域;从 Vertex
到 Fragment
的过程实际上大体就是 Rasterization
栅格化.
在讲解 Rasterization
之前,给出一个案例的描述,可以思考一下为什么出来一张长这样子的图:
# vertex
attribute vec3 Position;
attribute vec2 iUv;
varying vec2 oUV;
uniform mat4 WorldViewProj;
void main(void)
{
oUV = iUv;
gl_Position = WorldViewProj * vec4(Position, 1.0);
}
# fragment
varying vec2 oUV;
uniform sampler2D TexA;
uniform sampler2D TexB;
void main(void)
{
float val = (oUV.x + oUV.y) / 2;
gl_FragColor = vec4(val, val, val, val);
}
并且在 Vertex Puller
中输入以下数据:
// Position(3 scalar) iUv(2 scalar)
// one rectangle
0.0 0.0 0.0 0.0 0.0
1920.0 0.0 0.0 1.0 0.0
1920.0 1080.0 0.0 1.0 1.0
// another rectangle
0.0 0.0 0.0 0.0 0.0
1920.0 1080.0 0.0 1.0 1.0
0.0 1080.0 0.0 0.0 1.0
图例如下:
实际你在屏幕输出可以看到下面这样的效果:
OpenGL
的基础元素有点(Point)、线(Line Segment)以及三角形(Triangle),其栅格化过程各有不同,在这里选取三角形(Triangle)进行表述(实际上大多数场景也只会用到Triangle).
此节所有描述均来自于
OpenGL ES
中的13.7 Polygons
.
对于上面演示的这个例子如果以 1080P 作为屏幕尺寸,那么右上三角形输入三个顶点(vertex)则应该输出 1080*1920/2 个像素点(fragment); 这个过程我们称之为 Rasterization
栅格化,对应于 OpenGL ES
标准中的 Fixed-Function Primitive Assembly and Rasterization
.
栅格化的本质是数据内插(interpolation),即如何使用三个顶点(vertex)内插出1080*1920/2 个像素点(fragment)?这里就要谈及内插公式,这里不想讲得过于复杂就附带上 OpenGL ES
中官方的描述,并做扼要的说明:
- p : barycentric coordinates, 三角形中任意一点
- abc : p 与三角形三个顶点相连接,可以分割出三个子三角形; abc就是子三角形和三角形的比例关系,故 a+b+c=1
- associated datunm : 相关数据,比如内置变量
gl_Position
,自定义输出变量varying
oUV
- w : clip coordinate; 被剪辑过的坐标,这里简单可以理解为没有剪辑,传入是什么就是什么
结合下面这张图以及输入的值,找几个坐标好好算一算,就可以理解这个内插公式了:
案例演示
素材源
TexA
TexB
TexL
Vertex
// Position(3 scalar) iUv(2 scalar)
// one rectangle
0.0 0.0 0.0 0.0 0.0
1920.0 0.0 0.0 1.0 0.0
1920.0 1080.0 0.0 1.0 1.0
// another rectangle
0.0 0.0 0.0 0.0 0.0
1920.0 1080.0 0.0 1.0 1.0
0.0 1080.0 0.0 0.0 1.0
渐变转场
FadeVal
从 0.0 至 1.0 均匀变化.
Fade vertex shader
attribute vec3 Position;
attribute vec2 iUv;
varying vec2 oUV;
uniform mat4 WorldViewProj;
void main(void)
{
oUV = iUv;
gl_Position = WorldViewProj * vec4(Position, 1.0);
}
Fade fragemet shader
varying vec2 oUV;
uniform sampler2D TexA;
uniform sampler2D TexB;
uniform float FadeVal;
void main(void)
{
gl_FragColor = mix(texture(TexA, oUV), texture(TexB, oUV), FadeVal);
}
渐变转场效果图
亮度擦除转场
演示百叶窗效果,本质原理为查表;Progress
从 0.0 至 1.0 均匀变化.
Luma vertex shader
attribute vec3 Position;
attribute vec2 iUv;
varying vec2 oUV;
uniform mat4 WorldViewProj;
void main(void)
{
oUV = iUv;
gl_Position = WorldViewProj * vec4(Position, 1.0);
}
Luma fragment shader
varying vec2 oUV;
uniform sampler2D TexA;
uniform sampler2D TexB;
uniform sampler2D TexL;
uniform float Progress;
void main(void)
{
float softness = 0.03f;
float luma = texture(TexL, oUV).x;
float time = mix(0.0f, 1.0f + softness, Progress);
vec4 acolor = texture(TexA, oUV);
vec4 bcolor = texture(TexB, oUV);
if (luma <= time - softness)
{
gl_FragColor = bcolor;
}
else if (luma >= time)
{
gl_FragColor = acolor;
}
else
{
float alpha = (time - luma) / softness;
gl_FragColor = mix(acolor, bcolor, alpha);
}
}
亮度擦除转场效果图
结语
在写这篇文章之前我想了很久,最终我决定不去描述 OpenGL
的各个细节,比如 OpenGL
的接口定义、如何调用、如何从 CPU 向 GPU 传输数据等等; 想以概括性的方式描述一下 OpenGL
即可;如果想深入了解 OpenGL
,可以读读 ES 2.0知识串讲,个人非常喜欢(所以本章的写法侧重点也与此不同,不想重复).
此外,本章所描述的大部分实现你都可以从 MMP 这个仓库中找到,详细可以阅读下 调试.
MMP 是业余时我自己开发的一个SDK,目前主要参考了几个开源程序,比如 ppsspp (很有意思的开源模拟器)以及 obs; 个人觉得都是相当优秀的开源项目,值得学习以及投入.(顺便帮我点下 star,让我有更新的动力)