我们会讨论一些内建变量、组织着色器输入和输出的新方式以及一个叫做uniform缓冲对象的非常有用的工具。

GLSL的内建变量:

着色器是很小的,如果我们需要从当前着色器以外的别的资源里的数据,那么我们就不得不传给它。我们学过了使用顶点属性(vertex attributes)、uniform和采样器(samplers)可以实现这个目标。GLSL有几个以gl_为前缀的变量,使我们有一个额外的手段来获取和写入数据。其中两个我们已经打过交道了:gl_Position和gl_FragCoord,前一个是顶点着色器的输出向量,后一个是片段着色器的。

下面介绍几个有趣的GLSL内建变量,如果你想看到所有的内建变量最好去查看OpenGL的wiki

顶点着色器变量

gl_Position

我们已经了解gl_Position是顶点着色器裁切空间输出的位置向量。如果你想让屏幕上渲染出东西gl_Position必须被使用。否则我们什么都看不到。

gl_PointSize

我们可以使用的一种基本图形(primitive)是GL_POINTS,每个顶点(vertex)作为一个基本图形,被渲染为一个点(point)。可以使用glPointSize函数来设置这个点的大小,但我们还可以在顶点着色器里修改点的大小。

GLSL有一个输出变量叫做gl_PointSize,它是一个float变量,你可以以像素的方式设置点的高度和宽度。它在着色器中描述每个顶点做为点被绘制出来的大小。

在着色器中影响点的大小默认是关闭的,但是如果你打算开启它,你需要开启OpenGL的GL_PROGRAM_POINT_SIZE: glEnable(GL_PROGRAM_POINT_SIZE);

想象一下,每个顶点表示出来的点的大小的不同,如果用在像粒子生成之类的技术里会挺有意思的。

gl_VertexID

gl_Position和gl_PointSize都是输出变量,它们的值是作为顶点着色器的输出被读取的;通过向它们写入数据可以影响结果。 顶点着色器也为我们提供了一个有趣的输入变量,我们只能从它那里读取,这个变量叫做gl_VertexID。

gl_VertexID是个整型变量,它储存着我们绘制的当前顶点的ID。当进行索引渲染(indexed rendering,使用glDrawElements渲染)时,这个变量保存着当前绘制的顶点的索引。当用的不是索引绘制(glDrawArrays)时,这个变量保存的是从渲染开始起直到当前处理顶点的编号。

片段着色器的变量

gl_FragCoord

它是片段着色器的输入变量,类型为vec4向量。 在讨论深度测试时,我们已经了解到,gl_FragCoord向量的z元素和特定的fragment的深度值相等。然而,我们也可以使用这个向量的x和y元素来实现一些有趣的效果。

gl_FragCoord的x和y元素是当前片段的窗口空间坐标(window-space coordinate)。它们的起始处是窗口的左下角。如果我们的窗口是800×600的,那么一个片段的窗口空间坐标x的范围就在0到800之间,y在0到600之间。

我们可以使用片段着色器基于片段的窗口坐标计算出一个不同的颜色。gl_FragCoord变量的一个常用的方式是与一个不同的片段计算出来的视频输出进行对比,通常在技术演示中常见。比如我们可以把屏幕分为两个部分,窗口的左侧渲染一个输出,窗口的右边渲染另一个输出。下面是一个基于片段的窗口坐标的位置的不同输出不同的颜色的片段着色器:

1
2
3
4
5
6
7
void main()
{
    if(gl_FragCoord.x < 400)
        color = vec4(1.0f, 0.0f, 0.0f, 1.0f);
    else
        color = vec4(0.0f, 1.0f, 0.0f, 1.0f);
}

gl_FrontFacing

片段着色器另一个有意思的输入变量是gl_FrontFacing变量。在面剔除教程中,我们提到过OpenGL可以根据顶点绘制顺序弄清楚一个面是正面还是背面。gl_FrontFacing变量能告诉我们当前三角形图元是正面的还是背面的。然后我们可以决定做一些事情,比如为正面计算出不同的颜色。

gl_FrontFacing变量是一个布尔值,如果当前片段是正面的一部分那么就是true,否则就是false。这样我们可以创建一个立方体,里面和外面使用不同的纹理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
out vec4 color;
in vec2 TexCoords;

uniform sampler2D frontTexture;
uniform sampler2D backTexture;

void main()
{
    if(gl_FrontFacing)
        color = texture(frontTexture, TexCoords);
    else
        color = texture(backTexture, TexCoords);
}

注意,如果你开启了面剔除,你就看不到箱子里面有任何东西了,所以此时使用gl_FrontFacing就没有意义了。

gl_FragDepth

输入变量gl_FragCoord让我们可以读得当前片段的窗口空间坐标和深度值,但是它是只读的。我们不能影响到这个片段的窗口屏幕坐标,但是可以设置这个像素的深度值。GLSL给我们提供了一个叫做gl_FragDepth的变量,我们可以用它在着色器中设置像素的深度值。

1
gl_FragDepth = 0.0f; //现在片段的深度值被设为0

如果着色器中没有向gl_FragDepth写入值,它就会自动采用gl_FragCoord.z的值。

