教程地址
【中文版】高级GLSL
【英文版】Advanced GLSL
学习记录
上篇文章中我们介绍了 GLSL 的内建变量,并简单的实现了几个例子。这篇文章中我们将介绍接口块(Interface Block)与 Uniform 缓冲对象(Uniform Buffer Object)。
接口块实际上就是 GLSL 中的结构体,我们使用它将多个同类变量放在一个结构中进行传递,定义如下:
1 | Block_Name { |
Block_Name 是接口块在传递过程中使用的名字,如果我们需要将数据从顶点着色器发送到片段着色器,那么必须保证 Block_Name 相同,这和普通的变量是相同的。举例如下:
1 | //顶点着色器中 |
1 | //片段着色器中 |
接口块其实就是个结构体嘛!
下来介绍 Uniform 缓冲对象:
OpenGL 为我们提供了一个叫做 Uniform 缓冲对象(Uniform Buffer Object)的工具,它允许我们定义一系列在多个着色器中相同的全局Uniform变量。当使用Uniform缓冲对象的时候,我们只需要设置相关的uniform一次。当然,我们仍需要手动设置每个着色器中不同的uniform。并且创建和配置Uniform缓冲对象会有一点繁琐。
因为Uniform缓冲对象仍是一个缓冲,我们可以使用glGenBuffers来创建它,将它绑定到GL_UNIFORM_BUFFER缓冲目标,并将所有相关的uniform数据存入缓冲。
如果说接口块是着色器之间传递数据的结构体,那么 Uniform Buffer Object 则是 OpenGL 代码与 着色器代码之间的结构体了。在我们之前的很多代码中,我们大量使用了 Uniform 变量,比如:
1 | uniform mat4 model; |
而在这些代码中,一般情况下我们需要为每一个模型的着色器设置一次这个,然后再绘制的时候再大量的使用 glUniformMatrix4fv 方法为其赋值,所以经常看到这样的代码(我们使用了 Shader 类):
1 | //绘制第一个模型 |
可以看到,仅仅是 model 不同,我们就需要为每一个着色器赋值一遍,相对于而言 projection 和 view 几乎是不会因模型不同而异的。如果有几十个变量,几千个模型,那么我们就需要 几十*几千 行这样的冗余代码。而现在有了 UBO (Uniform Buffer Object),我们可以将其归类,统一绑定。
在着色器代码中我们定义 UBO :
1 | layout (std140) uniform VP{ |
我们将多个模型都相同的数据绑定在一起(关于 std140 等下解释),在 OpenGL 代码中,需要创建相应的 UBO 缓冲:
1 | unsigned int uboBlock; |
Uniform 缓冲的构建与其他缓冲相同,不过我们并没有进行初始化数据而是设为 nullptr ,size 我们设置为两个 mat4 的大小(我们 uniform 中有两个 mat4 变量)。
现在,每当我们需要对缓冲更新或者插入数据,我们都会绑定到uboExampleBlock,并使用glBufferSubData来更新它的内存。我们只需要更新这个Uniform缓冲一次,所有使用这个缓冲的着色器就都使用的是更新后的数据了。但是,如何才能让OpenGL知道哪个Uniform缓冲对应的是哪个Uniform块呢?
在OpenGL上下文中,定义了一些绑定点(Binding Point),我们可以将一个Uniform缓冲链接至它。在创建Uniform缓冲之后,我们将它绑定到其中一个绑定点上,并将着色器中的Uniform块绑定到相同的绑定点,把它们连接到一起。下面的这个图示展示了这个:
你可以看到,我们可以绑定多个Uniform缓冲到不同的绑定点上。因为着色器A和着色器B都有一个链接到绑定点0的Uniform块,它们的Uniform块将会共享相同的uniform数据,uboMatrices,前提条件是两个着色器都定义了相同的Matrices Uniform块。
为了将Uniform块绑定到一个特定的绑定点中,我们需要调用glUniformBlockBinding函数,它的第一个参数是一个程序对象,之后是一个Uniform块索引和链接到的绑定点。Uniform块索引(Uniform Block Index)是着色器中已定义Uniform块的位置值索引。这可以通过调用glGetUniformBlockIndex来获取,它接受一个程序对象和Uniform块的名称。
我们将我们的着色器中的 VP 缓冲绑定到绑定点 0 上(我们可以将多个着色器的 VP 缓冲对象绑定到一个绑定点,只要他们 VP 的内容相同,这正是我们的目的):
1 | const auto vpIndex = glGetUniformBlockIndex(glslShader.ID, "VP"); //获取着色器中的 uniform 缓冲对象位置索引 |
在 GLSL 420 (即 OpenGL 4.2版本)之后,支持直接在定义 Uniform 缓冲对象的时候绑定:
1 | layout(std140, binding = 2) uniform VP { |
现在将 OpenGL 代码中的 uboBlock 缓冲对象也绑定到 0 绑定点:
1 | glBindBufferBase(GL_UNIFORM_BUFFER, 0, uboBlock); //将 uboBlock 对象(OpenGL中的缓冲对象)绑定到 0 绑定点 |
这样绑定之后,我们可以使用 uboBlock 对象来更新着色器中的 Uniform 缓冲对象。我们使用之前介绍过的缓冲部分填充函数为其添加数据:
1 | glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), &projection); |
正常使用。我们现在创建四个模型与四个着色器程序,每个着色器程序里仅仅是最后给的颜色不同,之后实现绑定,最后渲染:
1 | const auto projection = glm::perspective(glm::radians(90.0f), static_cast<float>(WIDTH) / static_cast<float>(HEIGHT), 0.1f, 100.0f); |
效果如下:
最后,我们来解释下我们着色器代码中 stb140 的含义:std140 代表着我们使用140布局,这是 uniform 内存布局方式的一种。
Uniform块的内容是储存在一个缓冲对象中的,它实际上只是一块预留内存。因为这块内存并不会保存它具体保存的是什么类型的数据,我们还需要告诉OpenGL内存的哪一部分对应着着色器中的哪一个uniform变量。
假设着色器中有以下的这个Uniform块:
1 | layout (std140) uniform ExampleBlock |
我们需要知道的是每个变量的大小(字节)和(从块起始位置的)偏移量,来让我们能够按顺序将它们放进缓冲中。每个元素的大小都是在OpenGL中有清楚地声明的,而且直接对应C++数据类型,其中向量和矩阵都是大的float数组。OpenGL没有声明的是这些变量间的间距(Spacing)。这允许硬件能够在它认为合适的位置放置变量。比如说,一些硬件可能会将一个vec3放置在float边上。不是所有的硬件都能这样处理,可能会在附加这个float之前,先将vec3填充(Pad)为一个4个float的数组。这个特性本身很棒,但是会对我们造成麻烦。
默认情况下,GLSL会使用一个叫做共享(Shared)布局的Uniform内存布局,共享是因为一旦硬件定义了偏移量,它们在多个程序中是共享并一致的。使用共享布局时,GLSL是可以为了优化而对uniform变量的位置进行变动的,只要变量的顺序保持不变。因为我们无法知道每个uniform变量的偏移量,我们也就不知道如何准确地填充我们的Uniform缓冲了。我们能够使用像是glGetUniformIndices这样的函数来查询这个信息,但这超出本节的范围了。
虽然共享布局给了我们很多节省空间的优化,但是我们需要查询每个uniform变量的偏移量,这会产生非常多的工作量。通常的做法是,不使用共享布局,而是使用std140布局。std140布局声明了每个变量的偏移量都是由一系列规则所决定的,这显式地声明了每个变量类型的内存布局。由于这是显式提及的,我们可以手动计算出每个变量的偏移量。
每个变量都有一个基准对齐量(Base Alignment),它等于一个变量在Uniform块中所占据的空间(包括填充量(Padding)),这个基准对齐量是使用std140布局的规则计算出来的。接下来,对每个变量,我们再计算它的对齐偏移量(Aligned Offset),它是一个变量从块起始位置的字节偏移量。一个变量的对齐字节偏移量必须等于基准对齐量的倍数。
布局规则的原文可以在OpenGL的Uniform缓冲规范这里找到,但我们将会在下面列出最常见的规则。GLSL中的每个变量,比如说int、float和bool,都被定义为4字节量。每4个字节将会用一个
N
来表示。
类型 布局规则 标量,比如int和bool 每个标量的基准对齐量为N。 向量 2N或者4N。这意味着vec3的基准对齐量为4N。 标量或向量的数组 每个元素的基准对齐量与vec4的相同。 矩阵 储存为列向量的数组,每个向量的基准对齐量与vec4的相同。 结构体 等于所有元素根据规则计算后的大小,但会填充到vec4大小的倍数。 和OpenGL大多数的规范一样,使用例子就能更容易地理解。我们会使用之前引入的那个叫做ExampleBlock的Uniform块,并使用std140布局计算出每个成员的对齐偏移量:
1 | layout (std140) uniform ExampleBlock |
通过在Uniform块定义之前添加
layout (std140)
语句,我们告诉OpenGL这个Uniform块使用的是std140布局。除此之外还可以选择两个布局,但它们都需要我们在填充缓冲之前先查询每个偏移量。我们已经见过shared
布局了,剩下的一个布局是packed
。当使用紧凑(Packed)布局时,是不能保证这个布局在每个程序中保持不变的(即非共享),因为它允许编译器去将uniform变量从Uniform块中优化掉,这在每个着色器中都可能是不同的。