interface block 及 UBO、SSBO 详解

 
 

前言


  如果要将相机数据、光源数据同时传给地形着色器、阴影着色器等等要怎么做?一种简单的办法是在每个着色器中设置同样的 uniform 变量,然后各自赋值。但这个方法仍然显得太麻烦,有没有方法将数据存储在某个地方,然后需要的着色器自行读取呢?答案肯定是有的。
  GLSL 3.1 版本开始支持 uniform block 的数据结构,它可以和 OpenGL 中定义的 uniform buffer object(UBO) 绑定并读取数据,而且 UBO 的数据存储在显卡常量内存中;4.3 版本开始支持 buffer block 数据结构,它可以和 shader storage buffer object(SSBO) 绑定并读取数据,而且 SSBO 的数据存储在显卡全局内存中。
  由于定义中一个 UBO 可以绑定到多个 interface block 中,从而可以实现多个着色器之间共享数据的功能。

 

interface block


  interfac block是指 GLSL 中的对 in、out、uniform 修饰的变量组,类似于 C 语言中的 struct。但是 GLSL 中的 struct 和通常的变量一样,需要查询每个分量的位置才能赋值。
  interface block 按照用途不同分为 in block、out block、uniform block 和 buffer block。在流水线上下级传递的 block(in、out 修饰)和 struct 非常相似;uniform 和 buffer 修饰才是这一特性的精髓所在,它有额外和 OpenGL 交互的能力,其中 uniform 修饰的 block 对于来说 GLSL 不可修改,buffer 修饰 block 则相反。
  通过使用interface block,我们可以将着色器中的变量以组的形式来管理,这样书写更整洁。

  interface block的声明形式为:

storage_qualifier block_name
{
  ...define members here...
} instance_name;

  其中 storage_qualifier 指明这个 block 的存储限定符,限定符可以使用 in, out, uniform, 或者 buffer(GLSL4.3支持,详见 shader storage block)等,block_name 则给定名称,而 instance_name 给定实例名称。

  如果顶点着色器和片元着色器之间需要传递法向量、纹理坐标等变量,将他们封装到一个block中,代码显得更紧凑。顶点着色器中输出变量定义形式如下:

// 定义输出 interface block
out VS_OUT
{
    vec3 FragPos;
    vec2 TextCoord;
    vec3 FragNormal;
}vs_out; 

  而在片元着色器中,要以相同的block_name 接受,实例名称则可以不同,形式可以定义为:

// 定义输入interface block
in VS_OUT
{
    vec3 FragPos;
    vec2 TextCoord;
    vec3 FragNormal;
} fs_in; 

  如果指定了instance_name,则在片元着色器中引用这些变量时需要加上instance_name前缀,例如:

   // 环境光成分
    vec3 ambient = light.ambient * vec3(texture(material.diffuseMap, fs_in.TextCoord)); 

  反之如果没有指定 instance_name,则这个 block 中的变量将和 uniform 一样是全局的,可以直接使用。如果没有给定instance_name,则注意不要和 uniform 重名,否则造成重定义错误。

uniform MatrixBlock
{
  mat4 projection;
  mat4 modelview;
};

uniform vec3 modelview;  // 重定义错误 和MatrixBlock中冲突 

  相比于之前以分散形式书写这些变量,interface block 能够让你更合理的组织变量为一组,逻辑更清晰。

 

Uniform Buffer Object

  UBO 和 interface block 需要团结协作才能完成任务。如果要在多个着色器之间共享变量,可以将 GLSL 的 interface block 和 OpenGL 的 UBO 绑定在一起来实现。虽然多个 GLSL 都可以访问同一个 UBO,但是 UBO 的数据只能被 OpenGL 修改。如果需要 GLSL 反馈修改后的内容,可以使用 shader storage buffer object(SSBO) 和 interface block 中的 buffer 变量进行交互,详见下文。
  uniform buffer 的实现思路为: 在多个着色器中定义相同的 uniform block (就是上面的 interface block,使用 uniform 限定符定义),然后将这些 uniform block 绑定到对应的 uniform buffer object,而uniform buffer object 中实际存储这些需要共享的变量。GLSL 中的 uniform block 和 OpenGL 中的 uniform buffer object,是通过 OpenGL 的绑定点(binding points)连接起来的,它们的关系如下图所示(来自www.learningopengl.com Advanced GLSL):