我们自己设置深度值有一个显著缺点,因为只要我们在片段着色器中对gl_FragDepth写入什么,OpenGL就会关闭所有的前置深度测试。它被关闭的原因是,在我们运行片段着色器之前OpenGL搞不清像素的深度值,因为片段着色器可能会完全改变这个深度值。

因此,你需要考虑到gl_FragDepth写入所带来的性能的下降。然而从OpenGL4.2起,我们仍然可以对二者进行一定的调和,这需要在片段着色器的顶部使用深度条件(depth condition)来重新声明gl_FragDepth:

1
layout (depth_<condition>) out float gl_FragDepth;

condition可以使用下面的值:

Condition 描述
any 默认值. 前置深度测试是关闭的,你失去了很多性能表现
greater 深度值只能比gl_FragCoord.z大
less 深度值只能设置得比gl_FragCoord.z小
unchanged 如果写入gl_FragDepth, 你就会写gl_FragCoord.z

下面是一个在片段着色器里增加深度值的例子,不过仍可开启前置深度测试:

1
2
3
4
5
6
7
8
9
#version 330 core
layout (depth_greater) out float gl_FragDepth;
out vec4 color;

void main()
{
    color = vec4(1.0f);
    gl_FragDepth = gl_FragCoord.z + 0.1f;
}

这个功能只在OpenGL4.2以上版本才有。

接口块(Interface blocks):

到目前位置,每次我们打算从顶点向片段着色器发送数据,我们都会声明一个相互匹配的输出/输入变量。从一个着色器向另一个着色器发送数据,一次将它们声明好是最简单的方式,但是随着应用变得越来越大,你也许会打算发送的不仅仅是变量,最好还可以包括数组和结构体。

为了帮助我们组织这些变量,GLSL为我们提供了一些叫做接口块(Interface blocks)的东西,好让我们能够组织这些变量。声明接口块和声明struct有点像,不同之处是它现在基于块(block),使用in和out关键字来声明,最后它将成为一个输入或输出块(block)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec2 texCoords;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out VS_OUT
{
    vec2 TexCoords;
} vs_out;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0f);
    vs_out.TexCoords = texCoords;
}

这次我们声明一个叫做vs_out的接口块,它把我们需要发送给下个阶段着色器的所有输出变量组合起来。当我们希望把着色器的输入和输出组织成数组的时候它就变得很有用。

然后,我们还需要在下一个着色器——片段着色器中声明一个输入interface block。块名(block name)应该是一样的,但是实例名可以是任意的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
out vec4 color;

in VS_OUT
{
    vec2 TexCoords;
} fs_in;

uniform sampler2D texture;

void main()
{
    color = texture(texture, fs_in.TexCoords);
}

如果两个interface block名一致,它们对应的输入和输出就会匹配起来。这是另一个可以帮助我们组织代码的有用功能,特别是在跨着色阶段的情况,比如几何着色器。

uniform缓冲对象 (Uniform buffer objects):

目前为止,当我们时用一个以上的着色器的时候,我们必须一次次设置uniform变量,尽管对于每个着色器来说它们都是一样的。所以为什么还麻烦地多次设置它们呢?

OpenGL为我们提供了一个叫做uniform缓冲对象的工具,使我们能够声明一系列的全局uniform变量, 它们会在几个着色器程序中保持一致。当使用uniform缓冲的对象时相关的uniform只能设置一次。我们仍需为每个着色器手工设置唯一的uniform。创建和配置一个uniform缓冲对象需要费点功夫。

因为uniform缓冲对象是一个缓冲,因此我们可以使用glGenBuffers创建一个,然后绑定到GL_UNIFORM_BUFFER缓冲目标上,然后把所有相关uniform数据存入缓冲。有一些原则,像uniform缓冲对象如何储存数据,我们会在稍后讨论。首先我们我们在一个简单的顶点着色器中,用uniform块(uniform block)储存投影和视图矩阵:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout (location = 0) in vec3 position;

layout (std140) uniform Matrices
{
    mat4 projection;
    mat4 view;
};

uniform mat4 model;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0);
}

前面,大多数例子里我们在每次渲染迭代,都为projection和view矩阵设置uniform。这个例子里使用了uniform缓冲对象,这非常有用,因为这些矩阵我们设置一次就行了。

在这里我们声明了一个叫做Matrices的uniform块,它储存两个4×4矩阵。在uniform块中的变量可以直接获取,而不用使用block名作为前缀。接着我们在缓冲中储存这些矩阵的值,每个声明了这个uniform块的着色器都能够获取矩阵。

现在你可能会奇怪layout(std140)是什么意思。它的意思是说当前定义的uniform块为它的内容使用特定的内存布局,这个声明实际上是设置uniform块布局(uniform block layout)。

uniform块布局(uniform block layout):

一个uniform块的内容被储存到一个缓冲对象中,实际上就是在一块内存中。因为这块内存也不清楚它保存着什么类型的数据,我们就必须告诉OpenGL哪一块内存对应着色器中哪一个uniform变量。

假想下面的uniform块在一个着色器中:

1
2
3
4
5
6
7
8
9
layout (std140) uniform ExampleBlock
{
    float value;
    vec3 vector;
    mat4 matrix;
    float values[3];
    bool boolean;
    int integer;
};

