我们的第一个 OpenGL 程序
从这篇文章开始,就开启了自己的 OpenGL 学习计划了。
将本章的样例全部做过一遍之后,感觉还是不太能跟上 OpenGL 的编程“理念”:初步感受下来觉得 OpenGL 很注重上下文,但是调用的函数中就没有特别强调这一点,上下文相关的内容一旦设置过了,就会一直延续下去。同时着色器的概念也是第一次接触,相比 GDI 画个点和三角形的方式,OpenGL 的方式很颠覆自己的习惯。
不管怎么样,还是先记录下自己的学习过程。相信之后会更加了解 OpenGL。
书上的应用框架
书上的应用框架采用典型的“模板方法”设计模式。像 Windows 窗体的创建,目前阶段压根不是我们需要关注的地方,相关内容也非常繁琐,又需要看另一整本书才能了解。本书框架就把我们需要关注的部分提供为虚函数,其余不需要关注的地方都封装起来了。后续我们填充相应的虚函数即可。
如清单 2.2 所示,继承应用框架(sb7::application),然后重写 render 函数。DECLARE_MAIN 宏定义了 WinMain 函数(Windows 平台),并在其中使用到了我们自己定义的类(my_application)。
- #include "sb7.h"
- class my_application : public sb7::application
- {
- public:
- void render(double currentTime)
- {
- const GLfloat color[] = { sin(currentTime) * 0.5f + 0.5f,
- cos(currentTime) * 0.5f + 0.5f,
- 0.0f, 1.0f };
- glClearBufferfv(GL_COLOR, 0, color);
- }
- };
- DECLARE_MAIN(my_application);
glClearBufferfv 是接触到的第一个 OpenGL 相关函数,f 是 float 的缩写,v 是 vector 的缩写。第一个参数较易理解,指定要清除什么缓存,本例为 GL_COLOR,看名字应该是和颜色相关的缓存;第二个参数不太容易理解,有多个缓存的情况下,指定缓存的索引;第三个参数容易理解,就是颜色的 rgba 值。
待验证:glClearBufferfv 应该也是有绑定的上下文的,可能“埋没”在框架代码中了。
使用着色器
OpenGL 通过以固定函数作为“胶水”连接多个叫做着色器的小程序来工作。在这里的描述,对着色器的修饰是程序,后面也可以看到它通过 OpenGL 着色语言(GLSL)编写,并且也要编译链接。
最基本的管线配置只有一个顶点着色器(或者是一个运算着色器),但如果想在显示屏上看到图形,还需要一个片段着色器。本章也只实现最基本的顶点着色器和片段着色器。
清单 2.3 是一个顶点着色器,可以看到着色器语法和 C 语言类似。所有以 gl_ 开始的变量都是 OpenGL 的一部分,并使着色器相互连接或将其连接至 OpenGL 中固定函数的不同部分。顶点着色器中,gl_Position 表达顶点的输出位置。这些 gl_ 开始的变量有点 “全局共享” 的感觉。当前例子输出的顶点在窗口正中间。
- #version 450 core
- void main(void)
- {
- gl_Position = vec4(0.0, 0.0, 0.5, 1.0);
- }
坐标空间的定义书上此章没有介绍。不过照着例子核验,坐标系原点应该是在正中间,各轴范围都为 [-1,1]。
还有一个疑惑是为什么坐标要用 vec4 表示,多出的一个参数用作何用。
清单 2.4 是一个片段着色器。其中 out 关键字指示 color 是一个输出变量。这边我的理解暂时是:每个着色器就像是一个节点,有输入输出。顶点着色器中,gl_Position 已经是“全局”的了,就可以当成是输出;片段着色器中要显式指明什么是输出,像此例输出青色。
- #version 450 core
- out vec4 color;
- void main(void)
- {
- color = vec4(0.0, 0.8, 1.0, 1.0);
- }
清单 2.5 是编译着色器的封装。里面所调用的函数看名字是比较好理解的:
glCreateShader() 用于创建一个空的着色器对象。
glShaderSource() 用于向着色器对象传递源码。
glCompileShader() 用于编译着色器对象内部的源码。
glCreateProgram() 用于创建一个程序对象。
glAttachShader() 用于将一个着色器对象附加到一个程序对象。
glLinkProgram() 用于将所有附加到程序对象的着色器对象链接在一起。
glDeleteShader() 用于删除一个着色器对象。
本人将这些操作跟平常的编译过程进行对比理解:比如需要提供多份 cpp 文件(glShaderSource)进行编译(glCompileShader),多个产生的 obj 文件链接成一个可执行文件(glLinkProgram)。二进制可执行文件已经产生,源码就不占用额外的地方了(glDeleteShader)。
大部分函数的参数都是容易理解的,除了一开始接触的 glShaderSource() 函数,它的参数着实让我困扰了一番。它的第一个参数容易理解,就是创建的着色器对象标识;重点是第三个参数,它是一个字符串指针数组,代码 38 和 43 这种形式更容易让人理解。原书中字符串连接的写法,让我乍一看以为必须要一行一行输入源码😂;第二个参数指明数组的长度;第四个参数指明数组中每个字符串的长度,如果为 NULL,则表明每个字符串都是以 '\0' 结尾的。至于什么场景需要把源码拆成多个字符,目前不得而知。所以这边为了方便,第二个参数都设置为 1。
这边自己写了一个读着色器源码文件的函数——read_shader_text()。这边需要注意的是,按照文件大小开辟空间读取的话,就直接按二进制文件原原本本的读取,否则 '\r\n' 会被处理为 '\n'。如果分配的这段内存没有清零,实际使用的空间会减少,末尾会大概率附带无效的 ascii 码,进而导致着色器编译失败。
以上编译失败的点也让我苦找了一番。查看编译是否失败可以使用 glGetShaderiv() 函数(第 56 行);查看具体的失败原因可以使用 glGetShaderInfoLog() 函数(第 52 行)。
compile_shaders 函数最后返回的是程序对象标识。
- #include "sb7.h"
- #include <stdint.h>
- #include <string>
- #include <memory>
- #include <fstream>
- using TGLcharPtr = std::unique_ptr<const GLchar[]>;
- TGLcharPtr read_shader_text(std::string name)
- {
- GLchar* p = NULL;
- std::string path = name.insert(0, "GLSL/");
- std::ifstream f(path, std::ios::binary);
- if (f.is_open())
- {
- f.seekg(0, std::ios::end);
- uint64_t size = f.tellg();
- f.seekg(0, std::ios::beg);
- p = new GLchar[size + 1];
- f.read(p, size);
- p[size] = 0;
- f.close();
- }
- return TGLcharPtr(p);
- }
- GLuint compile_shaders(void)
- {
- GLuint vertex_shader;
- GLuint fragment_shader;
- GLuint program;
- // Source code for vertex shader
- TGLcharPtr vertex = read_shader_text("2_3_VertexShader.vert");
- const GLchar* vertex_shader_source[] =
- { vertex.get() };
- // Source code for fragment shader
- TGLcharPtr fragment = read_shader_text("2_4_FragmentShader.frag");
- const GLchar* fragment_shader_source[] =
- { fragment.get() };
- // Create and compile vertex shader
- vertex_shader = glCreateShader(GL_VERTEX_SHADER);
- glShaderSource(vertex_shader, 1, vertex_shader_source, NULL);
- glCompileShader(vertex_shader);
- /*static GLchar log[10240];
- glGetShaderInfoLog(vertex_shader, sizeof(log), NULL, log);
- printf("%s\n", log);
- GLint result = GL_FALSE;
- glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &result);*/
- // Create and compile fragment shader
- fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
- glShaderSource(fragment_shader, 1, fragment_shader_source, NULL);
- glCompileShader(fragment_shader);
- // Create program, attach shaders to it, and link it
- program = glCreateProgram();
- glAttachShader(program, vertex_shader);
- glAttachShader(program, fragment_shader);
- glLinkProgram(program);
- // Delete the shader as the program has them now
- glDeleteShader(vertex_shader);
- glDeleteShader(fragment_shader);
- return program;
- }
清单 2.6 和 2.7 实现了渲染一个点。成员函数 startup 和 shutdown 看名字容易猜测,是窗体生命周期的前后。我们看看有什么需要初始化和清理的。
绘图前需要做的最后一件事是创建顶点数组对象(vertex array object,VAO),表示 OpenGL 管线顶点获取阶段的对象,用于向顶点着色器提供输入。由于现在顶点着色器没有任何输入,因此我们不需要做太多工作。但是我们需要创建 VAO,这样才能使用 OpenGL 绘图。我们使用 glCreateVertexArrays 创建 VAO,使用 glBindVertexArray 将其绑定到我们的上下文。
窗体销毁时,我们需要清理一些之前申请的 OpenGL 资源,和之前说的 glDeleteShader 一样。glDeleteVertexArrays 删除创建的 VAO,glDeleteProgram 删除程序对象。
不知道为什么 glDeleteVertexArrays 需要调用两次。
- #include "sb7.h"
- GLuint compile_shaders(void);
- class my_application : public sb7::application
- {
- public:
- // <snip>
- void startup()
- {
- rendering_program = compile_shaders();
- glCreateVertexArrays(1, &vertex_array_object);
- glBindVertexArray(vertex_array_object);
- }
- void shutdown()
- {
- glDeleteVertexArrays(1, &vertex_array_object);
- glDeleteProgram(rendering_program);
- glDeleteVertexArrays(1, &vertex_array_object);
- }
- void render(double currentTime)
- {
- const GLfloat color[] = { sin(currentTime) * 0.5f + 0.5f,
- cos(currentTime) * 0.5f + 0.5f,
- 0.0f, 1.0f };
- glClearBufferfv(GL_COLOR, 0, color);
- // Use the program object we created earlier for rendering
- glUseProgram(rendering_program);
- // Draw one point
- glPointSize(40.0f);
- glDrawArrays(GL_POINTS, 0, 1);
- }
- private:
- GLuint rendering_program;
- GLuint vertex_array_object;
- };
- DECLARE_MAIN(my_application);
接着我们看到 render 函数。glUseProgram 选中我们之前创建的程序对象。这边自己的理解为:就像桌面上有许多程序一样,我们需要选中并双击打开一个程序,然后再向这个程序进行一些交互操作(glDrawArrays)。即 glDrawArrays 的上下文就是刚才选中的程序,在清单 2.5 中我们知道这个程序里有顶点着色器和片段着色器。
glDrawArrays 的第一个参数比较容易理解,其指定需要渲染什么图元(比如点、线段、三角形等)。第二个参数和第三个参数比较费解,结合后续的绘制三角形实验我是这样理解的:可以当成传递给顶点着色器的输入变量,初始值由第二个参数指定,传递(绘制)第三个参数指定的次数,且每次传递的值会递增。
glPointSize 指定点的大小,我们这边把点放大一点便于观察。如图 1,注意核对,这个点在屏幕正中间(顶点着色器指定),颜色是青色(片段着色器指定)。

