8 - 进阶GLSL

本文将讨论有关GLSL的一些高级知识,例如内建变量、uniform缓冲对象等。

GLSL的内建变量

对于GLSL的所有内建变量,可以在这里查看。

顶点着色器

gl_Position

顶点着色器的裁剪空间输出位置向量

gl_PointSize

图元里边有个叫做GL_POINTS的,就是画点。可以通过OpenGL的glPointSize()来设置渲染出点的大小,但也能在顶点着色器中修改这个值。

要想启用该功能,得先启用GL_PROGRAM_POINT_SIZE

glEnable(GL_PROGRAM_POINT_SIZE);

然后修改顶点着色器的gl_PointSize

gl_PointSize = gl_Position.z * 5;

这里将点的大小设置为深度值的5倍,也就是离得越远画出来的点越大:

gl_VertexID

和前面两个 输出变量 不同,这个是 输入变量,我们只能对它进行读取。使用glDrawElements()进行索引渲染时,它存储正在绘制顶点的索引;使用glDrawArrays()直接进行顶点渲染时,它存储从渲染开始到现在已处理的顶点数量。

片段着色器

gl_FragCoord

之前深度测试的时候,我们知道该变量的z分量就是对应片段的深度值。其实该变量的x和y分量表示的是 片段的窗口空间(Window-space)坐标,原点位于窗口左下角。

可以对它进行一个简单的实验,将屏幕分成两部分,左侧红色,右侧绿色:

void main()
{   // 800x600的窗口          
    if(gl_FragCoord.x < 400)
        FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    else
        FragColor = vec4(0.0, 1.0, 0.0, 1.0);        
}

gl_FrontFacing

如果没有开启面剔除(开了就看不到背向面了),该输入变量会告诉我们当前片段是属于正向面的一部分还是属于背向面的一部分,它的数据类型是bool

可以对它进行一个简单的实验,正向面背向面为不同材质(这里直接搬教程了,懒得搞了):

gl_FragDepth

它是一个输出变量,可以让我们修改片段的深度值。但如果我们对片段深度值进行修改,就会 禁用所有的提前深度测试 ,因为OpenGL无法在片段着色器运行前得知片段将拥有的深度值,导致性能大量损失。

但从 OpenGL 4.2起,可以用 深度条件(Depth Condition)重新声明gl_FragDepth变量:

layout (depth_<condition>) out float gl_FragDepth;

它可以让OpenGL假设当前片段深度值具有什么条件,然后根据此条件进行部分提前深度测试。条件如下:

条件描述
any默认值。提前深度测试是禁用的,你会损失很多性能
greater你只能让深度值比gl_FragCoord.z更大
less你只能让深度值比gl_FragCoord.z更小
unchanged如果你要写入gl_FragDepth,你将只能写入gl_FragCoord.z的值

例如,可以用greater条件写入深度值,并让OpenGL假设你写入比当前片段深度值更大的值:

#version 420 core // 注意GLSL的版本!
out vec4 FragColor;
layout (depth_greater) out float gl_FragDepth;

void main()
{             
    FragColor = vec4(1.0);
    gl_FragDepth = gl_FragCoord.z + 0.1;
}  

接口块

当着色器程序变得很大的时候,看着一堆in, out变量很烦,可以用 接口块(Interface Block) 来组合这些变量:

// -------- vert ---------
// 声明
out VS_OUT
{
    vec2 TexCoords;
    // ...
} vs_out;
// 使用
vs_out.TexCoords = ...
// -----------------------

// -------- frag ---------
// 声明
in VS_OUT
{
    vec2 TexCoords;
} fs_in;
// 使用
xxxx = fs_in.TexCoords;
// -----------------------

Uniform缓冲对象

在之前的代码中,我们老是对不同的着色器设置重复的uniform变量,这太过繁琐了。OpenGL为我们提供了一个叫做 Uniform缓冲对象(UBO)的工具,它允许我们定义可以在多个着色器中使用的一系列相同的全局变量。

Uniform块及其布局

首先介绍 Uniform块,类似结构体,但它里边的变量可以直接访问,不需要加块名作为前缀。一个使用了uniform块的顶点着色器如下:

#version 330 core
layout (location = 0) in vec3 aPos;

layout (std140) uniform Matrices
{
    mat4 projection;
    mat4 view;
};

uniform mat4 model;

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

其实,Uniform块也有它的内存布局,接下来以一个基本上啥类型都有的Uniform块简要谈谈(详细内容可以在这里找到):

layout (std140) uniform ExampleBlock
{
    float value;
    vec3  vector;
    mat4  matrix;
    float values[3];
    bool  boolean;
    int   integer;
};

GLSL默认会使用 共享(Shared)内存布局,为了优化,GLSL会对uniform变量的位置进行变动,因此我们并不知道每个uniform变量在uniform块的偏移量,得用glGetUniformIndices()查询,比较麻烦。

我们在这里使用 std140 布局,它声明了每个变量的偏移量都是由一系列规则所决定的,它显式声明了每个变量类型的内存布局,因此我们可以手动计算出每个变量的偏移量:

  • 每个变量都有一个 基准对齐量(Base Alignment),由std140给出。
  • 每个变量还要计算它的 对齐偏移量(Aligned Offset),它是一个变量从块起始位置的字节偏移量。一个变量的对其偏移量必须等于基准对齐量的倍数

std140布局常见的规则如下,这里每4个字节将会用一个N来表示:

类型规则
标量(int, bool等)每个标量的基准对齐量为N。
向量2N或者4N。这意味着vec3的基准对齐量为4N。
标量或向量的数组每个元素的基准对齐量与vec4的相同。
矩阵储存为列向量的数组,每个向量的基准对齐量与vec4的相同。
结构体等于所有元素根据规则计算后的大小,但会填充到vec4大小的倍数。

