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
vector2N 或者 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
vector2N 或者 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 整体大小、索引和偏移量的方法,需要尽量掌握。


转载请带上本文永久固定链接:http://www.gleam.graphics/interface-block-ubo-ssbo.html

About the Author

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

发表评论

电子邮件地址不会被公开。

Bitnami