目录
前言
上一篇回顾:OpenGL学习(一) freeglut / GLEW 环境搭建与窗口创建
在上一篇博客中我们实现了环境的配置,但是我们只创建了白色的窗口,并未绘制任何图形,于是今天我们来绘制最基本的图形 ---- 三角形。
在开始之前,我们必须首先了解OpenGL的绘制流程,渲染流水线以及一些相关的概念。否则代码将会变得难以理解。
渲染流水线
在这一部分我们将逐渐了解如何通过基本的几何信息,一步一步绘制出我们想要的图形,这个流程称之为渲染流水线。
顶点与图元
OpenGL是基于GPU的图形绘制,如果你使用过其他语言的画图API,比如 python 的 matplotlib,你就能够显然发现两者的区别。
在 matplotlib 中,绘制一幅图形,我们往往需要传输一整个巨大的矩阵给我们的绘图 API,然后 matplotlib 按照图片的格式解析这个矩阵并且输出。
如下图:一种典型的cpu绘图方式
那么OpenGL的绘图方式有什么差异呢?回想起小学数学课上老师说的 “三角形有三个顶点” ,下图演示了使用顶点信息来绘制三角形的过程:
是的!只要知道三个顶点的信息,我们就能够绘制三角形了!这是一件令人兴奋的事情,这意味着我们的绘制将不在基于一板一眼的像素矩阵。我们通过基本图元来进行复杂图形的绘制!
这时候问题就来了,任何几何形状都可以作为图元!于是我们指定了一些最基本的几何形状作为OpenGL的基本图元(否则就乱套了)。下图展示了OpenGL的一些基本图元,其中三角形使用最广泛。
现在思路需要转变了。在OpenGL中绘制图形,我们第一件事情就是指定顶点与图元。当我们指定了顶点与图元之后,我们就能够绘制一些复杂的图形。通常都是由多个三角形拼凑出复杂的图形:
图片引自:百度图片
光栅化
光栅?啥玩意?请忽略这个看起来应该出现在大学物理课本中的词汇。实际上,光栅化就是由几何图元生成像素的过程 。
在上面的 “顶点与图元” 部分,我们了解到任意图形的绘制都是基于基本图元进行的。基本图元有一个显著的特征,那就是每个顶点之间,都是直线!
这意味着只要知晓两个顶点的坐标,我们可以求得其连线上任意点的坐标。我们通过简单的线性插值就可以确定其连线上所有点的坐标。比如我们以两个顶点生成一条直线,下面展示了光栅化的过程:
注:每个格子代表一个像素点
对于两个点可以线性插值,同样,三个点的情况也是可以通过线性插值去绘制的:
注:不光是可以通过插值生成对应位置的像素,任何顶点属性都可以插值,比如顶点颜色,顶点法向量等等
着色器
从图元装配到光栅化,通过顶点信息生成像素,我们已经能够有足够的条件去生成各种各样的图形了。但是我们不能够对我们的顶点或者像素做更多的处理。
什么?你说在传送顶点数据之前就利用cpu对其进行处理?你知道,现役的模型,有多少个顶点吗? cpu算力不够,我们往往需要利用GPU对顶点和像素进行处理,而GPU是多核的,能够很好的承担这些繁重的任务。所以着色器应运而生!
一句话:着色器是给GPU设计的小程序 (雾,如果你阅读过mc中着色器的源码,就知道这玩意代码量绝对不小。。。)
一般来说,着色器有四种,但是我们必须关注其中的两种:顶点着色器和片元着色器。一个粗糙的渲染流水线必须经过这两个着色器的处理:
- 顶点着色器阶段: 把每个单独的顶点作为输入,对其进行一些处理,比如坐标转换什么的
- 图元装配阶段: 将顶点着色器的所有输出,装配成相应的图元,比如三角形
- 被我们省略的着色器
- 光栅化阶段: 将基本图元通过线性插值法生成像素,此外,裁剪掉屏幕外的像素以提升性能
- 片元着色器阶段: 对光栅化阶段输出的每个像素进行处理,常用来实现一些特效
如图展示了粗糙的渲染流水线(包含顶点着色器和片元着色器):
注:被省略的着色器包括几何着色器和细分着色器,这在高级绘制中才会用到。比如mc中,SE 的 PTGI 着色器,利用几何着色器生成物体的遮光体积,帮助后续全局光照的运行。
渲染流水线小结
一图流:
注意:中间省略了一些操作,比如几何和细分着色器的处理。此外,后续包括深度测试,模板测试,背面剔除等测试与混合阶段。此外,图形并不是直接输出到屏幕,而是输出到帧缓冲中,这个我们后面细🔒
向GPU传递数据
在了解完渲染流水线之后,我们晓得了绘制的起点就是顶点数据的传输。其实不光能够传递顶点数据,我们还能够传输其他的数据,比如顶点的颜色,顶点的法线,顶点的纹理坐标等等。这一部分我们将通过GLEW提供的API,向GPU传递数据。
vbo
早期OpenGL是直接将顶点数据发送到GPU的,但是效率不高,而且难以管理。于是有了VBO的概念。VBO 全称 vertex buffer object,顶点缓存对象,用以缓存发送到着色器的顶点属性(可以是顶点坐标,顶点颜色,顶点法线等)。
VBO借鉴内存虚拟化的思想,一个显存中可以有多个VBO,他们缓存了不同的顶点数据,就好像多个线程都有其自己独立的运行内存一样,如图展示了有无VBO的显存管理方式:
vao
只有vbo还不能够很好的组织显存内的数据,因为vbo只规定了数据的二进制字节存储空间,而vbo并未规定数据如何解析。如图,一堆浮点数可以被以不同的方式进行解析:
于是产生了vao。vao 全称 vertex array object,顶点数组对象。vao规定了其对应vbo内的数据应该如何解析。此外,vao还负责找到与其对应的vbo在显存中的对应地址。
开始绘制三角形
知晓了渲染流水线,我们明白需要向 GPU 传顶点数据以使其绘制,而且要通过较为现代的 VAO+VBO 的解决方案进行数据交互。接下来我们开始绘制一个三角形。
我们传递两个信息,他们分别是:
- 三角形三个顶点的位置信息
- 三角形三个顶点的颜色信息
然后我们在顶点着色器中直接输出他们的位置和颜色到片元着色器,然后让光栅化帮我们对颜色进行插值,然后我们就可以看到彩色的三角形了!

