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_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 执行。运行效果如下:

代码在 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_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 自动将坐标进行 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,正如某位女人大代表所言,要"很细!",因此,我非常非常期待后面继续深入和细化。

相关推荐
扶尔魔ocy3 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行并进行态势标绘(四)
qt·opengl·osg
:mnong4 天前
Web 三维可视化技术对比:ParaView Web vs 本项目 wasm + Three.js
前端·webgl·opengl·opencl·科学可视化
扶尔魔ocy5 天前
【qt openGL入门笔记】OSG实现显示飞机模型飞行的点迹(二)
qt·opengl·osg
扶尔魔ocy5 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行(三)
opengl·osg
扶尔魔ocy6 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行
qt·opengl·osg
扶尔魔ocy10 天前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy10 天前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
扶尔魔ocy12 天前
【qt openGL入门笔记】基础光照
qt·opengl
扶尔魔ocy12 天前
【qt openGL入门笔记】坐标系统和摄像机
qt·opengl
茉莉玫瑰花茶15 天前
OpenGL [ 纹理 ]
开发语言·c++·opengl