接下来计算一下上边ExampleBlock内各个成员的对齐偏移量:

layout (std140) uniform ExampleBlock
{
                     // 基准对齐量       // 对齐偏移量
    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              // 96  (values[0])
                     // 16              // 112 (values[1])
                     // 16              // 128 (values[2])
    bool boolean;    // 4               // 144
    int integer;     // 4               // 148
}; 

可以发现还是有很多地方出现内存空隙的,这需要设计数据结构的我们好好优化。

还有一个 紧凑(Packed) 布局,它是非共享的,可鞥会优化掉一些uniform变量。

基本操作

创建一个UBO

可以用glGenBuffers()来创建一个uniform缓存对象,只需将其绑定到GL_UNIFORM_BUFFER

unsigned int UBO;
glGenBuffers(1, &UBO);
glBindBuffer(GL_UNIFORM_BUFFER, UBO);

不要忘了给它分配内存:

glBufferData(GL_UNIFORM_BUFFER, 152, NULL, GL_STATIC_DRAW); // 分配152字节的内存

最后记得解绑它:

glBindBuffer(GL_UNIFORM_BUFFER, 0);

uniform缓冲创建好了,该怎么让OpenGL知道哪个Uniform缓冲对应的是哪个Unifrom块呢,需要用到OpenGL上下文定义的 绑定点(Binding Point)

如图,可以先将不同的UBO链接至不同的绑定点上,然后告诉着色器里的Uniform块,应该找哪个绑定点即可。

绑定UBO和Uniform块

首先是Uniform块和绑定点之间的绑定,这里以上图Shader A的Lights作为示例:

在进行绑定之前,先需要了解什么是 Uniform块索引(Uniform Block Index)。它是着色器中已定义Uniform块的位置值索引,可以通过glGetUniformBlockIndex()来获取:

// 接收一个程序对象和Uniform块的名称
unsigned int lights_index = glGetUniformBlockIndex(shaderA.ID, "Lights");   

然后就可以通过glUniformBlockBinding()函数,将着色器的Uniform块绑定到一个特定的绑定点:

// 接收一个程序对象,一个Uniform块索引和要链接到的绑定点
glUniformBlockBinding(shaderA.ID, lights_index, 2);

注意我们需要对每个Uniform块重复这一步骤

OpenGL 4.2版本起,可以在着色器中做这件事:

layout(std140, binding = 2) uniform Lights { ... };

有两种方法可以完成UBO和绑定点之间的绑定,这里以uboLights为例:

  • glBindBufferBase():它需要一个目标,一个绑定点索引和一个UBO作为参数,将Uniform块和UBO单独绑定起来

    glBindBufferBase(GL_UNIFORM_BUFFER, 2, uboExampleBlock); 
  • glBindBufferRange():它除了需要上述参数外,还需要一个附加的偏移量和大小参数,将多个不同的Uniform块和UBO绑定

    glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 152);

更新UBO的数据

可以使用glBufferSubData()来更新UBO中的数据:

glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
int b = true; // GLSL中的bool是4字节的,所以我们将它存为一个integer
glBufferSubData(GL_UNIFORM_BUFFER, 144, 4, &b); 
glBindBuffer(GL_UNIFORM_BUFFER, 0);

实践:画一个3D微软图标

接下来我们准备体现出UBO的优势,用一个顶点着色器和四个片段着色器来画一个3D微软图标。沿用上边提到的顶点着色器,然后为这四个正方体创建不同的片段着色器以输出4种颜色。

首先,将所有着色器的Uniform块绑定到绑定点0上:

unsigned int uniBlkIdxRed = glGetUniformBlockIndex(microRShader.ID, "Matrices");
unsigned int uniBlkIdxGreen = glGetUniformBlockIndex(microGShader.ID, "Matrices");
unsigned int uniBlkIdxBlue = glGetUniformBlockIndex(microBShader.ID, "Matrices");
unsigned int uniBlkIdxYellow = glGetUniformBlockIndex(microYShader.ID, "Matrices");
glUniformBlockBinding(microRShader.ID, uniBlkIdxRed, 0);
glUniformBlockBinding(microGShader.ID, uniBlkIdxGreen, 0);
glUniformBlockBinding(microBShader.ID, uniBlkIdxBlue, 0);
glUniformBlockBinding(microYShader.ID, uniBlkIdxYellow, 0);

然后,创建一个UBO并为其分配空间后,将其绑定到绑定点0:

unsigned int uboMatrices;
glGenBuffers(1, &uboMatrices);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));

接着给UBO填充数据:

glm::mat4 uboProjection = glm::perspective(glm::radians(45.0f), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(uboProjection));
glm::mat4 uboView = camera.GetViewMatrix();
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(uboView));

最后就能画4个方块了:

Uniform缓冲对象比起独立的uniform有很多好处:

  1. 一次设置很多uniform会比一个一个设置多个uniform要快很多。
  2. 比起在多个着色器中修改同样的uniform,在Uniform缓冲中修改一次会更容易一些。
  3. 如果使用Uniform缓冲对象的话,你可以在着色器中使用更多的uniform。OpenGL限制了它能够处理的uniform数量,这可以通过GL_MAX_VERTEX_UNIFORM_COMPONENTS来查询。当使用Uniform缓冲对象时,最大的数量会更高。所以,当你达到了uniform的最大数量时(比如再做骨骼动画(Skeletal Animation)的时候),你总是可以选择使用Uniform缓冲对象。

参考资料

  • 高级GLSL - LearnOpenGL CN (learnopengl-cn.github.io)