绘制我们的第一个三角形
绘制三角形需要三个点,所以我们需要修改一下着色器。如清单 2.8 所示,其中硬编码了三个点的位置,关键是如何使用到它们。
GLSL 的顶点着色器包含一个名为 gl_VertexID 的特殊输入,它是此时正在被处理的顶点的索引。gl_VertexID 输入从 glDrawArrays() 的 first 参数给定的值开始计算,并且每次向上计算一个顶点直到 count 个顶点(glDrawArrays()的第三个参数)。以上这段话的逻辑前面也用自己的理解讲过一遍,要注意 gl_VertexID 是一个输入变量。
- #version 450 core
- void main(void)
- {
- // Declare a hard-coded array of positions
- const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0),
- vec4(-0.25, -0.25, 0.5, 1.0),
- vec4(0.25, 0.25, 0.5, 1.0));
- // Index into our array using gl_VertexID
- gl_Position = vertices[gl_VertexID];
- }
清单 2.9 中,glDrawArrays 的第一个参数变为三角形图元,第一个下标索引从 0 开始,需要 3 个顶点。最终画出的三角形如图 2 所示。
- void render(double currentTime)
- {
- const GLfloat color[] = { sin(currentTime) * 0.5f + 0.5f,
- cos(currentTime) * 0.5f + 0.5f,
- 0.0f, 1.0f };
- glClearBufferfv(GL_COLOR, 0, color);
- // Use the program object we created earlier for rendering
- glUseProgram(rendering_program);
- // Draw one triangle
- glDrawArrays(GL_TRIANGLES, 0, 3);
- }

从图 3 和图 4 中也能理解 gl_VertexID 的逻辑。图 3 的绘制语句是 glDrawArrays(GL_POINTS, 0, 3),因此绘制了 3 个点。图 4 的绘制语句是 glDrawArrays(GL_POINTS, 1, 2),因此绘制了从索引 1 开始的两个点(注意不要溢出)。