我们所希望知道的是每个变量的大小(以字节为单位)和偏移量(从block的起始处),所以我们可以以各自的顺序把它们放进一个缓冲里。每个元素的大小在OpenGL中都很清楚,直接与C++数据类型呼应,向量和矩阵是一个float序列(数组)。OpenGL没有澄清的是变量之间的间距。这让硬件以它认为合适的位置放置变量。

GLSL 默认使用的uniform内存布局叫做共享布局(shared layout),因为一旦偏移量被硬件定义,它们就会持续地被多个程序所共享。使用共享布局,GLSL可以为了优化而重新放置uniform变量,只要变量的顺序保持不变。

尽管共享布局给我们做了一些节约空间的优化,但通常在实践中并不适用分享布局,而是使用std140布局。std140通过一系列规范声明了它们各自的偏移量,为每个变量类型显式地声明了内存的布局。由于被显式的提及,我们就可以手工算出每个变量的偏移量。

每个变量都有一个基线对齐(base alignment),它等于在一个uniform块中这个变量所占的空间(包含边距),这个基线对齐是使用std140布局原则计算出来的。然后,我们为每个变量计算出它的对齐偏移(aligned offset),这是一个变量从块(block)开始处的字节偏移量。变量对齐的字节偏移一定等于它的基线对齐的倍数。

准确的布局规则可以在OpenGL的uniform缓冲规范中得到,但我们会列出最常见的规范。GLSL中每个变量类型比如int、float和bool被定义为4字节,每4字节被表示为N。

类型 布局规范
像int和bool这样的标量 每个标量的基线为N
向量 每个向量的基线是2N或4N大小。这意味着vec3的基线为4N
标量与向量数组 每个元素的基线与vec4的相同
矩阵 被看做是存储着大量向量的数组,每个元素的基数与vec4相同
结构体 根据以上规则计算其各个元素,并且间距必须是vec4基线的倍数

再次利用之前介绍的uniform块ExampleBlock,我们用std140布局,计算它的每个成员的aligned offset(对齐偏移):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
layout (std140) uniform ExampleBlock
{
                        // base alignment ----------    // aligned offset
    float value;        // 4                            // 0
    vec3 vector;        // 16                           // 16 (必须是16的倍数,因此 4->16)
    mat4 matrix;        // 16                           // 32  (第 0 行)
                        // 16                           // 48  (第 1 行)
                        // 16                           // 64  (第 2 行)
                        // 16                           // 80  (第 3 行)
    float values[3];    // 16 (数组中的标量与vec4相同)    // 96 (values[0])
                        // 16                           // 112 (values[1])
                        // 16                           // 128 (values[2])
    bool boolean;       // 4                            // 144
    int integer;        // 4                            // 148
};

使用计算出来的偏移量,根据std140布局规则,我们可以用glBufferSubData这样的函数,使用变量数据填充缓冲。虽然不是很高效,但std140布局可以保证在每个程序中声明的这个uniform块的布局保持一致。

在定义uniform块前面添加layout (std140)声明,我们就能告诉OpenGL这个uniform块使用了std140布局。另外还有两种其他的布局可以选择,它们需要我们在填充缓冲之前查询每个偏移量。我们已经了解了分享布局(shared layout)和其他的布局都将被封装(packed)。当使用封装(packed)布局的时候,不能保证布局在别的程序中能够保持一致,因为它允许编译器从uniform块中优化出去uniform变量,这在每个着色器中都可能不同。

使用uniform缓冲:

我们讨论了uniform块在着色器中的定义和如何定义它们的内存布局,但是我们还没有讨论如何使用它们。

首先我们需要创建一个uniform缓冲对象,这要使用glGenBuffers来完成。当我们拥有了一个缓冲对象,我们就把它绑定到GL_UNIFORM_BUFFER目标上,调用glBufferData来给它分配足够的空间。

1
2
3
4
5
6
GLuint uboExampleBlock;
glGenBuffers(1, &uboExampleBlock);
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
// 分配150个字节的内存空间
glBufferData(GL_UNIFORM_BUFFER, 150, NULL, GL_STATIC_DRAW); 
glBindBuffer(GL_UNIFORM_BUFFER, 0);

现在任何时候当我们打算往缓冲中更新或插入数据,我们就绑定到uboExampleBlock上,并使用glBufferSubData来更新它的内存。我们只需要更新这个uniform缓冲一次,所有的使用这个缓冲着色器就都会使用它更新的数据了。但是,OpenGL是如何知道哪个uniform缓冲对应哪个uniform块呢?

在OpenGL上下文(context)中,定义了若干绑定点(binding points),在那我们可以把一个uniform缓冲链接上去。当我们创建了一个uniform缓冲,我们把它链接到一个绑定点上,我们也把着色器中uniform块链接到同一个绑定点上,这样就把它们链接到一起了。下面的图标表示了这点:

你可以看到,我们可以将多个uniform缓冲绑定到不同绑定点上。因为着色器A和着色器B都有一个链接到同一个绑定点0的uniform块,它们的uniform块分享同样的uniform数据—uboMatrices。有一个前提条件是两个着色器必须都定义了Matrices这个uniform块。