uniform buffer

  使用时,每个 shader 中定义的 uniform block 有一个索引,通过这个索引连接到OpenGL 的绑定点 index;而 OpenGL 创建的 uniform buffer object 传递数据后,也将这个UBO绑定到对应的 index,此后 GLSL 中 uniform block 就和OpenGL中的UBO联系起来,我们在程序中操作UBO的数据,就能够在不同着色器之间共享了。例如上图中,着色器 A 和 B 定义的 Matrices 的索引都指向绑定点0,他们共享 openGL 的 uboMatrices 这个 UBO 的数据。同时着色器 A 的 Lights 和着色器 B 的 Data,分别指向不同的 UBO。
 

UBO的使用

  在上面我们介绍了 UBO 的概念,下面通过实例了解 UBO 的实际使用。
  GLSL 中 uniform block 的内存布局有三种形式:shared, packed, std140。默认使用 shared,详见OpenGL规范

  • shared 默认的内存布局。地址偏移量依赖于具体实现的优化方案,不为人所知,但是相同定义的 block 拥有相同的布局,因此可以在不同程序之间共享。要使 block 能够共享必须注意 block 具有相同定义,同时所有成员显式指定数组的大小。同时 shared 保证所有成员都是激活状态,没有变量被优化掉。
  • std140 这种方式明确的指定 alignment 的大小,会在 block 中添加额外的字节来保证字节对齐,因而可以提前就计算出布局中每个变量的位移偏量,并且能够在 shader 之间共享;不足在于添加了额外的padding字节。稍后会介绍字节对齐和padding相关内容
  • packed 紧凑的排布方式。变量挨个排列,没有间隙。节约存储空间,但对程序读取不友好。

  下面通过两个简单例子,来熟悉 std140 和默认的 shared 内存布局。这个例子将会在屏幕上通过 4 个着色器绘制 4 个不同颜色的立方体,在着色器之间共享的是投影矩阵和视变换矩阵,以及为了演示 shared layout 而添加的混合颜色的示例。
 

layout std140

 

字节对齐

  字节对齐的一个经典案例就是C语言中的结构体变量,例如下面的结构体:

struct StructExample {
    char c; 
    int i; 
    short s;
}; 

  估计它占用内存大小多少字节? 假设在int 占用 4 字节,short 占用 2 个字节,那么整体大小等于 1 + 4 + 2 = 7 字节吗?
  答案是否定的。在 VC14 下测试,当 int 占用 4 个字节,short 占用 2 个字节是,实际占用大小为 12 个字节。上述结构体的内存布局为:

   struct StructExample {
    char c;  // 0 bytes offset, 3 bytes padding
    int i;   // 4 bytes offset
    short s; // 8 bytes offset, 2 bytes padding
}; // End of 12 bytes 

内存布局如下图所示:
内存布局

  字节对齐的一个重要原因是为了使机器访问更迅速。例如在 32 字长的地址的机器中,每次读取 4 个字节数据,所以将字节对齐到上述地址 0x0000,0x0004 和 0x0008, 0x000C将使读取更加迅速。否则例如上面结构体中的 int i 将跨越两个字长(0x0000和0x0004),需要两次读取操作,影响效率。当然关于为什么使用字节对齐的更详细分析,感兴趣地可以参考 SO Purpose of memory alignment

  关于字节对齐,我们需要知道的几个要点就是(参考自wiki Data structure alignment):

  • 一个内存地址,当它是n字节的倍数时,称之为n字节对齐,这里n字节是2的整数幂。

  • 每种数据类型都有它自己的字节对齐要求(alignment),例如char是1字节,int一般为4字节,float为4字节对齐,8字节的long则是8字节对齐。

  • 当变量的字节没有对齐时,将额外填充字节(padding)来使之对齐。

  上面的结构体中,int 变量 i 需要 4 字节对齐,因此在 char 后面填充了3个字节,同时结构体变量整体大小需要满足最长 alignment 成员的字节对齐,因此在 short 后面补充了 2 个字节,总计达到 12 字节。

  关于字节对齐这个概念,介绍到这里,希望了解更多地可以参考The Lost Art of C Structure Packing
 

std140的字节对齐

  std140内存布局同样存在字节对齐的概念,你可以参考官方文档获取完整描述。常用标量int,float,bool要求4字节对齐,4字节也被作为一个基础值N,这里列举几个常用的结构的字节对齐要求:

