现代 OpenGL 都使用可编程管线,从 OpenGL 3.2 开始,固定管线已经被废弃了,这意味着需要开发者自己编写、编译和链接着色器程序。手写 Shader 无疑增加了学习的成本,但也让开发者能直接接触并操控底层,能够实现更为强大的效果,也有利于学习计算机图形学。
写在前面
之前的例子使用的都是固定管线,又叫立即渲染模式,这个模式下 OpenGL 把渲染的细节封装隐藏起来,开发人员使用起来非常容易,但效率比较低且不够灵活。现代 OpenGL 使用的大多是可编程管线,又叫核心模式,从 OpenGL 3.2 开始,固定管线就已经被废弃了,所以学习现代 OpenGL 就必须学可编程管线。虽然可编程管线上手比较难,但它能让我们操控 OpenGL 的底层,更有利于学习计算机图形学。
使用可编程管线需要用到“着色器”,也就是大名鼎鼎的 shader。着色器是运行在 GPU 中的指令集,OpenGL 提供了一门着色器语言 GLSL,即 opengl shader language
,还提供了相应的编译器,shader 程序必须编译后才能在 GPU 上运行。
Shader
GPU 接受一级顶点数据,经过一系列过程之后生成屏幕上显示的像素。这是一个基本的图形渲染管线,包括下面几个过程:
顶点着色器(Vertex Shader):它接收单一的顶点作为输入,然后把 3D 坐标转换成另一个 3D 坐标,主要做变换操作;顶点着色器允许我们对顶点属性做一些基本处理。
图元装配(Primitive Assembly):它接收顶点着色器输出的顶点作为输入,然后把顶点装配成指定的图元形状,如果是 GL_POINTS 是装配成点,如果是 GL_TRIANGLES,则装配成三角形。
几何着色器(Geometry Shader):它接收图元装配生成的图元的一系列顶点作为输入,通过产生新顶点构造出新的图元形状。
光栅化阶段(Rasterization Stage):它接收几何着色器生成的图元,将图元映射为屏幕上显示的像素,生成供下个阶段使用的片段(Fragment);在这个阶段会对片段进行裁切,把屏幕之外的片段丢弃掉。
片段着色器(Fragment Shader):它接收光栅化之后的片段数据,一个片段就是一个像素所需的所有数据;片段着色器计算一个片段(像素)的最终颜色,通常片段着色器包含 3D 场景的数据(如光照、阴影、光的颜色等),这些因素会影响像素最终的颜色。
Alpha 测试和混合(Blending)阶段:它检测片段的深度值,以确定该像素是在其它物体前面还是后面;还会检查 alpha 值并进行混合(blend),所以片段着色器处理过的颜色还不是最终的颜色,alpha 测试和混合也会影响颜色。
可以看到一个管线要经过上面六个过程,其最终目的是确定屏幕上绘制的像素点及其颜色,其中从顶点着色器到光栅化是根据顶点数据生成屏幕上的像素;片段着色器和混合是确定像素的最终颜色。这六个阶段使用到了三个着色器,这三个着色器是我们可以自定义的,其中顶点着色器和片段着色器是最重要的,也是我们必须自定义的,因为 GPU 中并没有默认的顶点着色器和片段着色器(这是针对可编程管线的,固定管线则不用我们自己定义任何着色器)。
前面介绍了固定管线是如何在屏幕上绘制图元的,现在知道了可编程管线的过程和 shader,我们再看看可编程管线如何一步步绘制图元。
输入顶点数据
无论是绘制一个点还是一个三角形,或是更复杂的模型,都需要先传入顶点数据(再复杂的模型也是由一个个顶点组成的)。顶点的一个最重要属性就是位置,位置在 OpenGL 中以三维坐标表示,所以一个顶点需要三个浮点数,表示它的位置。如果有多个顶点,可以以多维数组的方式来组织数据。
1 | GLfloat vertices[][3] = { |
也可以以一维数组的方式来组织数据,这不重要。
1 | GLfloat vertices[] = { |
标准化坐标
顶点位置经过顶点着色器处理之后,其坐标应该是标准化设备坐标,否则该顶点不会进行绘制;标准化坐标指 x,y,z
值都在范围 -1.0~1.0
之间,超出这个范围的顶点将被丢弃(这一步在光栅化的时候处理)。
这些顶点数据将会传给顶点着色器,它将在 GPU 中创建内存(显存)来存储这些顶点数据,通常是经过顶点缓冲对象(VBO)来管理顶点。使用顶点缓冲对象的好处是可以一次性发送大批顶点数据到显卡,数据从 CPU 发送 GPU 速度是很慢的。
回顾 VBO
前面已经介绍过 VBO 的使用了,这里再复习一下。首先要创建顶点缓冲对象,创建的方法是给对象一个名字。
1 | GLuint VBO; |
然后将对象绑定到 context,绑定的目标是 GL_ARRAY_BUFFER
,绑定之后针对 GL_ARRARY_BUFFER
的操作都将作用于这个 VBO 上,直到我们绑定新的对象或者将这个 VBO 解绑。然后我们要把顶点数据复制到缓冲内存中,此时在 GL_ARRAY_BUFFER
上的缓冲调用都是配置当前绑定的 VBO。
1 | glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); |
最后一个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW
=> 数据不会或几乎不会改变。GL_DYNAMIC_DRAW
=> 数据会被改变很多。GL_STREAM_DRAW
=> 数据每次绘制时都会改变。
经过创建缓冲对象、绑定对象、填充数据这三步之后,我们就已经成功将顶点数据发送到显卡并使用缓冲对象来管理。但这并不是最终的顶点数据,管线还需使用顶点着色器对数据进行处理,所以下一步就是定义一个顶点着色器。
顶点着色器
接下来我们使用 glsl 来编写我们第一个 shader 程序。
1 |
|
第一行是声明 glsl 的版本号,从 OpenGL3.3 开始,GLSL 和 OpenGL 的版本号是对应的,即 OpenGL3.3 对应的 GLSL 版本号是 330。可以在程序中查询我们电脑上 OpenGL 的版本号。
1 | char* VERSION = (char*)glGetString(GL_VERSION); |
要注意的是必须在初始化 context 之后才能使用该函数,即使用 glut 创建出窗口之后调用。
第二行是创建一个变量,in 表示这是一个输入顶点属性,即它的值从外部(上一阶段)接收。vec3 表示变量的类型,即是一个三维向量。layout (location =0 )
是设定输入变量的位置值(location),因为一个对象可能有多个属性值,我们必须知道 position 接收的是对象的第几个属性。
GLSL 的语法和 C 语言很相似,每个 shader 都需要一个 main 函数。在本例子中,main 函数根据输入的三维顶点数据生成四维顶点数据,生成的顶点数据用于下个阶段使用。
编译着色器
目前我们已经完成了一个最简单的顶点着色器编写,接下来需要对该 shader 进行编译,编译的方式是使用代码来编译。
前面说过 OpenGL 使用对象来处理一切,所以我们首先要创建一个 shader 对象。
1 | GLuint vertexShader; |
同样,使用一个 32 位无符号整数来作为 shader 对象的引用(名字),创建 shader 对象的时候需要指定 shader 类型,这里是顶点着色器,所以传入 GL_VERTEX_SHADER
。
接下来开始编译 shader,shader 源文件是以字符串的形式存在的,所以如果我们把 shader 定义成一个文件的话,则需要先将文件内容读取出来,保存在字符串中。
1 | std::ifstream vertexFile; |
glShaderSource
函数把要编译的着色器对象作为第一个参数;第二参数指定了传递的源码字符串数量,这里只有一个;第三个参数是顶点着色器真正的源码,第四个参数我们先设置为 NULL
。
编译之后还得验证一下编译是否成功,使用 glGetShaderiv
获取当前 shader 的状态。
1 | GLint status; |
片段着色器
片段着色器主要是设置像素的颜色,同样我们先用 glsl 编写一个片段着色器 fragment.fs。
1 |
|
第一行同样声明使用的 glsl 版本号;第二行使用 out 关键字声明一个输出变量;在 main 函数中设置输出变量的值。
使用和顶点着色器一样的方法编译片段着色器。
1 | std::ifstream fragmentFile; |
链接着色器
现在我们已经成功创建一个顶点着色器对象和一个片段着色器对象并编译成功,接下来得把这两个对象链接到一个着色器程序对象,然后在渲染的时候激活着色器程序,已激活的着色器程序中的着色器将在发送渲染调用时被使用。
创建着色器程序对象和创建着色器对象类似。
1 | GLuint shaderProgram; |
然后把着色器附加到着色器程序,再进行链接。
1 | glAttachShader(shaderProgram, vertexShader); |
和编译一样,我们也得检测链接着色器程序是否成功,避免后面使用出现问题。
1 | glGetProgramiv(shaderProgram, GL_LINK_STATUS, &status); |
把着色器对象链接到着色器程序对象之后,着色器对象就没用了,我们应该把它们删掉。
1 | glDeleteShader(vertexShader); |
链接顶点属性
经过创建着色器对象,编译着色器对象,链接着色器对象到着色器程序对象这三步之后,我们成功得到一个包括顶点着色器和片段着色器的着色器程序对象。现在我们已经有了顶点缓冲对象 VBO,着色器程序对象,似乎已经可以开始绘制了,但其实还缺少一步。
顶点着色器接收任何顶点属性作为输入,因此我们必须告诉 OpenGL 顶点缓冲对象中的哪些顶点数据对应哪一个顶点属性,使用 glVertexAttribPointer
指定 指定索引的顶点属性 使用的顶点数据。回顾我们的顶点着色器,定义了一个顶点属性输入变量,我们使用 layout(location = 0 )
指明了顶点着色器将使用索引为 0 的顶点属性作为顶点的位置属性,所以在程序中我们要给索引为 0 的顶点属性指定其数据来源。
1 | glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), 0); |
首先我们看一下 VBO 管理的数据在显存中的存储方式。
- 第一个参数指定顶点属性的索引,即要为第几个顶点属性指定数据;
- 第二个参数指定顶点属性的大小,即该属性由几个值构成;
- 第三个参数指定顶点属性的类型;
- 第四个参数指定数据是否标准化;
- 第五个参数指定连续的顶点属性组之间的间隔,简单讲就是一个顶点所有属性的步长;本例的顶点只有一个属性(位置),这个属性由 xyz 三个分量组成,共 3*4=12 个字节;
如果顶点只有一个属性,也可以使用 0 让 OpenGL 自动计算步长。
- 最后一个参数表示当前指定给顶点属性的数据在缓冲区中的起始偏移值,这里顶点位置数据在缓冲区起始位置,所以偏移值为 0。
如果顶点数据为 xyzrgb,给顶点的颜色属性指定数据时,使用的是 rgb 这三个数据,则其偏移值是 r 与数据起始位置的偏移值,即 12 个字节。
开始绘制
终于要开始绘制了,但首先让我们回顾一下这整个过程。
- 第一步,创建顶点缓冲对象 VBO,绑定到 context,然后往缓冲对象填充数据;
- 第二步,编写着色器,编译链接,生成一个着色器程序对象;
这两步是静态的,只需要创建一次即可,因此在初始化函数里完成。
- 第三步,设置顶点属性指针,指定每个顶点属性与顶点缓冲对象数据的关系;
- 第四步,开启顶点属性,使用着色器程序,调用绘制函数,然后关闭顶点属性。
这两步是动态的,需要放在主回调函数 render 中。
1 |
|