我们调用glUniformBlockBinding函数来把uniform块设置到一个特定的绑定点上。函数的第一个参数是一个程序对象,接着是一个uniform块索引(uniform block index)和打算链接的绑定点。uniform块索引是一个着色器中定义的uniform块的索引位置,可以调用glGetUniformBlockIndex来获取这个值,这个函数接收一个程序对象和uniform块的名字。我们可以从图表设置Lights这个uniform块链接到绑定点2:

1
2
GLuint lights_index = glGetUniformBlockIndex(shaderA.Program, "Lights");
glUniformBlockBinding(shaderA.Program, lights_index, 2);

注意,我们必须在每个着色器中重复做这件事。

从OpenGL4.2起,也可以在着色器中通过添加另一个布局标识符来储存一个uniform块的绑定点,就不用我们调用glGetUniformBlockIndex和glUniformBlockBinding了。下面显式设置了Lights这个uniform块的绑定点:

1
layout(std140, binding = 2) uniform Lights { ... };

然后我们还需要把uniform缓冲对象绑定到同样的绑定点上,这个可以使用glBindBufferBase或glBindBufferRange来完成。

1
2
3
glBindBufferBase(GL_UNIFORM_BUFFER, 2, uboExampleBlock);
// 或者
glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 150);

函数glBindBufferBase接收一个目标、一个绑定点索引和一个uniform缓冲对象作为它的参数。这个函数把uboExampleBlock链接到绑定点2上面,自此绑定点所链接的两端都链接在一起了。你还可以使用glBindBufferRange函数,这个函数还需要一个偏移量和大小作为参数,这样你就可以只把一定范围的uniform缓冲绑定到一个绑定点上了。使用glBindBufferRage函数,你能够将多个不同的uniform块链接到同一个uniform缓冲对象上。

现在所有事情都做好了,我们可以开始向uniform缓冲添加数据了。我们可以使用glBufferSubData将所有数据添加为一个单独的字节数组或者更新缓冲的部分内容,只要我们愿意。为了更新uniform变量boolean,我们可以这样更新uniform缓冲对象:

1
2
3
4
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
GLint b = true; // GLSL中的布尔值是4个字节,因此我们将它创建为一个4字节的整数
glBufferSubData(GL_UNIFORM_BUFFER, 144, 4, &b);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

同样的处理也能够应用到uniform块中其他uniform变量上。

uniform缓冲对象比单独的uniform有很多好处。 第一,一次设置多个uniform比一次设置一个速度快。 第二,如果你打算改变一个横跨多个着色器的uniform,在uniform缓冲中只需更改一次。 最后一个好处可能不是很明显,使用uniform缓冲对象你可以在着色器中使用更多的uniform。OpenGL有一个对可使用uniform数据的数量的限制,可以用GL_MAX_VERTEX_UNIFORM_COMPONENTS来获取。当使用uniform缓冲对象中,这个限制的阈限会更高。所以无论何时,你达到了uniform的最大使用数量(比如做骨骼动画的时候),你可以使用uniform缓冲对象。


参考 LearnOpenGL-CN 高级GLSL

缓冲数据写入

我们在OpenGL中已经大量使用缓冲来储存数据。有一些有趣的方式来操纵缓冲,也有一些有趣的方式通过纹理来向着色器传递大量数据。下面我们会讨论一些更加有意思的缓冲函数。

OpenGL中缓冲只是管理一块儿内存区域的对象。而当把缓冲绑定到一个特定的缓冲目标时,缓冲就被赋予了特殊的意义。OpenGL内部为每个目标(target)储存一个缓冲,并基于目标来处理不同的缓冲。

到目前为止,我们使用glBufferData函数填充缓冲对象管理的内存,这个函数分配了一块内存空间,然后把数据存入其中。如果我们向它的data参数传递的是NULL,那么OpenGL只会帮我们分配内存,而不会填充它。如果我们先打算开辟一些内存,稍后回到这个缓冲一点一点的填充数据,有些时候会很有用。

我们还可以调用glBufferSubData函数填充特定区域的缓冲,而不是一次填充整个缓冲。这个函数需要一个缓冲目标(target),一个偏移量(offset),数据的大小以及数据本身作为参数。这个函数不同的地方在于,我们可以给它一个偏移量(offset)来指定我们打算填充缓冲的位置与起始位置之间的偏移量。这样我们就可以插入/更新指定区域的缓冲内存空间了。一定要确保修改的缓冲要有足够的内存分配,所以在调用glBufferSubData之前,调用glBufferData是必须的。

1
2
// 范围: [24, 24 + sizeof(data)]
glBufferSubData(GL_ARRAY_BUFFER, 24, sizeof(data), &data); 

把数据传进缓冲另一个方式是向缓冲内存请求一个指针,你自己直接把数据复制到缓冲中。调用glMapBuffer函数OpenGL会返回一个当前绑定缓冲的内存的地址,供我们操作:

1
2
3
4
5
6
7
8
9
10
11
12
float data[] = {
0.5f, 1.0f, -0.35f
...
};