使用GLuint进行对象引用
前面我们提到很多的 object ,比如 vao,vbo 等等。可是在 c 语言中并没有其对应类型的实现。事实上,为了跨平台,我们几乎所有的OpenGL的对象,都通过一个数字来对其进行引用。
我们可以通过同样的数据类型(GLunit)来表示不同的对象:
GLuint vao; // vao对象
GLuint vbo; // vbo对象
GLuint program; // 着色器对象
加载着色器程序
在往GPU里面传递数据之前,我们要告诉着色器程序我们传递的变量叫啥,比如我们传递顶点位置,我们需要在着色器中声明一个变量去接收顶点位置。可是,在此之前,我们先得有一个着色器程序!
⚠ 着色器程序对象 是 【顶点着色器+片元着色器】 组成的一组程序,而不是指单个的着色器。
我们可以使用 glCreateShader
函数来产生单个的着色器对象 :
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
随后通过 glShaderSource
函数,将真正的着色器字符源码(通过第三个参数)传送给我们的着色器对象,随后我们编译着色器程序:
glShaderSource(vertexShader, 1, (const GLchar**)(指向代码字符串的指针), NULL);
glCompileShader(vertexShader);
注:glShaderSource的第二个参数是源码字符串数目,我们读进来之后把它都转成一行的字符串,所以我们填1即可,此外第四个参数暂时填NULL。
有时候我们希望知晓着色器程序是否编译正确,我们可以在 glCompileShader
之后,通过如下的代码检测编译结果:
// 容错
GLint success;
GLchar infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success); // 错误检测
if (!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "顶点着色器编译错误\n" << infoLog << std::endl;
exit(-1);
}
我们对片元着色器也是如法炮制:
// 创建并且编译片段着色器
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, (const GLchar**)(&fpointer), NULL);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success); // 错误检测
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "片段着色器编译错误\n" << infoLog << std::endl;
exit(-1);
}
在两个着色器都编译正常之后,我们创建【着色器程序】对象,并且链接两个着色器,链接完成之后,我们可以销毁两个着色器对象了:
// 链接两个着色器到program对象
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// 删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
在了解这个流程之后,我们可以实现一个函数,传入两个路径,他就会读取对应的着色器,并且返回着色器程序对象。这个流程,在我们今后的代码中恐怕要频繁使用了,我们封装一下这些函数:
// 读取文件并且返回一个长字符串表示文件内容
std::string readShaderFile(std::string filepath)