OpenGL ES 2.0 笔记 #2:Hello Triangle

我感觉,知道和使用 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_posattribute 变量是 vertex shader 的输入,其值在应用程序中绑定。后详。这里 a_pos 是输入的顶点坐标。

main() 函数是 vertex shader 的入口。它直接将输入顶点坐标 a_pos 赋值给 gl_Positiongl_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 执行。运行效果如下:

代码在 gitlab.com/sihokk/lear...

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_SHADERGL_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 自动将坐标进行 normalize
  • stride - 相邻 2 个顶点之间的距离,单位为 byte。如果所有顶点紧密排列(无间隙),可指定为 0
  • pointer - 数据指针

设置好 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,正如某位女人大代表所言,要"很细!",因此,我非常非常期待后面继续深入和细化。

相关推荐
闲暇部落19 小时前
Android OpenGL ES详解——绘制圆角矩形
opengl·圆形·矩形·圆角矩形
凌云行者2 天前
OpenGL入门008——环境光在片段着色器中的应用
c++·cmake·opengl
闲暇部落6 天前
Android OpenGL ES详解——立方体贴图
opengl·天空盒·立方体贴图·环境映射·动态环境贴图
闲暇部落6 天前
Android OpenGL ES详解——实例化
android·opengl·实例化·实例化数组·小行星带
闲暇部落9 天前
Android OpenGL ES详解——几何着色器
opengl·法线·法向量·几何着色器
刘好念13 天前
[OpenGL]使用OpenGL实现硬阴影效果
c++·计算机图形学·opengl
闲暇部落14 天前
Android OpenGL ES详解——纹理:纹理过滤GL_NEAREST和GL_LINEAR的区别
opengl·texture·linear·纹理过滤·nearest·邻近过滤·线性过滤
凌云行者15 天前
OpenGL入门005——使用Shader类管理着色器
c++·cmake·opengl
凌云行者15 天前
OpenGL入门006——着色器在纹理混合中的应用
c++·cmake·opengl
凌云行者18 天前
OpenGL入门004——使用EBO绘制矩形
c++·cmake·opengl