UniformBufferObject(UBO)

Number of views 123

之前在编写着色器的时候,我们每次都需要在不同物体中定义类似功能的Uniform数据,比如 viewMatrix, projMatrix等。这在所有物体都使用相同着色器时,它的弊端不是那么明显,但是一个游戏中物体使用的着色器往往是各不相同的,所以对于每个着色器都传递一次相同的uniform数据一方面不易于管理,另一方面也造成了性能上的浪费。

假设我们有15个不同的着色器文件,每个文件都定义了viewMatrix与projMatrix Uniform变量,那么每个着色器程序我们需要传递两次Uniform数据,15个着色器程序总共就需要30次的传递至GPU,那么有没有办法只定义一次数据,对所有需要此类数据的着色器都能共享?并且共享的同时对于部分的Uniform数据也能定制化更新?这就是要介绍的UniformBufferObject,下面简称为UBO。

要定义UBO,首先需要知道它的分配规则,为了使UBO能够运行在更多的GPU上,除了定义它们外,还需要指定它们在GPU中的内存大小。UBO默认使用shared layout(共享布局)(布局规则包括了packed,shared,std140,std430),正如它的名称一样,它一旦定义就能够被多个着色器程序所共用,并且内部各个Uniform数据的内存已经由系统内部分配完成,但是这也带来了一定的麻烦,因为UBO是一段Buffer,所以在传递对应的UBO数据时,我们需要明确的知道系统分配时各个Uniform的偏移量,否则无法在一个Buffer中对其有效对应,虽然我们可以使用glGetUniformIndices显式的进行获取,但是还是相对麻烦。所以一般我们采用std140的布局规则,该布局规则为每个Uniform变量显式声明了内存大小,所以我们就可以计算出每个Uniform变量的偏移量,从而确定一个Uniform Buffer段的内存大小。可参考关于GPU内存布局规则

GLSL中标量类型比如int、float和bool被定义为4字节,每4字节表示为N,一个变量的对齐字节偏移量必须等于基准对齐量的倍数,详细描述见下表。

变量类型 基底对齐(字节) 总大小(字节)
标量 (bool, int, uint, float) 4 4
双精度浮点 (double) 8 8
二维向量 (vec2) 8 8
三维向量 (vec3) 16 16
四维向量 (vec4) 16 16
标量数组或向量数组 每个元素的基准对齐16字节 每个元素基准对齐16字节
矩阵类型 (mat2) 16 32 (2x16)
矩阵类型 (mat3) 16 48 (3x16)
矩阵类型 (mat4) 16 64 (4x16)
结构体 (struct) 16 对齐到16字节的倍数

从上表的分配规则可以看出,该分配方式会造成比较大的浪费,比如:bool类型实际只占用1字节,但是需要4字节来分配;另外就是数组的分配,数组中不管标量还是向量每个元素都以16字节分配;因为我们的数据常用做法都是统一放在一个结构体中,这又造成了另一个浪费: 结构体内的上一条Uniform数据与下一条Uniform数据的内存间隔必须是16字节的倍数(如果前后两数据都是标量类型,这种浪费会更明显),所以一个好的组织顺序是能够优化内存分配的(比如:可以把同类逻辑的标量数据用向量存储)。

  1. 标量数据类型
  • 标量数据类型 (bool, int, uint, float, double)
  • 基底对齐:4 字节(double 是 8 字节)
  • 大小:4 字节(double 是 8 字节)
  • 解释:标量的起始地址必须是 4 字节的倍数,double 的起始地址必须是 8 字节的倍数。
  1. 二维向量 (vec2)
  • 基底对齐:8 字节
  • 大小:8 字节
  • 解释:vec2 的起始地址必须是 8 字节的倍数。
  1. 三维向量 (vec3)
  • 基底对齐:16 字节
  • 大小:16 字节(实际占用 12 字节,但在 std140 中被视为 16 字节)
  • 解释:vec3 的起始地址必须是 16 字节的倍数,尽管它只占用了 12 字节,但它会被补齐到 16 字节以满足对齐要求。
  1. 四维向量 (vec4)
  • 基底对齐:16 字节
  • 大小:16 字节
  • 解释:vec4 的起始地址必须是 16 字节的倍数。
  1. 标量数组或向量数组
  • 每个元素的基底对齐:每个元素为16字节
  • 解释:每个元素16字节。
  1. 矩阵类型 (mat2, mat3, mat4)
  • 每行的基底对齐:16 字节
  • 总大小:
  • mat2:每行 8 字节,总共 16 字节(2 行,每行 8 字节)
  • mat3:每行 16 字节,总共 48 字节(3 行,每行 16 字节)
  • mat4:每行 16 字节,总共 64 字节(4 行,每行 16 字节)
  1. 结构体 (struct)
  • 基底对齐:结构体的基底对齐是其成员中最大基底对齐值的倍数,通常是 16 字节(即 4N)。
  • 大小:结构体的总大小必须是对齐到 16 字节的倍数,即使最后一个成员没有完全填满 16 字节,也会在结构体末尾添加填充字节以确保对齐。