类型 对齐基数(base alignment)
标量,例如 int bool 每个标量对齐基数为N
vector 2N 或者 4N, vec3的基数为4N.
标量或者vector的数组 每个元素的基数等于vec4的基数.
矩阵 以列向量存储, 列向量基数等于vec4的基数.
结构体 元素按之前规则,同时整体大小填充为vec4的对齐基数

例如一个复杂的uniform block定义为:

   layout (std140) uniform ExampleBlock
{
    //               // base alignment  // aligned offset
    float value;     // 4               // 0
    vec3 vector;     // 16              // 16  (must be multiple of 16 so 4->16)
    mat4 matrix;     // 16              // 32  (column 0)
                     // 16              // 48  (column 1)
                     // 16              // 64  (column 2)
                     // 16              // 80  (column 3)
    float values[3]; // 16              // 96  (values[0])
                     // 16              // 112 (values[1])
                     // 16              // 128 (values[2])
    bool boolean;    // 4               // 144
    int integer;     // 4               // 148
}; 

上面的注释给出了它的字节对齐,其中填充了不少字节,可以根据上面表中给定的对齐基数提前计算出来,在主程序中可以设置这个UBO的变量:

   GLuint exampleUBOId;
    glGenBuffers(1, &exampleUBOId);
    glBindBuffer(GL_UNIFORM_BUFFER, exampleUBOId);
    glBufferData(GL_UNIFORM_BUFFER, 152, NULL, GL_DYNAMIC_DRAW); // 预分配空间 大小可以提前根据alignment计算
    glBindBuffer(GL_UNIFORM_BUFFER, 0);
    glBindBufferBase(GL_UNIFORM_BUFFER, 1, exampleUBOId); // 绑定点为1
    // step4 只更新一部分值
    glBindBuffer(GL_UNIFORM_BUFFER, exampleUBOId);
    GLint b = true; // 布尔变量在GLSL中用4字节表示 因此这里用int存储
    glBufferSubData(GL_UNIFORM_BUFFER, 144, 4, &b); // offset可以根据UBO中alignment提前计算
    glBindBuffer(GL_UNIFORM_BUFFER, 0); 

说明: 上面最终计算出的大小为152,UBO整体不必满足vec4的字节对齐要求。152 /4 = 38,满足N的对齐要求即可。

从上面可以看到,当成员变量较多时,这种手动计算offset的方法比较笨拙,可以事先编写一个自动计算的函数库,以减轻工作负担。
 

std140的简单例子

  下面通过一个简单例子来熟悉 UBO 的使用。

Step1: 首先我们在顶点着色器中定义uniform block如下:

   #version 330 core

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;


uniform mat4 model; // 因为模型变换矩阵一般不能共享 所以单独列出来

// 定义UBO
layout (std140) uniform Matrices
{
   mat4 projection;
   mat4 view;
};  // 这里没有定义instance name,则在使用时不需要指定instance name


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

Step2 在主程序中设置着色器的uniform block索引指向到绑定点0:

   // step1 获取shader中 uniform buffer 的索引
    GLuint redShaderIndex = glGetUniformBlockIndex(redShader.programId, "Matrices");
    GLuint greeShaderIndex = glGetUniformBlockIndex(greenShader.programId, "Matrices");
    ...
    // step2 设置shader中 uniform buffer 的索引到指定绑定点
    glUniformBlockBinding(redShader.programId, redShaderIndex, 0); // 绑定点为0
    glUniformBlockBinding(greenShader.programId, greeShaderIndex, 0);
    ... 

  这里为了演示代码中重复写出了4个着色器,实际中可以通过vector装入这4个着色器简化代码。

Step3: 创建UBO,并绑定到绑定点0
  需要传入2个mat4矩阵,由于mat4中每列的vec4对齐,因此两个mat4中没有额外的padding,大小即为2*sizeof(mat4)。

   GLuint UBOId;
    glGenBuffers(1, &UBOId);
    glBindBuffer(GL_UNIFORM_BUFFER, UBOId);
    glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_DYNAMIC_DRAW); // 预分配空间
    glBindBuffer(GL_UNIFORM_BUFFER, 0);
    glBindBufferRange(GL_UNIFORM_BUFFER, 0, UBOId, 0, 2 * sizeof(glm::mat4)); // 绑定点为0 

Step4: 更新UBO中的数据
  这里使用 glBufferSubData 更新UBO中数据,例如更新视变换矩阵如下:

 glm::mat4 view = camera.getViewMatrix(); // 视变换矩阵
