OpenGL核心技术之GPU编程
笔者介绍
姜雪伟,泰课在线高级讲师
引言
3D游戏引擎的核心在于渲染,而游戏品质的提升往往依赖于Shader编程实现的渲染技术。常见的渲染方式主要有Direct3D和OpenGL。目前流行的引擎,如Unity3D、Cocos2d-x、UE4引擎在移动端的渲染均采用OpenGL。因此,掌握OpenGL的渲染技术对于了解引擎内部实现方式至关重要。
Shader编程核心内容
顶点着色器与片段着色器的数据传递
Shader脚本的实现主要分为顶点着色器和片段着色器,顶点着色器计算得到的值会传递给片段着色器使用。当我们打算从顶点着色器向片段着色器发送数据时,需要声明相互匹配的输出/输入变量。在小型应用中,一次性声明好这些变量是较为简单的方式。但随着应用规模的增大,我们可能需要发送数组和结构体等更为复杂的数据。
为了更好地组织这些变量,GLSL提供了接口块(Interface Blocks)。声明接口块与声明结构体类似,但它基于块(block),使用in和out关键字进行声明,最终会成为一个输入或输出块。以下是一个顶点着色器的示例:
#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的接口块,它将需要发送给下一个阶段着色器的所有输出变量组合在一起。虽然这只是一个简单的例子,但接口块确实有助于我们组织着色器的输入和输出。
接下来,我们需要在片段着色器中声明一个输入接口块,块名应与顶点着色器中的输出接口块名一致,但实例名可以任意指定。以下是片段着色器的示例:
#version 330 core
out vec4 color;
in VS_OUT
{
vec2 TexCoords;
} fs_in;
uniform sampler2D texture;
void main()
{
color = texture(texture, fs_in.TexCoords);
}
如果两个接口块名一致,它们对应的输入和输出就会匹配起来。这一特性在跨着色阶段(如几何着色器)时,对组织代码非常有用。
Uniform缓冲对象
引入Uniform缓冲对象的原因
在使用OpenGL进行开发时,如果我们使用多个着色器,可能会遇到一个问题:对于每个着色器,都需要多次设置相同的uniform变量,这无疑增加了开发的工作量。为了解决这个问题,OpenGL提供了Uniform缓冲对象(Uniform Buffer Object),它允许我们声明一系列全局的uniform变量,这些变量在多个着色器程序中保持一致。使用Uniform缓冲对象时,相关的uniform只需设置一次,不过仍需为每个着色器手工设置唯一的uniform。
创建和配置Uniform缓冲对象
Uniform缓冲对象本质上是一个缓冲,我们可以使用glGenBuffers函数创建一个Uniform缓冲对象,然后将其绑定到GL_UNIFORM_BUFFER缓冲目标上,并将所有相关的uniform数据存入缓冲。以下是一个在顶点着色器中使用uniform块存储投影和视图矩阵的示例:
#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);
}
在这个示例中,我们声明了一个名为Matrices的uniform块,用于存储两个4×4矩阵。在uniform块中的变量可以直接获取,无需使用块名作为前缀。通过使用Uniform缓冲对象,我们只需设置一次投影和视图矩阵,而无需在每次渲染迭代时都进行设置。
Uniform块布局
在上述示例中,我们看到了layout(std140)的声明,它表示当前定义的uniform块使用特定的内存布局,即设置了uniform块布局(uniform block layout)。一个uniform块的内容会被存储到一个缓冲对象中,也就是一块内存中。由于这块内存不清楚它保存的数据类型,我们必须告诉OpenGL哪一块内存对应着色器中的哪一个uniform变量。
GLSL默认使用的uniform内存布局是共享布局(shared layout),共享布局允许GLSL为了优化而重新放置uniform变量,只要变量的顺序保持完整。但由于我们无法确定每个uniform变量的偏移量,也就难以精确地填充uniform缓冲。因此,在实践中,我们通常使用std140布局。
std140布局通过一系列规则规范了每个变量的偏移量,我们可以手工计算出每个变量的偏移量。每个变量都有一个基线对齐(base alignment),它等于该变量在uniform块中所占的空间(包含边距),这个基线对齐是使用std140布局原则计算出来的。然后,我们为每个变量计算出它的对齐偏移(aligned offset),即变量从块(block)开始处的字节偏移量,变量对齐的字节偏移必须是其基线对齐的倍数。
以下是一些常见的std140布局规范:
| 类型 | 布局规范 |
| ---- | ---- |
| 像int和bool这样的标量 | 每个标量的基线为N(4字节) |
| 向量 | 每个向量的基线是2N或4N大小,vec3的基线为4N |
| 标量与向量数组 | 每个元素的基线与vec4相同 |
| 矩阵 | 被看做是存储着大量向量的数组,每个元素的基数与vec4相同 |
| 结构体 | 根据以上规则计算其各个元素,并且间距必须是vec4基线的倍数 |
我们以之前的ExampleBlock为例,使用std140布局计算每个成员的对齐偏移:
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块布局保持一致。
除了std140布局和共享布局,还有封装(packed)布局。当使用封装布局时,不能保证布局在其他程序中保持一致,因为它允许编译器从uniform块中优化掉uniform变量,这在每个着色器中可能会有所不同。
使用Uniform缓冲对象
首先,我们需要使用glGenBuffers函数创建一个Uniform缓冲对象,然后将其绑定到GL_UNIFORM_BUFFER目标上,并使用glBufferData函数为其分配足够的空间。以下是一个示例:
GLuint uboExampleBlock;
glGenBuffers(1, &uboExampleBlock);
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
glBufferData(GL_UNIFORM_BUFFER, 150, NULL, GL_STATIC_DRAW); // 分配150个字节的内存空间
glBindBuffer(GL_UNIFORM_BUFFER, 0);
当我们需要更新或插入数据时,只需绑定到uboExampleBlock上,并使用glBufferSubData函数更新其内存。我们只需更新这个uniform缓冲一次,所有使用该缓冲的着色器都会使用更新后的数据。
那么,OpenGL如何知道哪个uniform缓冲对应哪个uniform块呢?在OpenGL环境(context)中,定义了若干绑定点(binding points),我们可以将一个uniform缓冲链接到这些绑定点上。当我们创建一个uniform缓冲时,将其链接到一个绑定点上,同时也将着色器中的uniform块链接到同一个绑定点上,这样就实现了它们之间的链接。
我们可以使用glUniformBlockBinding函数将uniform块设置到一个特定的绑定点上。该函数的第一个参数是一个程序对象,接着是一个uniform块索引(uniform block index)和打算链接的绑定点。uniform块索引可以通过glGetUniformBlockIndex函数获取,该函数接收一个程序对象和uniform块的名字。以下是一个示例:
GLuint lights_index = glGetUniformBlockIndex(shaderA.Program, "Lights");
glUniformBlockBinding(shaderA.Program, lights_index, 2);
需要注意的是,我们必须在每个着色器中重复进行上述操作。
从OpenGL 4.2起,我们也可以在着色器中通过添加另一个布局标识符来存储一个uniform块的绑定点,从而避免调用glGetUniformBlockIndex和glUniformBlockBinding函数。以下是一个示例:
layout(std140, binding = 2) uniform Lights { ... };
最后,我们还需要使用glBindBufferBase或glBindBufferRange函数将uniform缓冲对象绑定到同样的绑定点上。
通过以上步骤,我们可以有效地使用Uniform缓冲对象,提高代码的可维护性和性能。