我感觉,知道和使用 API 是简单的,关键的是理解背后的逻辑。
OpenGL 的渲染流水线如下:
第一阶段是 vertex shader,它对 3D 模型的每个顶点进行坐标转换,最终转换到 NDC(Normalized Device Coordinates)空间,又叫 clip space。NDC 空间是中心位于原点,边长为 2 的立方体,亦即,其 x/y/z 坐标区间为 [-1, 1],如下图所示。之所以叫做 clip space,是因为这个立方体之外的模型/顶点将被丢弃或裁切掉,只有位于它里面的会进入后续流程,最终显示到屏幕上:
具体而言,vertex shader 的坐标空间转换,经由 model, view, projection 三次转换,合称"MVP"。所谓坐标/空间转换,实质是对顶点坐标的矩阵乘运算。将顶点坐标记作 vector v
,转换矩阵依次记作 M1
, M2
, ...Mn
,那么转换所得坐标 v'
为
vbnet
v' = Mn ×...× M2 × M1 × v
线性代数的东西。具体的 MVP 转换过程暂且视为黑盒,现在需要知道的是,vertex shader 的输出,也就是 rasterizer 的输入,是 3D 模型的 NDC 坐标,其区间为 [-1, 1]。
另外一点需注意的是,vertex shader 工作在右手坐标系(RHS),但 rasterizer 工作在左手坐标系(LHS,见上图)。
Rasterizer 还要进行一次坐标变换,即 viewport 变换,将 NDC 坐标转换到 viewport 空间。上一篇提到过 viewport,说它是屏幕/窗口上的矩形显示区域。严格地说,viewport 是一个 3D 空间,除了 2D 显示区域,它还有一个维度是 depth(z 坐标方向)。如下图:
Rasterizer 是 GPU 硬件实现的,但是 OpenGL 提供接口设置/修改其参数,除了之前说过的 glViewport()
外,还可以设置其 depth 范围,其默认值为 [0, 1]。
Viewport 变换就是将 NDC 空间进行缩放和平移操作,转换为 3D viewport 空间。最后,rasterizer 对 viewport 空间内的顶点向 xy 平面正交投影,得到平面 2D 像素坐标。几何体除顶点之外的其他像素,则经过插值(Interpolation)运算得到。
每个像素(以及它的状态数据)称为 fragment,输入给流水线下一个阶段 fragment shader 进行处理。简化地说,fragment shader 的工作是确定每个像素的颜色。
OpenGL ES 2.0 的 vertex shader 和 fragment shader 必须编程实现,这区别于 OpenGL ES 1.x 的硬件实现。所以,OpenGL ES 2.0 称为可编程渲染流水线,而 ES 1.x 则为固定流水线。
Vertex shader 和 fragment shader 编程语言叫做 GLSL(OpenGL Shader Language),语法类似于 C++。总的来说,GLSL 是一门简单的语言。下面通过例程来初步认识 GLSL 和 vertex/fragment shader 的写法。
这次例程的目标是绘制一个三角形。上面说过,原本 vertex shader 是要对每一个顶点坐标进行矩阵运算,将其变换为 NDC([-1, 1])。最简单的实现方式是,三角形的顶点坐标,作为 vertex shader 的输入已经位于 NDC 空间内,vertex shader 不需经过任何运算,原样输出给 rasterizer 即可!如下:
C++
#version 100
attribute vec4 a_pos;
void main()
{
gl_Position = a_pos;
}
第 1 行的#version
预处理符指定了 GLSL 语言版本号。对于 OpenGL ES 2.0,其 GLSL 版本为 100。
第 3 行用 attribute
修饰符声明一个 vec4
类型的变量 a_pos
。attribute
变量是 vertex shader 的输入,其值在应用程序中绑定。后详。这里 a_pos
是输入的顶点坐标。
main()
函数是 vertex shader 的入口。它直接将输入顶点坐标 a_pos
赋值给 gl_Position
。gl_Position
是 OpenGL ES 2.0 定义的一个内建变量,是 vertex shader 的输出,即顶点的 NDC 坐标值。后面会将输入坐标 a_pos
绑定到 NDC 空间内的值,因此 vertex shader 没有进行任何处理,直接将其输出。
关于 fragment shader,上面说过它的工作是为像素确定颜色。这里直接在 fragment shader 内"写死"一个固定的颜色:
C++
#version 100
void main()
{
gl_FragColor = vec4(1.0, .5, .2, 1.0);
}
gl_FragColor
是 OpenGL ES 定义的一个内建变量,表示 fragment shader 的输出颜色值。vec4()
构造函数的 4 个参数分别是 RGBA。
将 vertex shader 和 fragment shader 源码分别保存为文件,后面在程序中将进行读取、编译和链接的过程,最终加载到 GPU,被 GPU 执行。运行效果如下:
Shader 源码文件,我保存为 .c/.cpp 后缀名,这样可以利用 IDE 提供的(有限的)排版/语法高亮等辅助。
程序流程在上一篇的基础上,在进入事件循环之前增加了一个 render_init()
函数:
c
...
glViewport(0, 0, INIT_SCREEN_WIDTH, INIT_SCREEN_HEIGHT);
render_init();
// Event loop
while (1)
{
render();
SDL_GL_SwapWindow(win);
...
}
render_init()
将作为 OpenGL 相关的初始化,主要是加载 shader 代码和程序。这类操作只需执行一次,而不必在每次渲染时都执行。
Shader 对象
首先创建一个 shader 对象:
C
GLuint glCreateShader(GLenum type)
type
参数可选值有 GL_VERTEX_SHADER
和 GL_FRAGMENT_SHADER
,分别表示创建 vertex shader 和 fragment shader。返回值是一个 unsigned
整型,可视为 shader 对象的 ID 或句柄。
接下来设定 shader 源码(以字符串形式)并编译为二进制:
C
const char *src = ...
glShaderSource(shader_id, 1, &src, NULL);
glCompileShader(shader_id);
执行编译之后,需检查编译是否成功:
C
GLint result;
glGetShaderiv(shader_id, GL_COMPILE_STATUS, &result);
if (result)
{
...
}
else
{
...
}
如果编译失败,可以打印出 OpenGL log,以助分析问题:
C
GLint len;
glGetShaderiv(shader_id, GL_INFO_LOG_LENGTH, &len);
if (len > 0)
{
char *buf = (char *)malloc(len);
glGetShaderInfoLog(shader_id, len, NULL, buf);
printf("Compiling %s : %s\n", path, buf);
free(buf);
}
这里先调用 glGetShaderiv()
获取到 log 长度,然后分配足够空间,调用 glGetShaderInfoLog()
取得 log 内容。
Vertex shader 和 fragment shader 都是按照以上过程进行编译。二者均编译成功,则可将其链接到 OpenGL 程序(Program)对象。
Program 对象
基本过程是先创建 program 对象,然后将 vertex shader 和 fragment shader "附加"到 program,最后进行链接:
C
GLuint prog_id = glCreateProgram();
GLuint vertex_shader = ...
GLuint frag_shader = ...
glAttachShader(prog_id, vertex_shader);
glAttachShader(prog_id, frag_shader);
glLinkProgram(prog_id);
在链接 program 之后,vertex shader 和 fragment shader 都可以删除。删除 shader 对象使用函数 glDeleteShader()
:
C
void glDeleteShader(GLuint shader)
检查 program 链接结果。与 shader 类似:
C
GLint result;
glGetProgramiv(prog_id, GL_LINK_STATUS, &result);
if (result)
{
..
}
else
{
...
}
若链接失败,则可打印出 OpenGL log,帮助分析失败原因。过程和 shader 类似,先获取 log 长度,再分配内存空间、获取 log 内容:
C
GLint len;
glGetProgramiv(prog_id, GL_INFO_LOG_LENGTH, &len);
if (len > 0)
{
char *buf = (char *)malloc(len);
glGetProgramInfoLog(prog_id, len, NULL, buf);
printf("Linking program : %s\n", buf);
free(buf);
}
Vertex Attribute
上面看到 vertex shader 的输入顶点坐标是一个 attribute
变量,变量名是 a_pos
:
C++
attribute vec4 a_pos;
OpenGL 状态机中包含一项称为 vertex attribute 数组。Vertex attribute 数组长度是固定的,但是每个 OpenGL 实现不尽相同,可以通过 API 查询。OpenGL ES 2.0 规定 vertex attribute 数组最小长度为 8。
Vertex attribute 数组的每一个元素当然就叫做 vertex attribute,用其数组索引来指代。每个 vertex attribute 都有各自的启用/禁用状态。
Vertex attribute 用来向 OpenGL 提供顶点相关的属性,包括顶点坐标。
在链接 program 的过程中,OpenGL 将 vertex shader 中声明为 attribute
的变量分别"绑定"到某一个 vertex attribute。在应用程序中,向对应的 vertex attribute 提供数据,这便是 vertex shader 的输入数据。
理解以上关于 vertex attribute 的机制后,看代码:
C
const float vertices[] = {
-.5f, -.5f,
.5f, -.5f,
0, .5f,
};
GLuint prog = ...
GLint attr_index = glGetAttribLocation(prog, "a_pos");
glEnableVertexAttribArray(attr_index);
glVertexAttribPointer(attr_index, 2, GL_FLOAT, GL_FALSE, 0, vertices);
三角形的 3 个顶点的坐标放在一个数组中,这里每个顶点提供了 x/y 坐标。OpenGL 会自动将 z 坐标给定为缺省值 0。首先调用 glGetAttribLocation()
获取到 vertex shader 中 a_pos
变量绑定的 vertex attribute(的索引),然后启用该 vertex attribute。最后调用 glVertexAttribPointer()
函数,将顶点坐标作为数据复制给 vertex attribute。
glVertexAttribPointer()
原型为
C
void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void *pointer)
它的参数需要详细说明一下,
index
- vertex attribute(的索引)size
- 每个顶点的属性数据个数,有效值为 1-4。这里我们每个顶点 2 个数据,分别为 x/y 坐标type
- 属性数据的类型normalized
- 这个在数据类型(type
)为整型时有效,是否需要 OpenGL 自动将坐标进行 normalizestride
- 相邻 2 个顶点之间的距离,单位为 byte。如果所有顶点紧密排列(无间隙),可指定为 0pointer
- 数据指针
设置好 vertex attribute 后,就可进行渲染了:
C
glUseProgram(prog);
glDrawArrays(GL_TRIANGLES, 0, 3);
Program 须通过 glUseProgram()
得以被执行。glDrawArrays()
函数各型为
C
void glDrawArrays(GLenum mode, GLint first, GLsizei count)
其参数列表为
mode
- 绘制的几何体类型。GL_TRIANGLES
表示三角形,OpenGL 将从 vertex attribute 中依次取 3 个顶点构成一个三角形first
- 第一个顶点的索引,即:从哪一个顶点开始count
- 顶点个数
Hello Triangle 例程虽然代码量不大,但是覆盖了完整的 OpenGL 渲染流水线,对于作为初学小白的我来说,理清各个部分的关联和逻辑还是颇费了一番功夫的。目前对 OpenGL 的理解还极其粗略,例如,渲染流水线每个阶段基本上都还是一个黑盒。想完全学透 OpenGL,正如某位女人大代表所言,要"很细!",因此,我非常非常期待后面继续深入和细化。