glBindBuffer(GL_UNIFORM_BUFFER, UBOId);
glBufferSubData(GL_UNIFORM_BUFFER,      sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));
    glBindBuffer(GL_UNIFORM_BUFFER, 0); 

通过上面的步骤,我们完成了着色器中unifrom block和UBO的连接,实现了投影矩阵和视变换矩阵在4个着色器之间的共享,绘制4个立方体如下图所示:


layout std140

 

验证ExampleBlock

  这里在着色器中添加一段代码测试下上面那个复杂的ExampleBlock的内容,我们在主程序中设置boolean变量为true,在着色器中添加一个判断,如果boolean为true,则输出白色立方体:

   if(boolean)
    {
      color = vec4(1.0, 1.0, 1.0, 1.0);
    } 

最终显示获得了4个全是白色的立方体,效果如下:


四个白色立方体

这就验证了上述计算出那个复杂ExampleBlock的大小为152,boolean变量位移偏量为144是正确的。
 

layout shared

  同 std140 内存布局方式不一样,shared 方式的内存布局依赖于具体实现,因此无法提前根据某种字节对齐规范计算出 UBO 中变量的位移偏量和整体大小,因此在使用 shared 方式时,我们需要多次利用 OpenGL 的函数来查询 UBO 的信息。

  这里在着色器中定义一个用于混合颜色的uniform block:

#version 330 core
// 使用默认shared​方式的UBO
uniform mixColorSettings {
    vec4  anotherColor;
    float mixValue;
};
out vec4 color;
void main()
{
    color = mix(vec4(0.0, 0.0, 1.0, 1.0), anotherColor, mixValue);
} 

  在出程序中首先查询UBO整体大小,预分配空间:

GLuint colorUBOId;
glGenBuffers(1, &colorUBOId);
glBindBuffer(GL_UNIFORM_BUFFER, colorUBOId);
// 获取UBO大小 因为定义相同 只需要在一个shader中获取大小即可
GLint blockSize;
glGetActiveUniformBlockiv(redShader.programId, redShaderIndex,
    GL_UNIFORM_BLOCK_DATA_SIZE, &blockSize);
glBufferData(GL_UNIFORM_BUFFER, blockSize, NULL, GL_DYNAMIC_DRAW); // 预分配空间
glBindBuffer(GL_UNIFORM_BUFFER, 0);
glBindBufferBase(GL_UNIFORM_BUFFER, 1, colorUBOId); // 绑定点为1 

  然后,通过查询UBO中成员变量的索引和位移偏量来设置变量值:

   // 通过查询获取uniform buffer中各个变量的索引和位移偏量
const GLchar* names[] = {
    "anotherColor", "mixValue"
};
GLuint indices[2];
glGetUniformIndices(redShader.programId, 2, names, indices);
GLint offset[2];
glGetActiveUniformsiv(redShader.programId, 2, indices, GL_UNIFORM_OFFSET, offset);
// 使用获取的位移偏量更新数据
glm::vec4 anotherColor = glm::vec4(0.0f, 1.0f, 1.0f, 1.0f);
GLfloat mixValue = 0.5f;
glBindBuffer(GL_UNIFORM_BUFFER, colorUBOId);
glBufferSubData(GL_UNIFORM_BUFFER, offset[0], sizeof(glm::vec4), glm::value_ptr(anotherColor));
glBufferSubData(GL_UNIFORM_BUFFER, offset[1], sizeof(glm::vec4), &mixValue);
glBindBuffer(GL_UNIFORM_BUFFER, 0); 

和上面std140定义的uniform block一起工作,产生的混合颜色效果如下图所示:


混合颜色

从上面可以看到,使用shared布局时,当变量较多时,这种查询成员变量索引和位移偏量的工作显得比较麻烦。

 

shader storage buffer object

  Shader Storage Buffer Object 也是一种缓冲区对象,用于存储与检索着色器语言的数据,简称 SSBO,类似于UBO。存储 SSBO 的缓冲区对象绑定到 SSBO 独立的绑定点。
– SSBO 的容量大的多,通常的 UBO 的大小可达到 64KB 左右(可能更大),而 SSBO 的大小支持最小也有 128MB,大多数实现可允许分配大小达到GPU的内存极限,也就是 GB 左右的大小。
– GLSL 访问 SSBO 是可以写入数据的,而且支持原子操作;而 UBO 是无法被 GLSL 修改的。 由于 SSBO 支持写入数据,而且内存访问不连续,所以必须考虑同步问题。
– SSBO 支持可变存储,在运行期决定 block 的大小;而 UBO 必须在编译期就确定对象大小。因此 SSBO 内可有任意长度的数组,数组的实际大小基于缓存区的范围。
– SSBO 更慢。因为 SSBO 的数据存放在显卡的全局内存中,UBO 数据存放在显卡常量内存中,而显卡对常量内存的访问速度比全局内存快得多。
 

