我们的第一个 OpenGL 程序

从这篇文章开始,就开启了自己的 OpenGL 学习计划了。

将本章的样例全部做过一遍之后,感觉还是不太能跟上 OpenGL 的编程“理念”:初步感受下来觉得 OpenGL 很注重上下文,但是调用的函数中就没有特别强调这一点,上下文相关的内容一旦设置过了,就会一直延续下去。同时着色器的概念也是第一次接触,相比 GDI 画个点和三角形的方式,OpenGL 的方式很颠覆自己的习惯。

不管怎么样,还是先记录下自己的学习过程。相信之后会更加了解 OpenGL。

书上的应用框架

书上的应用框架采用典型的“模板方法”设计模式。像 Windows 窗体的创建,目前阶段压根不是我们需要关注的地方,相关内容也非常繁琐,又需要看另一整本书才能了解。本书框架就把我们需要关注的部分提供为虚函数,其余不需要关注的地方都封装起来了。后续我们填充相应的虚函数即可。

如清单 2.2 所示,继承应用框架(sb7::application),然后重写 render 函数。DECLARE_MAIN 宏定义了 WinMain 函数(Windows 平台),并在其中使用到了我们自己定义的类(my_application)。

原书清单 2.2 随着时间赋予颜色
  • #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 应该也是有绑定的上下文的,可能“埋没”在框架代码中了。

例1 随着时间赋予颜色

使用着色器

OpenGL 通过以固定函数作为“胶水”连接多个叫做着色器的小程序来工作。在这里的描述,对着色器的修饰是程序,后面也可以看到它通过 OpenGL 着色语言(GLSL)编写,并且也要编译链接。

最基本的管线配置只有一个顶点着色器(或者是一个运算着色器),但如果想在显示屏上看到图形,还需要一个片段着色器。本章也只实现最基本的顶点着色器和片段着色器。

清单 2.3 是一个顶点着色器,可以看到着色器语法和 C 语言类似。所有以 gl_ 开始的变量都是 OpenGL 的一部分,并使着色器相互连接或将其连接至 OpenGL 中固定函数的不同部分。顶点着色器中,gl_Position 表达顶点的输出位置。这些 gl_ 开始的变量有点 “全局共享” 的感觉。当前例子输出的顶点在窗口正中间。

原书清单 2.3 我们的第一个顶点着色器
  • #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 已经是“全局”的了,就可以当成是输出;片段着色器中要显式指明什么是输出,像此例输出青色。

原书清单 2.4 我们的第一个片段着色器
  • #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 函数最后返回的是程序对象标识。

原书清单 2.5 改编 编译一个简单的着色器
  1. #include "sb7.h"
  2. #include <stdint.h>
  3. #include <string>
  4. #include <memory>
  5. #include <fstream>
  6.  
  7. using TGLcharPtr = std::unique_ptr<const GLchar[]>;
  8.  
  9. TGLcharPtr read_shader_text(std::string name)
  10. {
  11.     GLchar* p = NULL;
  12.     std::string path = name.insert(0, "GLSL/");
  13.     std::ifstream f(path, std::ios::binary);
  14.     if (f.is_open())
  15.     {
  16.          f.seekg(0, std::ios::end);
  17.          uint64_t size = f.tellg();
  18.          f.seekg(0, std::ios::beg);
  19.  
  20.          p = new GLchar[size + 1];
  21.          f.read(p, size);
  22.          p[size] = 0;
  23.  
  24.          f.close();
  25.     }
  26.  
  27.     return TGLcharPtr(p);
  28. }
  29.  
  30. GLuint compile_shaders(void)
  31. {
  32.     GLuint vertex_shader;
  33.     GLuint fragment_shader;
  34.     GLuint program;
  35.  
  36.     // Source code for vertex shader
  37.     TGLcharPtr vertex = read_shader_text("2_3_VertexShader.vert");
  38.     const GLchar* vertex_shader_source[] =
  39.     { vertex.get() };
  40.  
  41.     // Source code for fragment shader
  42.     TGLcharPtr fragment = read_shader_text("2_4_FragmentShader.frag");
  43.     const GLchar* fragment_shader_source[] =
  44.     { fragment.get() };
  45.  
  46.     // Create and compile vertex shader
  47.     vertex_shader = glCreateShader(GL_VERTEX_SHADER);
  48.     glShaderSource(vertex_shader, 1, vertex_shader_source, NULL);
  49.     glCompileShader(vertex_shader);
  50.  
  51.     /*static GLchar log[10240];
  52.     glGetShaderInfoLog(vertex_shader, sizeof(log), NULL, log);
  53.     printf("%s\n", log);
  54.  
  55.     GLint result = GL_FALSE;
  56.     glGetShaderiv(vertex_shader, GL_COMPILE_STATUS, &result);*/
  57.  
  58.     // Create and compile fragment shader
  59.     fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
  60.     glShaderSource(fragment_shader, 1, fragment_shader_source, NULL);
  61.     glCompileShader(fragment_shader);
  62.  
  63.     // Create program, attach shaders to it, and link it
  64.     program = glCreateProgram();
  65.     glAttachShader(program, vertex_shader);
  66.     glAttachShader(program, fragment_shader);
  67.     glLinkProgram(program);
  68.  
  69.     // Delete the shader as the program has them now
  70.     glDeleteShader(vertex_shader);
  71.     glDeleteShader(fragment_shader);
  72.  
  73.     return program;
  74. }

清单 2.6 和 2.7 实现了渲染一个点。成员函数 startup 和 shutdown 看名字容易猜测,是窗体生命周期的前后。我们看看有什么需要初始化和清理的。

绘图前需要做的最后一件事是创建顶点数组对象(vertex array object,VAO),表示 OpenGL 管线顶点获取阶段的对象,用于向顶点着色器提供输入。由于现在顶点着色器没有任何输入,因此我们不需要做太多工作。但是我们需要创建 VAO,这样才能使用 OpenGL 绘图。我们使用 glCreateVertexArrays 创建 VAO,使用 glBindVertexArray 将其绑定到我们的上下文。

窗体销毁时,我们需要清理一些之前申请的 OpenGL 资源,和之前说的 glDeleteShader 一样。glDeleteVertexArrays 删除创建的 VAO,glDeleteProgram 删除程序对象。

不知道为什么 glDeleteVertexArrays 需要调用两次。

原书清单 2.6/2.7 创建程序成员变量/渲染一个点
  • #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,注意核对,这个点在屏幕正中间(顶点着色器指定),颜色是青色(片段着色器指定)。

图1 渲染我们的第一个点

绘制我们的第一个三角形

绘制三角形需要三个点,所以我们需要修改一下着色器。如清单 2.8 所示,其中硬编码了三个点的位置,关键是如何使用到它们。

GLSL 的顶点着色器包含一个名为 gl_VertexID 的特殊输入,它是此时正在被处理的顶点的索引。gl_VertexID 输入从 glDrawArrays() 的 first 参数给定的值开始计算,并且每次向上计算一个顶点直到 count 个顶点(glDrawArrays()的第三个参数)。以上这段话的逻辑前面也用自己的理解讲过一遍,要注意 gl_VertexID 是一个输入变量。

原书清单 2.8 在一个顶点着色器中生成多个顶点
  • #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 所示。

原书清单 2.9 渲染一个三角形
  • 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);
  • }
图2 最初的 OpenGL 三角形

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

图3 glDrawArrays(GL_POINTS, 0, 3)
图3 glDrawArrays(GL_POINTS, 1, 2)