以下面代码为例:

// 摘自Learn Opengl
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
};

上述是在着色器中对UBO的结构体声明,其中layout (std140) uniform指定该结构体是个UBO,并且使用std140的布局规则。由于vec3的基底对齐是16字节,所以value变量到vector变量的偏移量是16字节。

Opengl中正式支持UBO是在Opengl3.1的版本,在这版本之前需要确定是否有可支持的扩展(扩展名为 ARB_Uniform_Buffer_Object),比如GLEW库中可以通过判断GLEW_ARB_uniform_buffer_object是否为true,true则支持,false为不支持:

if (GLEW_ARB_uniform_buffer_object) {
 printf ("GLEW_ARB_uniform_buffer_object = YES\n");
} else {
 printf ("GLEW_ARB_uniform_buffer_object = NO\n");
}

因为每台设备对UBO可支持的最大绑定数量是不确定的,可以借助GL_MAX_UNIFORM_BUFFER_BINDINGS宏确定当前设备最大绑定数量:

GLint blocks = 0;
// 本人计算机运行webgl,实测支持的最大绑定数量为24
glGetIntegerv (GL_MAX_UNIFORM_BUFFER_BINDINGS, &blocks);
printf ("GL_MAX_UNIFORM_BUFFER_BINDINGS = %i\n", blocks);

关于glGetIntergerv(WebGL中为getParameter)的可支持状态获取可查看下面链接:

生成UBO的方式与生成VAO/VBO的方式类似,都是通过glGenBuffers生成Buffer,并通过glBindBuffer绑定,以及通过glBufferData分配数据, 但是需要明确指定Buffer类型为GL_UNIFORM_BUFFER:

GLuint cam_ubo_buffer;
glGenBuffers (1, &cam_ubo_buffer);
glBindBuffer (GL_UNIFORM_BUFFER, cam_ubo_buffer);
// cam_ubo只简单定义viewMat与projMat,所以它的总长度为sizeof (float) * 32
glBufferData (GL_UNIFORM_BUFFER, sizeof (float) * 32, NULL, GL_DYNAMIC_DRAW);

每个在Shader中定义的UBO block都需要指定其绑定索引,上面我们通过GL_MAX_UNIFORM_BUFFER_BINDINGS获取UBO能够绑定的最大数量,如果绑定的最大数量为M,则绑定索引的可能值范围为(0 ~ M-1),如果整个项目中只使用了一个UBO那么直接使用索引0作为绑定目标就可以了,但是绑定索引不要指定为可能值范围之外的数(比如:M或-1等),这会使UBO失效,并会输出如下的类似警告:

GL_INVALID_OPERATION: It is undefined behaviour to have a used but unbound uniform buffer.

要使用UBO绑定索引,需要先指定Shader中哪些UBO Block与绑定索引产生关联,那就需要手动指定Shader中各个UBO block的绑定索引:

// 设置ubo的默认绑定索引为0
int ubo_binding_idx = 0;
// 获取monkey这个物体对应的shader文件中定义的名称为cam_ubo_block的结构体索引位置
GLuint uniform_block_index_monkey =
glGetUniformBlockIndex (monkeyShaderProgram, "cam_ubo_block");
// 让shader中定义的ubo block结构体与绑定索引产生关联
glUniformBlockBinding (monkeyShaderProgram, uniform_block_index_monkey, ubo_binding_idx);
// 获取cube这个物体对应的shader文件中定义的名称为cam_ubo_block的结构体索引位置
GLuint uniform_block_index_cube_sp =
glGetUniformBlockIndex (cubeShaderPrograme, "cam_ubo_block");
// 让shader中定义的ubo block结构体与绑定索引产生关联
glUniformBlockBinding (cubeShaderPrograme, uniform_block_index_cube_sp, ubo_binding_idx);

如果整个项目中只定义了一个UBO,那么可以省略glGetUniformBlockIndex的过程,直接给定glUniformBlockBinding中第二个参数值为0即可,但是一般项目的UBO个数不止一个(比如有ShadowMapUBO, CameraUBO, GlobalUBO等)。

定义好了UBO相关配置后,就可以对绑定的数据进行更新了,这里有两种实现方式,一种是使用glBufferSubData更新数据:

// 通过glBindBufferBase把ubo数据与绑定索引关联起来
// 这里也可以使用glBindBufferRange函数,它需要一个附加的偏移量和大小参数,
// 这样子就可以绑定Uniform缓冲的特定一部分到绑定点中
// glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 152);
glBindBufferBase (GL_UNIFORM_BUFFER, ubo_binding_idx, cam_ubo_buffer);
// 对proj矩阵进行更新
glBufferSubData (
 GL_UNIFORM_BUFFER, 0, sizeof (float) * 16, proj_mat.m);
// 对view矩阵进行更新
glBufferSubData (
 GL_UNIFORM_BUFFER, sizeof (float) * 16, sizeof (float) * 16, view_mat.m);

另一种是使用glMapBufferRange的方式更新数据,一般来讲这种方式更新数据特别是大数据更新会更加高效:

// 通过glBindBufferBase把ubo数据与绑定索引关联起来
glBindBufferBase (GL_UNIFORM_BUFFER, ubo_binding_idx, cam_ubo_buffer);
float* cam_ubo_ptr = (float*)glMapBufferRange (
 GL_UNIFORM_BUFFER,
 0,
 sizeof (float) * 32,
 // 设置写标志
 GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT
);
// 对proj矩阵进行更新
memcpy (&cam_ubo_ptr[0], proj_mat.m, sizeof (float) * 16);
// 对view矩阵进行更新
memcpy (&cam_ubo_ptr[16], view_mat.m, sizeof (float) * 16);
// 数据设置完毕后记得Unmap
glUnmapBuffer(GL_UNIFORM_BUFFER);

简单讲glMapBufferRange的方式可以拿到指向的内存地址,可以直接对内存地址内部的缓冲区数据进行更新,所以它将更高效;而glBufferSubData的方式需要经过中间拷贝的过程。这是Opengl Insights 中的两者对比:

这是顶点着色器中的代码:

#version 400
in vec3 vp;
//uniform mat4 P, V; // old uniforms
/* new virtual camera block */
layout (std140) uniform cam_ubo_block{
 mat4 P;
 mat4 V;
};
void main () {
 gl_Position = P * V * vec4 (vp, 1.0);
}

顶点着色器中定义了一个叫cam_ubo_block的UBO 结构体,它使用了std140的布局规范,注意:在实际使用中可以直接取内部的uniform变量进行实际计算,而不需要定义cam_ubo_block的变量(比如在main函数中直接取P与V矩阵进行顶点变换)。

0 Answers