SSBO 使用

  SSBO 的使用与 UBO 大同小异。定义如下所示,采用内存布局方式为 std430,绑定点为1。std430 内存布局是随同 SSBO 新加的,目前只有 shader storage block 可以使用。SSBO 支持 UBO 的三种格式。下文会再次介绍。

layout (std430, binding=1) buffer shader_storage_block_data
{
    vec3 uLightDirectionE;
    vec3 uMaterialAmbient;
    vec3 uMaterialDiffuse;
    vec3 uLightAmbient;
    vec3 uLightDiffuse;
}; 

  SSBO 的初始化和 UBO 一样。

    m_ShaderStorageBlockData.uLightDirectionE = glm::vec4(1, 1, 1, 0);
    m_ShaderStorageBlockData.uMaterialAmbient = glm::vec4(0.3, 0.3, 0.3, 0);
    m_ShaderStorageBlockData.uMaterialDiffuse = glm::vec4(0.9, 0.9, 0.9, 0);
    m_ShaderStorageBlockData.uLightAmbient = glm::vec4(0.6, 0.6, 0.6, 0);
    m_ShaderStorageBlockData.uLightDiffuse = glm::vec4(0.9, 0.9, 0.9, 0);

    m_ShaderStorageBlockIndex = glGetProgramResourceIndex(m_pEffect->getProgramID(0), GL_SHADER_STORAGE_BLOCK, "shader_storage_block_data");
    GLint SSBOBinding=0, BlockDataSize = 0;
    glGetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &SSBOBinding);
    glGetIntegerv(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &BlockDataSize);

    glGenBuffers(1, &m_SSBO);
    glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_SSBO);
    glBufferData(GL_SHADER_STORAGE_BUFFER, sizeof(shader_storage_block_data), &m_ShaderStorageBlockData, GL_DYNAMIC_DRAW);
    glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); 

  然后建立连接。建立 buffer block 和 shader storage buffer 的连接,也是通过绑定点完成。如下所示,注意 SSBO 的绑定点与 UBO 是不共享的,也就是说 SSBO 可以绑定到 0 位置,UBO 也可以绑定到 0 位置,两者不会相互影响。

    GLuint BindingPointIndex = 1;
    glBindBufferBase(GL_SHADER_STORAGE_BUFFER, BindingPointIndex, m_SSBO);
    glShaderStorageBlockBinding(m_pEffect->getProgramID(0), m_ShaderStorageBlockIndex, BindingPointIndex);
    m_pMesh->render();
    glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); 

  其实SSBO和UBO用法类似,所以参照 uniform buffer object 即可。
 

std430

  std430 内存布局中,常用标量 int,float,bool 也要求4字节对齐,4 字节也被作为一个基础值N。但也有一些不同,如下表所示。

类型 对齐基数(base alignment)
标量,例如 int bool 每个标量对齐基数为N
vector 2N 或者 4N, vec3的基数为4N.
标量或者vector的数组 每个元素的大小和元素大小相同,不会四舍五入为四分量向量
矩阵 以列向量存储, 列向量基数等于vec4的基数.
结构体 元素按之前规则,同时整体大小填充为vec4的对齐基数

  总的来说,std430 和 std140 非常相似,除了对数组内存对齐做了一些额外的优化。比如说 float 数组,在 std140 中会将每一个 float 扩充为 4 字节,其中四分之三是为了对齐产生的消耗;而 std430 结构和 C 语言类似,可以使 float 数组紧密结合,同样的空间可以比 std140 存储 4 倍的 float。但是需要注意,std430 只能用于 SSBO

 

最后的说明


  本节学习了interface block、UBO、SSBO 概念。部分函数的具体使用未在此展开介绍,需要的可以自行参考OpenGL文档。关于 std140 和 std430 的 offset 计算方法,以及使用 shared 方式时通过查询获取 UBO 和 SSBO 整体大小、索引和偏移量的方法,需要尽量掌握。

Tags :

About the Author

1 thought on “interface block 及 UBO、SSBO 详解

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注