glBindBuffer(GL_ARRAY_BUFFER, buffer);
// 获取当前绑定缓存buffer的内存地址
void* ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
// 向缓冲中写入数据
memcpy(ptr, data, sizeof(data));
// 完成后别忘了告诉OpenGL我们不再需要它了
glUnmapBuffer(GL_ARRAY_BUFFER);

调用glUnmapBuffer函数可以告诉OpenGL我们已经用完指针了。通过解映射(unmapping),指针会不再可用,如果OpenGL可以把你的数据映射到缓冲上,就会返回GL_TRUE。

把数据直接映射到缓冲上使用glMapBuffer很有用,因为不用把它储存在临时内存里。你可以从文件读取数据然后直接复制到缓冲的内存里。

分批处理顶点属性(Batching vertex attributes):

使用glVertexAttribPointer函数可以指定缓冲内容的顶点数组的属性的布局(layout)。我们已经知道,通过使用顶点属性指针我们可以交叉(interleaved)属性,也就是说我们可以把每个顶点的位置、法线、纹理坐标放在彼此挨着的地方。现在我们了解了更多的缓冲的内容,可以采取另一种方式了。

我们可以做的是把每种类型的属性的所有向量数据批量保存在一个布局,而不是交叉布局。与交叉布局123123123123不同,我们采取批量方式111122223333。

当从文件加载顶点数据时你通常获取一个位置数组,一个法线数组和一个纹理坐标数组。需要花点力气才能把它们结合为交叉数据。使用glBufferSubData可以简单的实现分批处理方式:

1
2
3
4
5
6
7
GLfloat positions[] = { ... };
GLfloat normals[] = { ... };
GLfloat tex[] = { ... };
// 填充缓冲
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(positions), &positions);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions), sizeof(normals), &normals);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions) + sizeof(normals), sizeof(tex), &tex);

这样我们可以把属性数组当作一个整体直接传输给缓冲,不需要再处理它们了。我们还可以把它们结合为一个更大的数组然后使用glBufferData立即直接填充它,不过对于这项任务使用glBufferSubData是更好的选择。

我们还要更新顶点属性指针来反应这些改变:

1
2
3
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), 0);  
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)(sizeof(positions)));  
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(GLfloat), (GLvoid*)(sizeof(positions) + sizeof(normals)));

注意,stride参数等于顶点属性的大小,由于同类型的属性是连续储存的,所以下一个顶点属性向量可以在它的后面3(或2)的元素那儿找到。

这样我们有了另一种设置和指定顶点属性的方式。使用哪个方式对OpenGL来说也不会有立竿见影的效果,这只是一种采用更加组织化的方式去设置顶点属性。选用哪种方式取决于你的偏好和应用类型。

复制缓冲:

当你的缓冲被数据填充以后,你可能打算让其他缓冲能分享这些数据或者打算把缓冲的内容复制到另一个缓冲里。glCopyBufferSubData函数让我们能够相对容易地把一个缓冲的数据复制到另一个缓冲里。函数的原型是:

1
void glCopyBufferSubData(GLenum readtarget, GLenum writetarget, GLintptr readoffset, GLintptr writeoffset, GLsizeiptr size);

readtarget和writetarget参数是复制的来源和目的缓冲目标。例如我们可以从一个VERTEX_ARRAY_BUFFER复制到一个VERTEX_ELEMENT_ARRAY_BUFFER,各自指定源和目的的缓冲目标。当前绑定到这些缓冲目标上的缓冲会被影响到。

但如果我们打算读写的两个缓冲都是顶点数组缓冲(GL_VERTEX_ARRAY_BUFFER)怎么办?我们不能用同一个缓冲作为操作的读取和写入目标次。出于这个理由,OpenGL给了我们另外两个缓冲目标叫做:GL_COPY_READ_BUFFER和GL_COPY_WRITE_BUFFER。这样我们就可以把我们选择的缓冲,用上面二者作为readtarget和writetarget的参数绑定到新的缓冲目标上了。

接着glCopyBufferSubData函数会从readoffset处读取的size大小的数据,写入到writetarget缓冲的writeoffset位置。下面是一个复制两个顶点数组缓冲的例子:

1
2
3
4
GLfloat vertexData[] = { ... };
glBindBuffer(GL_COPY_READ_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

我们也可以把writetarget缓冲绑定为新缓冲目标类型其中之一:

1
2
3
4
GLfloat vertexData[] = { ... };
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_ARRAY_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

有了这些额外的关于如何操纵缓冲的知识,我们已经可以以更有趣的方式来使用它们了。当你对OpenGL更熟悉,这些新缓冲方法就变得更有用。


参考 LearnOpenGL-CN 高级数据

利用cubemap,我们拥有了把整个环境映射为一个单独纹理的对象,我们利用这个信息能做的不仅是天空盒。使用带有场景环境的cubemap,我们还可以让物体有一个反射或折射属性。像这样使用了环境cubemap的技术叫做环境映射(environment mapping )技术,其中最重要的两个是反射(reflection)和折射(refraction)。

反射(reflection):

反射就是一个物体(或物体的某部分)反射他周围环境的属性,比如物体的颜色多少有些等于它周围的环境,这要基于观察者的角度。例如一个镜子是一个反射物体:它会基于观察者的角度泛着它周围的环境。 反射的基本思路不难。下图展示了如何计算反射向量,然后使用这个向量去从一个cubemap中采样:

反射示意

我们基于观察方向向量I和物体的法线向量N计算出反射向量R。我们可以使用GLSL的内建函数reflect来计算这个反射向量。最后向量R作为一个方向向量对cubemap进行索引/采样,返回一个环境的颜色值。最后的效果看起来就像物体反射了天空盒。

因为之前cubemap教程中,我们在场景中已经设置了一个天空盒,创建反射就不难了。我们改变一下箱子使用的那个片段着色器,给箱子一个反射属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
in vec3 Normal;
in vec3 Position;
out vec4 color;

uniform vec3 cameraPos;
uniform samplerCube skybox;

void main()
{
    vec3 I = normalize(Position - cameraPos);
    vec3 R = reflect(I, normalize(Normal));
    color = texture(skybox, R);
}

我们先来计算观察/摄像机方向向量I,然后使用它来计算反射向量R,接着我们用R从天空盒cubemap采样。要注意的是,我们需要修正顶点着色器来提供Normal和Position。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;

out vec3 Normal;
out vec3 Position;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0f);
    Normal = mat3(transpose(inverse(model))) * normal;
    Position = vec3(model * vec4(position, 1.0f));
}

我们使用正规矩阵(normal matrix)变换,来获得法线向量。Position输出的向量是一个世界空间位置向量,所以要乘以model矩阵。

我们可以引进反射贴图(reflection map)来使模型有另一层细节。和diffuse、specular贴图一样,我们可以从反射贴图上采样来决定fragment的反射率。使用反射贴图我们还可以决定模型的哪个部分有反射能力,以及强度是多少。

折射(refraction):

环境映射的另一个形式叫做折射,它和反射差不多。折射是光线通过特定材质对光线方向的改变。我们通常看到像水一样的表面,光线并不是直接通过的,而是让光线弯曲了一点。它看起来像你把半只手伸进水里的效果。

折射遵守斯涅尔定律(Snell’s law),使用环境贴图看起来就像这样:

折射示意

我们有个观察向量I,一个法线向量N,这次折射向量是R。就像你所看到的那样,观察向量的方向有轻微弯曲。弯曲的向量R随后用来从cubemap上采样。

折射可以通过GLSL的内建函数refract来实现,除此之外还需要一个法线向量,一个观察方向和一个两种材质之间的折射指数。

折射指数决定了一个材质上光线扭曲的数量,每个材质都有自己的折射指数。下表是常见的折射指数:

我们使用这些折射指数来计算光线通过两个材质的比率。在我们的例子中,光线/视线从空气进入玻璃的比率是1.00 / 1.52 = 0.658。

改变片段着色器:

1
2
3
4
5
6
7
void main()
{
    float ratio = 1.00 / 1.52;
    vec3 I = normalize(Position - cameraPos);
    vec3 R = refract(I, normalize(Normal), ratio);
    color = texture(skybox, R);
}

你可以向想象一下,如果将光线、反射、折射和顶点的移动合理的结合起来就能创造出漂亮的水的图像。一定要注意,出于物理精确的考虑当光线离开物体的时候还要再次进行折射;现在我们简单的使用了单边(一次)折射,大多数目的都可以得到满足。

动态环境贴图(Dynamic environment maps):

现在,我们已经使用了静态图像组合的天空盒,看起来不错,但是没有考虑到物体可能移动的实际场景。我们到现在还没注意到这点,是因为我们目前还只使用了一个物体。如果我们有个镜子一样的物体,它周围有多个物体,只有天空盒在镜子中可见,和场景中只有这一个物体一样。

使用帧缓冲可以为提到的物体的所有6个不同角度创建一个场景的纹理,把它们每次渲染迭代储存为一个cubemap。之后我们可以使用这个(动态生成的)cubemap来创建真实的反射和折射表面,这样就能包含所有其他物体了。这种方法叫做动态环境映射(dynamic environment mapping),因为我们动态地创建了一个物体的以其四周为参考的cubemap,并把它用作环境贴图。

它看起效果很好,但是有一个劣势:使用环境贴图我们必须为每个物体渲染场景6次,这需要非常大的开销。现代应用尝试尽量使用天空盒子,凡可能预编译cubemap就创建少量动态环境贴图。动态环境映射是个非常棒的技术,要想在不降低执行效率的情况下实现它就需要很多巧妙的技巧。


参考 LearnOpenGL-CN 立方体贴图

Cubemap:

之前我们一直使用的是2D纹理,下面来讨论一种将多个纹理组合起来映射到一个单一纹理的类型,它就是cubemap。

基本上说cubemap包含6个2D纹理,每个2D纹理是立方体的一个面。你可能会奇怪费事地把6个纹理结合为一个这样的立方体有什么用?这是因为cubemap有自己特有的属性,可以使用一个方向向量对它们索引和采样。 想象一下,我们有一个1×1×1的单位立方体,在它内部有个以原点为起点的方向向量。

从cubemap上使用橘黄色向量采样一个纹理值看起来和下图有点像:

方向向量的大小不重要。一旦提供了方向,OpenGL就会获取方向向量触碰到立方体表面上的相应的纹理像素(texel),这样就返回了正确的纹理采样值。

方向向量触碰到立方体表面的一点也就是cubemap的纹理位置,这意味着只要立方体的中心位于原点上,我们就可以使用立方体的位置向量来对cubemap进行采样。然后我们就可以获取所有顶点的纹理坐标,就和立方体上的顶点位置一样。所获得的结果是一个纹理坐标,通过这个纹理坐标就能获取到cubemap上正确的纹理。

创建一个Cubemap:

Cubemap和其他纹理一样,所以要创建一个cubemap,在进行任何纹理操作之前,需要生成一个纹理,激活相应纹理单元然后绑定到合适的纹理目标上。 这次要绑定到 GL_TEXTURE_CUBE_MAP纹理类型:

1
2
3
GLuint textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

由于cubemap包含6个纹理,我们必须调用glTexImage2D函数6次,还需要把纹理目标(target)参数设置为cubemap特定的面,来告诉OpenGL创建的纹理是对应立方体哪个面的。 OpenGL就提供了6个不同的纹理目标,来应对cubemap的各个面:

根据枚举特效,可以每次加1进行循环操作

1
2
3
4
5
for(GLuint i = 0; i < textures_paths.size(); i++)
{
    image = SOIL_load_image(textures_paths[i], &width, &height, 0, SOIL_LOAD_RGB);
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
}

我们也要定义它的环绕方式和过滤方式:

1
2
3
4
5
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

在片段着色器中,我们使用一个不同的采样器 — samplerCube,用它来从texture函数中采样,但是这次使用的是一个vec3方向向量,取代vec2。下面是一个片段着色器使用了cubemap的例子:

1
2
3
4
5
in vec3 textureDir; // 用一个三维方向向量来表示Cubemap纹理的坐标
uniform samplerCube cubemap;  // Cubemap纹理采样器
void main() {
    color = texture(cubemap, textureDir);
}

天空盒(Skybox):

cubemap可以简单的实现很多有意思的技术。其中之一便是天空盒(Skybox)。天空盒是一个包裹整个场景的立方体,它由6个图像构成一个环绕的环境,给玩家一种他所在的场景比实际的要大得多的幻觉。如果把这6个面折叠到一个立方体中,就会获得模拟了一个巨大风景的立方体。

加载天空盒: 先按上面方法加载6张纹理。然后创建一个立方体来绘制天空盒。当一个立方体的中心位于原点(0,0,0)的时候,它的每一个位置向量也就是以原点为起点的方向向量。这个方向向量就是我们要得到的立方体某个位置的相应纹理值。所以我们只需要提供位置向量,而无需纹理坐标。它的顶点着色器如下:

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
layout (location = 0) in vec3 position;
out vec3 TexCoords;

uniform mat4 projection;
uniform mat4 view;

void main()
{
    gl_Position =   projection * view * vec4(position, 1.0);  
    TexCoords = position;
}

顶点着色器把输入的位置向量作为输出给片段着色器的纹理坐标。片段着色器就会把它们作为输入去采样samplerCube:

1
2
3
4
5
6
7
8
9
10
#version 330 core
in vec3 TexCoords;
out vec4 color;

uniform samplerCube skybox;

void main()
{
    color = texture(skybox, TexCoords);
}

我们希望天空盒以玩家为中心。移除视图矩阵的平移部分,这样移动就影响不到天空盒的位置向量了。我们可以只用4X4矩阵的3×3部分去除平移。简单地将矩阵转为33矩阵再转回来,就能达到目标:

1
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));

为了绘制天空盒,我们可以把它作为场景中第一个绘制的物体并且关闭深度写入。这样天空盒才能成为所有其他物体的背景来绘制出来。

但这样并不高效,如果先渲染天空盒,那么就会为屏幕上每一个像素运行片段着色器,即使前面有物体遮挡。但如果我们直接打开深度测试,因为天空盒是个1×1×1的立方体,极有可能会通不过深度测试。办法是,我们需要让深度缓冲相信天空盒的深度缓冲有着最大深度值1.0,如此只要有个物体存在深度测试就会失败,看似物体就在它前面了。

前面坐标系统我们说过,透视除法(perspective division)是在顶点着色器运行之后执行的,它把gl_Position的xyz坐标除以w元素。而除法结果的z就等于顶点的深度值。因此,我们可以把输出位置的z元素设置为它的w元素,这样它的z就会转换为w/w = 1.0。修改顶点着色器:

1
2
3
4
5
6
void main()
{
    vec4 pos = projection * view * vec4(position, 1.0);
    gl_Position = pos.xyww;
    TexCoords = position;
}

1.0就是深度值的最大值,只有在没有任何物体可见的情况下天空盒才会被渲染(只有通过深度测试才渲染,否则假如有任何物体存在,就不会被渲染,只去渲染物体)。

我们必须改变一下深度方程,把它设置为GL_LEQUAL,原来默认的是GL_LESS。深度缓冲会为天空盒用1.0这个值填充深度缓冲,所以我们需要保证天空盒是使用小于等于深度缓冲来通过深度测试的,而不是小于。


参考 LearnOpenGL-CN 立方体贴图

我们前面已经把整个场景渲染到了一个单独的纹理上,我们可以创建一些有趣的效果,只要简单操纵纹理数据就能做到。这部分,我们展示一些常用的后处理特效,以及怎样添加一些创造性去创建出你自己的特效。

反相(Inversion):

我们已经取得了渲染输出的每个颜色,所以在片段着色器里返回这些颜色的反色并不难。我们得到屏幕纹理的颜色,然后用1.0减去它:

1
2
3
4
void main()
{
    color = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}

反相效果

灰度(Grayscale):

另一个有意思的效果是移除所有除了黑白灰以外的颜色作用,使整个图像成为黑白的。实现它的简单的方式是获得所有颜色元素,然后将它们平均化(因为当RGB值是相等时,就是灰度图):

1
2
3
4
5
6
void main()
{
    color = texture(screenTexture, TexCoords);
    float average = (color.r + color.g + color.b) / 3.0;
    color = vec4(average, average, average, 1.0);
}

这已经创造出很赞的效果了,但是人眼趋向于对绿色更敏感,对蓝色感知比较弱,所以为了获得更精确的符合人体物理的结果,我们需要使用加权通道(weighted channels):

1
2
3
4
5
6
void main()
{
    color = texture(screenTexture, TexCoords);
    float average = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
    color = vec4(average, average, average, 1.0);
}

灰度效果

Kernel effects:

在单独纹理图像上进行后处理的另一个好处是我们可以从纹理的其他部分进行采样。比如我们可以从当前纹理坐标的周围采样多个纹理值。创造性地把它们结合起来就能创造出有趣的效果了。

kernel是一个长得有点像一个小矩阵的数值数组,它中间的值中心可以映射到一个像素上,这个像素和这个像素周围的值再乘以kernel,最后再把结果相加就能得到一个值。所以,我们基本上就是给当前纹理坐标加上一个它四周的偏移量,然后基于kernel把它们结合起来。下面是一个kernel的例子:

kernel数组的例子

这个kernel表示一个像素周围八个像素乘以2,它自己乘以-15。这个例子基本上就是把周围像素乘上2,中间像素去乘以一个比较大的负数来进行平衡。

注:你在网上能找到的kernel的例子大多数都是所有值加起来等于1,如果加起来不等于1就意味着这个纹理值比原来更大或者更小了。

kernel对于后处理来说非常管用,因为用起来简单,网上能找到有很多实例。这里假设每个kernel都是3×3(实际上大多数都是3×3):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const float offset = 1.0 / 300;  

void main()
{
    vec2 offsets[9] = vec2[](
        vec2(-offset, offset),  // top-left
        vec2(0.0f,    offset),  // top-center
        vec2(offset,  offset),  // top-right
        vec2(-offset, 0.0f),    // center-left
        vec2(0.0f,    0.0f),    // center-center
        vec2(offset,  0.0f),    // center-right
        vec2(-offset, -offset), // bottom-left
        vec2(0.0f,    -offset), // bottom-center
        vec2(offset,  -offset)  // bottom-right
    );

    float kernel[9] = float[](
        -1, -1, -1,
        -1,  9, -1,
        -1, -1, -1
    );

    vec3 sampleTex[9];
    for(int i = 0; i < 9; i++)
    {
        sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
    }
    vec3 col;
    for(int i = 0; i < 9; i++)
        col += sampleTex[i] * kernel[i];

    color = vec4(col, 1.0);
}

在片段着色器中我们先为每个四周的纹理坐标创建一个9个vec2偏移量的数组。偏移量是一个简单的常数,你可以设置为自己喜欢的。接着我们定义kernel,这里应该是一个锐化kernel,它通过一种有趣的方式从所有周边的像素采样,对每个颜色值进行锐化。最后,在采样的时候我们把每个偏移量加到当前纹理坐标上,然后用加在一起的kernel的值乘以这些纹理值。

kernel 锐化效果

Blur:

创建模糊效果的kernel定义如下:

模糊效果 kernel

由于所有数值加起来的总和为16,简单返回结合起来的采样颜色是非常亮的,所以我们必须将kernel的每个值除以16。最终的kernel数组会是这样的:

1
2
3
4
5
float kernel[9] = float[](
    1.0 / 16, 2.0 / 16, 1.0 / 16,
    2.0 / 16, 4.0 / 16, 2.0 / 16,
    1.0 / 16, 2.0 / 16, 1.0 / 16  
);

我们可以随着时间的变化改变模糊量,创建出类似于某人喝醉酒的效果。或者,当我们的主角摘掉眼镜的时候增加模糊。模糊也能为我们在后面的教程中提供对颜色值进行平滑处理的能力。

你可以看到我们一旦拥有了这个kernel的实现以后,创建一个后处理特效就不再是一件难事。

kernel 模糊效果

边检测:

边检测 kernel

这个kernel将所有的边提高亮度,而对其他部分进行暗化处理,当我们值关心一副图像的边缘的时候,它非常有用。

边检测效果


参考 LearnOpenGL-CN 帧缓冲