当前位置: 首页 > article >正文

LearnOpenGL——高级OpenGL(下)

教程地址:简介 - LearnOpenGL CN


高级数据

  • 原文链接:高级数据 - LearnOpenGL CN

在OpenGL中,我们长期以来一直依赖缓冲来存储数据。本节将深入探讨一些操作缓冲的高级方法。

OpenGL中的缓冲本质上是一个管理特定内存块的对象,它本身没有更多的功能。只有当我们将其绑定到一个缓冲目标(Buffer Target)时,才能赋予其具体的用途。例如,当一个缓冲被绑定到 GL_ARRAY_BUFFER 时,它用于存储顶点数组;而当绑定到 GL_ELEMENT_ARRAY_BUFFER 时,则用于索引数据。OpenGL为每个目标维护一个独立的缓冲,并根据目标的不同以相应的方式处理它们。


为了填充缓冲对象所管理的内存,我们通常使用 glBufferData 函数:

  • 原型:`void glBufferData(GLenum target, GLsizeiptr size, const void *data, GLenum usage);
  • 作用:分配指定大小的GPU内存并将数据添加进去。如果 data 参数设置为NULL,则只分配内存而不填充,这在需要预留特定大小内存时非常有用。
  • 示例glBufferData(GL_ARRAY_BUFFER, sizeof(skyboxVertices), &skyboxVertices, GL_STATIC_DRAW);

另一种填充缓冲的方法是使用glBufferSubData

  • 原型void glBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, const void *data);
  • 作用:更新现有缓冲的特定区域。通过提供偏移量,可以从指定位置开始填充或更新缓冲的部分内容。注意,在调用此函数之前必须先确保有足够的已分配内存(调用glBufferData)。
  • 示例glBufferSubData(GL_ARRAY_BUFFER, 24, sizeof(data), &data); // 范围: [24, 24 + sizeof(data)]

若要直接请求缓冲区的内存指针进行数据复制,可以使用 glMapBufferglUnmapBuffer

  • glMapBuffer 原型void* glMapBuffer(GLenum target, GLenum access);
  • 作用:请求并返回当前绑定缓冲的内存指针,允许用户直接操作该内存。在调用此函数之前必须先确保有足够的已分配内存(调用glBufferData)。
  • glUnmapBuffer 原型GLboolean glUnmapBuffer(GLenum target);
  • 作用:通知OpenGL已完成对映射缓冲的操作,解除映射并确保数据成功写入缓冲。在解除映射(Unmapping)之后,指针将会不再可用,并且如果OpenGL能够成功将您的数据映射到缓冲中,这个函数将会返回GL_TRUE。
  • 示例
    float data[] = {
      0.5f, 1.0f, -0.35f
      ...
    };
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    // 获取指针
    void *ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
    // 复制数据到内存
    memcpy(ptr, data, sizeof(data));
    // 记得告诉OpenGL我们不再需要这个指针了
    glUnmapBuffer(GL_ARRAY_BUFFER);
    

如果要直接映射数据到缓冲,而不事先将其存储到临时内存中,glMapBuffer这个函数会很有用。比如说,你可以从文件中读取数据,并直接将它们复制到缓冲内存中。


分批顶点属性

通过使用 glVertexAttribPointer,我们能够指定顶点数组缓冲内容的属性布局。在顶点数组缓冲中,我们对属性进行了交错(Interleave)处理,也就是说,我们将每一个顶点的位置、法线和/或纹理坐标紧密放置在一起。既然我们现在已经对缓冲有了更多的了解,我们可以采取另一种方式

  • glVertexAttribPointer 原型void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void* pointer);

我们可以做的是,将每一种属性类型的向量数据打包(Batch)为一个大的区块,而不是对它们进行交错储存。与交错布局123 123 123 123不同,我们将采用分批(Batched)的方式1111 2222 3333。

当从文件中加载顶点数据的时候,你通常获取到的是一个位置数组、一个法线数组和/或一个纹理坐标数组。我们需要花点力气才能将这些数组转化为一个大的交错数据数组。

使用分批的方式会是更简单的解决方案,我们可以很容易使用 glBufferSubData 函数实现:

float positions[] = { ... };
float normals[] = { ... };
float tex[] = { ... };
// 填充缓冲
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(positions), &positions);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions), sizeof(normals), &normals);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions) + sizeof(normals), sizeof(tex), &tex);

这样子我们就能直接将属性数组作为一个整体传递给缓冲,而不需要事先处理它们了。我们仍可以将它们合并为一个大的数组,再使用 glBufferData 来填充缓冲,但对于这种工作,使用 glBufferSubData 会更合适一点。

我们还需要更新顶点属性指针来反映这些改变:

// 注意`stride`参数等于顶点属性的大小,因为下一个顶点属性向量能在3个(或2个)分量之后找到。
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), 0);  
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)(sizeof(positions)));  
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)(sizeof(positions) + sizeof(normals)));

这给了我们设置顶点属性的另一种方法。使用哪种方法都是可行的,它只是设置顶点属性的一种更整洁的方式。但是,推荐使用交错方法,因为这样一来,每个顶点着色器运行时所需要的顶点属性在内存中会更加紧密对齐。

在图形渲染过程中,当顶点着色器处理一个顶点时,它通常需要访问该顶点的所有属性(如位置、颜色、法线等)。如果这些属性是交错存储的,那么它们在内存中将是连续的,这意味着一次内存访问就可以获取到一个顶点所需的所有信息。相反,如果是分批存储,则可能需要多次内存访问来收集同一个顶点的所有属性,这会增加内存延迟并降低性能。

复制缓冲

当你的缓冲已经填充好数据之后,你可能会想与其它的缓冲共享其中的数据,或者想要将缓冲的内容复制到另一个缓冲当中。glCopyBufferSubData能够让我们相对容易地从一个缓冲中复制数据到另一个缓冲中。这个函数的原型如下:

void glCopyBufferSubData(GLenum readtarget, GLenum writetarget, GLintptr readoffset,
                         GLintptr writeoffset, GLsizeiptr size);

readtargetwritetarget 参数需要填入复制源和复制目标的缓冲目标。比如说,我们可以将 VERTEX_ARRAY_BUFFER 缓冲复制到 VERTEX_ELEMENT_ARRAY_BUFFER 缓冲,分别将这些缓冲目标设置为读和写的目标。当前绑定到这些缓冲目标的缓冲将会被影响到。


但如果我们想读写数据的两个不同缓冲都为顶点数组缓冲该怎么办呢?我们不能同时将两个缓冲绑定到同一个缓冲目标上。正是出于这个原因,OpenGL提供给我们另外两个缓冲目标,叫做 GL_COPY_READ_BUFFERGL_COPY_WRITE_BUFFER。我们接下来就可以将需要的缓冲绑定到这两个缓冲目标上,并将这两个目标作为 readtargetwritetarget 参数。

接下来 glCopyBufferSubData 会从 readtarget 中读取 size 大小的数据,并将其写入 writetarget 缓冲的 writeoffset 偏移量处。下面这个例子展示了如何复制两个顶点数组缓冲:

glBindBuffer(GL_COPY_READ_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

我们也可以只将 writetarget 缓冲绑定为新的缓冲目标类型之一:

float vertexData[] = { ... };
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_ARRAY_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

有了这些关于如何操作缓冲的额外知识,我们已经能够以更有意思的方式使用它们了。当你越深入OpenGL时,这些新的缓冲方法将会变得更加有用。在下一节中,在我们讨论Uniform缓冲对象(Uniform Buffer Object)时,我们将会充分利用 glBufferSubData


高级GLSL

  • 原文链接:高级GLSL - LearnOpenGL CN

这一小节并不会向你展示非常先进非常酷的新特性,也不会对场景的视觉质量有显著的提高。但是,这一节会或多或少涉及GLSL的一些有趣的地方以及一些很棒的技巧,它们可能在今后会帮助到你。简单来说,它们就是在组合使用OpenGL和GLSL创建程序时的一些最好要知道的东西,和一些会让你生活更加轻松的特性

我们将会讨论一些有趣的内建变量(Built-in Variable),管理着色器输入和输出的新方式以及一个叫做Uniform缓冲对象(Uniform Buffer Object)的有用工具。

GLSL的内建变量

着色器需要数据才能工作。我们已经学会了使用顶点属性、uniform 和采样器来传递数据。除此之外,GLSL 还定义了一些以 gl_ 为前缀的变量,它们提供了更多读取/写入数据的方式。我们已经在前面的教程中接触过其中的两个:顶点着色器的输出向量 gl_Position 和片段着色器的 gl_FragCoord

我们将讨论一些有趣的 GLSL 内置输入和输出变量,并解释它们如何帮助你。请注意,我们不会讨论 GLSL 中存在的所有内置变量。如果你想了解所有内置变量,请查看 OpenGL 的 wiki。

顶点着色器变量

gl_Position

我们已经见过 gl_Position 了,它是顶点着色器的裁剪空间输出位置向量。如果你想在屏幕上显示任何东西,在顶点着色器中设置 gl_Position 是必须的步骤。除此之外,它没有其他功能。

gl_PointSize

我们可以使用 GL_POINTS 图元来渲染点。每个顶点都将作为一个单独的点被渲染。我们可以使用 glPointSize 函数来设置点的大小,也可以在顶点着色器中修改点的大小

GLSL 定义了一个名为 gl_PointSize 的输出变量,它是一个 float 变量,用于设置点的宽度与高度(以像素为单位)。在顶点着色器中修改点的大小,可以为每个顶点设置不同的值。

默认情况下,在顶点着色器中修改点大小的功能是禁用的。如果需要启用它,你需要启用 OpenGL 的 GL_PROGRAM_POINT_SIZE

glEnable(GL_PROGRAM_POINT_SIZE);

一个简单的例子是将点的大小设置为裁剪空间位置的 z 值,也就是顶点与观察者的距离。点的大小会随着观察者与顶点距离的增加而增大。

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

结果就是,当我们远离这些点时,它们会变得更大:

PixPin_2025-02-16_22-29-50.gif

你可以想到,对每个顶点使用不同的点大小,会在粒子生成之类的技术中很有意思。

gl_VertexID

gl_Positiongl_PointSize 都是输出变量,因为它们的值作为顶点着色器的输出被读取。我们可以对它们进行写入来改变渲染结果。除此之外,顶点着色器还提供了一个有趣的输入变量,我们只能读取它,它就是 gl_VertexID

整型变量 gl_VertexID 存储了当前绘制顶点的 ID。当进行索引渲染(使用 glDrawElements)时,这个变量存储了当前绘制顶点的索引值。当不使用索引进行绘制(使用 glDrawArrays)时,这个变量存储了从渲染调用开始已处理的顶点数量。

片段着色器变量

在片段着色器中,我们也能访问到一些有趣的变量。GLSL提供给我们两个有趣的输入变量:gl_FragCoordgl_FrontFacing

gl_FragCoord

我们之前在讨论深度测试时多次提到过 gl_FragCoord,因为它的 z 分量等于对应片段的深度值。但是,我们也可以使用它的 x 和 y 分量来实现一些有趣的效果。

gl_FragCoord 的 x 和 y 分量是片段的窗口空间(Window-space)坐标,其原点为窗口的左下角。如果我们使用 glViewport 设定了一个 800x600 的窗口,那么片段窗口空间坐标的 x 分量将在 0 到 800 之间,y 分量在 0 到 600 之间。

通过利用片段着色器,我们可以根据片段的窗口坐标,计算出不同的颜色。gl_FragCoord 的一个常见用法是用于对比不同片段计算的视觉输出效果,这在技术演示中经常可以看到。例如,我们可以将屏幕分成两部分,在窗口的左侧渲染一种输出,在窗口的右侧渲染另一种输出。以下示例片段着色器会根据窗口坐标输出不同的颜色:

void main() {             
    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);        
}

由于窗口的宽度是 800,当一个像素的 x 坐标小于 400 时,它一定在窗口的左侧,所以我们给它一个不同的颜色。

PixPin_2025-02-16_22-42-23.gif

我们现在会计算出两个完全不同的片段着色器结果,并将它们显示在窗口的两侧。举例来说,你可以将它用于测试不同的光照技巧

gl_FrontFacing

片段着色器另一个很有意思的输入变量是 gl_FrontFacing。在面剔除教程中,我们提到 OpenGL 能够根据顶点的环绕顺序来决定一个面是正面还是背面。如果我们不(启用 GL_FACE_CULL )使用面剔除,那么 gl_FrontFacing 将会告诉我们当前片段是属于正面的一部分还是背面的一部分。例如,我们可以对正面计算出不同的颜色。

gl_FrontFacing 变量是一个布尔值,如果当前片段是正面的一部分,则为 true,否则为 false。例如,我们可以这样创建一个立方体,在内部和外部使用不同的纹理:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D frontTexture;
uniform sampler2D backTexture;

void main() {             
    if (gl_FrontFacing)
        FragColor = texture(frontTexture, TexCoords);
    else
        FragColor = texture(backTexture, TexCoords);
}

如果我们往箱子里面看,就能看到使用的是不同的纹理:

PixPin_2025-02-16_22-58-48.gif

注意,如果你开启了面剔除,你就看不到箱子内部的面了,所以现在再使用 gl_FrontFacing 就没有意义了。

gl_FragDepth

输入变量 gl_FragCoord 允许我们读取当前片段的窗口空间坐标,并获取其深度值。但是,它是一个只读(Read-only)变量。我们无法修改片段的窗口空间坐标,但实际上修改片段的深度值是可能的。GLSL 提供了一个名为 gl_FragDepth 的输出变量,我们可以使用它在着色器内设置片段的深度值。

要设置深度值,我们只需写入一个 0.0 到 1.0 之间的浮点值到输出变量即可:

gl_FragDepth = 0.0; // 这个片段现在的深度值为 0.0

如果着色器没有写入值到 gl_FragDepth,它会自动取用 gl_FragCoord.z 的值。

然而,由我们自己设置深度值有一个很大的缺点,只要我们在片段着色器中对 gl_FragDepth 进行写入,OpenGL 就会(像深度测试小节中讨论的那样)禁用所有的提前深度测试(Early Depth Testing)。它被禁用的原因是,OpenGL 无法在片段着色器运行之前得知片段将拥有的深度值,因为片段着色器可能会完全修改这个深度值。

在写入 gl_FragDepth 时,你就需要考虑到它所带来的性能影响。然而,从 OpenGL 4.2 起,我们仍可以对两者进行一定的调和,在片段着色器的顶部使用深度条件(Depth Condition)重新声明 gl_FragDepth 变量:

layout (depth_<condition>) out float gl_FragDepth;

condition可以为下面的值:

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

通过将深度条件设置为 greaterless,OpenGL 就能假设你只会写入比当前片段深度值更大或更小的值了。这样的话,设置深度条件为 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;
}

注意,这个特性只在 OpenGL 4.2 或更高版本中可用。

接口块

到目前为止,每当我们希望从顶点着色器向片段着色器发送数据时,我们都声明了几个对应的输入/输出变量。将它们一个一个声明是着色器间发送数据最简单的方式了,但当程序变得更大时,你希望发送的可能就不只是几个变量了,它还可能包括数组和结构体。

为了帮助我们管理这些变量,GLSL 为我们提供了一个叫做 接口块(Interface Block) 的东西,来方便我们组合这些变量。接口块的声明和 struct 的声明有点相像,不同的是,现在根据它是一个输入还是输出块(Block),使用 inout 关键字来定义。

在顶点着色器中:

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

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out VS_OUT {
    vec2 TexCoords;
} vs_out;

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

这次我们声明了一个叫做 vs_out 的接口块,它打包了我们希望发送到下一个着色器中的所有输出变量。这只是一个很简单的例子,但你可以想象一下,它能够帮助你管理着色器的输入和输出。当我们希望将着色器的输入或输出打包为数组时,它也会非常有用,我们将在下一节讨论几何着色器(Geometry Shader)时见到。

之后,我们还需要在下一个着色器,即片段着色器中,定义一个输入接口块。块名(Block Name) 应该是和着色器中一样的(VS_OUT),但实例名(Instance Name)(顶点着色器中用的是 vs_out)可以是随意的,但要避免使用误导性的名称,比如对实际上包含输入变量的接口块命名为 vs_out

#version 330 core
out vec4 FragColor;

in VS_OUT {
    vec2 TexCoords;
} fs_in;

uniform sampler2D texture;

void main() {             
    FragColor = texture(texture, fs_in.TexCoords);   
}

只要两个接口块的名字一样,它们对应的输入和输出就会匹配起来。这是帮助你管理代码的又一个有用特性,在有几何着色器这样穿插特定着色器阶段的场景下会很有用。

Uniform缓冲对象

我们已经使用 OpenGL 很长时间了,学会了一些很酷的技巧,但也遇到了一些很麻烦的地方。比如说,当使用多于一个的着色器时,尽管大部分的 uniform 变量都是相同的,我们还是需要不断地设置它们,所以为什么要这么麻烦地重复设置它们呢?

OpenGL 为我们提供了一个叫做 Uniform 缓冲对象(Uniform Buffer Object) 的工具,它允许我们定义一系列在多个着色器程序中相同的全局 Uniform 变量。当使用 Uniform 缓冲对象的时候,我们只需要设置相关的 uniform 一次。当然,我们仍需要手动设置每个着色器中不同的 uniform。并且创建和配置 Uniform 缓冲对象会有一点繁琐。

因为 Uniform 缓冲对象仍然是一个缓冲,我们可以使用 glGenBuffers 来创建它,将它绑定到 GL_UNIFORM_BUFFER 缓冲目标,并将所有相关的 uniform 数据存入缓冲。在 Uniform 缓冲对象中储存数据是有一些规则的,我们会在之后讨论它。首先,我们将使用一个简单的顶点着色器,将 projectionview 矩阵存储到所谓的 Uniform 块(Uniform Block) 中:

#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);
}

在我们大多数的例子中,我们都会在每个渲染迭代中,对每个着色器设置 projectionview Uniform 矩阵。这是利用 Uniform 缓冲对象的一个非常完美的例子,因为现在我们只需要存储这些矩阵一次就可以了。

这里,我们声明了一个叫做 Matrices 的 Uniform 块,它储存了两个 4x4 矩阵。Uniform 块中的变量可以直接访问,不需要加块名作为前缀。接下来,我们在 OpenGL 代码中将这些矩阵值存入缓冲中,每个声明了这个 Uniform 块的着色器都能够访问这些矩阵。

你现在可能会在想 layout (std140) 这个语句是什么意思。它的意思是说,当前定义的 Uniform 块对它的内容使用一个特定的内存布局。这个语句设置了 Uniform 块布局(Uniform Block Layout)

Uniform块布局

Uniform 块的内容存储在一个缓冲对象中,它实际上只是一块预留内存。由于这块内存并不会保存它具体保存的是什么类型的数据,我们还需要告诉 OpenGL 内存的哪一部分对应着色器中的哪一个 uniform 变量。

假设着色器中有以下 Uniform 块:

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

我们需要知道的是每个变量的大小(以字节为单位)和(从块起始位置的)偏移量,以便我们能够按顺序将它们放入缓冲中。每个元素的大小都在 OpenGL 中有清楚地声明,并且直接对应于 C++ 数据类型,其中向量和矩阵都是大的浮点数数组。OpenGL 没有声明的是这些变量间的间距(Spacing)。这允许硬件能够在它认为合适的位置放置变量。例如,一些硬件可能会将一个 vec3 放置在 float 边上。不是所有的硬件都能这样处理,可能会在附加这个 float 之前,先将 vec3 填充(Pad)为一个 4 个 float 的数组。这个特性本身很棒,但是会对我们造成麻烦。

共享布局(Shared Layout)

默认情况下,GLSL 会使用一个叫做共享(Shared)布局的 Uniform 内存布局,共享是因为一旦硬件定义了偏移量,它们在多个程序中是共享且一致的。使用共享布局时,GLSL 可以为了优化而对 uniform 变量的位置进行变动,只要变量的顺序保持不变。

由于我们无法知道每个 uniform 变量的偏移量,我们也就不知道如何准确地填充我们的 Uniform 缓冲了。我们能够使用像 glGetUniformIndices 这样的函数来查询这个信息,但这超出了本节的范围。

std140 布局

虽然共享布局给了我们很多节省空间的优化,但是我们需要查询每个 uniform 变量的偏移量,这会产生非常多的工作量。通常的做法是,不使用共享布局,而是使用 std140 布局。

std140 布局声明了每个变量的偏移量都是由一系列规则所决定的,这显式地声明了每个变量类型的内存布局。由于这是显式提及的,我们可以手动计算出每个变量的偏移量。

每个变量都有一个基准对齐量(Base Alignment),它等于一个变量在 Uniform 块中所占据的空间(包括填充量(Padding)),这个基准对齐量是使用 std140 布局的规则计算出来的。

接下来,对每个变量,我们再计算它的对齐偏移量(Aligned Offset),它是一个变量从块起始位置的字节偏移量。一个变量的对齐字节偏移量必须等于基准对齐量的倍数。

布局规则的原文在OpenGL的Uniform缓冲规范这里找到,但我们会在下面列出最常见的规则。GLSL 中的每个变量,例如 intfloatbool,都被定义为 4 字节量。每 4 个字节将用一个 N 来表示。

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

和 OpenGL 大多数的规范一样,使用例子就能更容易地理解。我们会使用之前引入的那个叫做 ExampleBlock 的 Uniform 块,并使用 std140 布局计算出每个成员的对齐偏移量:

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
};

作为练习,尝试去自己计算一下偏移量,并和表格进行对比。使用计算后的偏移量值,根据 std140 布局的规则,我们就能使用像 glBufferSubData 的函数将变量数据按照偏移量填充进缓冲中了。虽然 std140 布局不是最高效的布局,但它保证了内存布局在每个声明了这个 Uniform 块的程序中是一致的。


注意对于数组 values[3] 的每一个元素,需要单独设置,虽然一个 float 只占 4 个字节,但是数组元素的基准对齐量与 vec4 保持一致为 16 个字节。

unsigned int ubo;
glGenBuffers(1, &ubo);
glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferData(GL_UNIFORM_BUFFER, 152, NULL, GL_STATIC_DRAW);

glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(float), &value);
glBufferSubData(GL_UNIFORM_BUFFER, 16, sizeof(glm::vec3), &vector);
glBufferSubData(GL_UNIFORM_BUFFER, 32, sizeof(glm::mat4), &matrix);
for (int i = 0; i < 3; ++i) {
	// 计算当前元素的实际偏移量
	// 基础偏移量96加上每个元素16字节的间隔*i
	glBufferSubData(GL_UNIFORM_BUFFER, 96 + i * 16, sizeof(float), &values[i]);
}
glBufferSubData(GL_UNIFORM_BUFFER, 144, sizeof(bool), &boolean);
glBufferSubData(GL_UNIFORM_BUFFER, 148, sizeof(int), &integer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

通过在 Uniform 块定义之前添加 layout (std140) 语句,我们告诉 OpenGL 这个 Uniform 块使用的是 std140 布局。除此之外还可以选择两个布局,但它们都需要我们在填充缓冲之前先查询每个偏移量。我们已经见过 shared 布局了,剩下的一个布局是 packed。当使用紧凑(Packed)布局时,是不能保证这个布局在每个程序中保持不变的(即非共享),因为它允许编译器去将 uniform 变量从 Uniform 块中优化掉,这在每个着色器中都可能是不同的。


接下来展示使用 glGetUniformIndicesglGetActiveUniformsiv 动态查询偏移位置:

// 获取uniform变量的索引
glGetUniformIndices(GLuint program, GLsizei uniformCount, const GLchar *const*uniformNames, GLuint *uniformIndices);
// 查询每个uniform变量的偏移量
glGetActiveUniformsiv(GLuint program, GLsizei uniformCount, const GLuint *uniformIndices, GLenum pname, GLint *params)

由于我们只能查询数组首元素的对齐偏移量,针对于 shared 与 packed 分布,我们需要推导每一个元素占了多少字节(基准对齐量),然后计算每一个元素的对齐偏移量。但这种做法会存在潜在风险,packed 旨在尽可能紧凑地存储uniform变量而不遵循固定的对齐规则,所以在某些情况下推导会出现错误。

const char* uniformNames[] = { "value", "vector", "matrix", "values","boolean", "integer" };
GLuint indices[6];
// 获取uniform变量的索引
glGetUniformIndices(ourShader.ID, 6, uniformNames, indices);

// 查询每个uniform变量的偏移量
int offsets[6];
glGetActiveUniformsiv(ourShader.ID, 6, indices, GL_UNIFORM_OFFSET, offsets);

glBufferSubData(GL_UNIFORM_BUFFER, offsets[0], sizeof(float), &value);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[1], sizeof(glm::vec3), &vector);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[2], sizeof(glm::mat4), &matrix);
// shared或packed的推导
for (int i = 0; i < 3; ++i) {
	glBufferSubData(GL_UNIFORM_BUFFER, offsets[3] + sizeof(float)*i, sizeof(float), &values[i]);
}
glBufferSubData(GL_UNIFORM_BUFFER, offsets[4], sizeof(int), &boolean);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[5], sizeof(int), &integer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

同时需要注意,shared 布局声明为 layout (shared) uniform ExampleBlock,但若你希望为 packed 布局,不需要加 layout,直接 uniform ExampleBlock


使用Uniform缓冲

我们已经讨论了如何在着色器中定义 Uniform 块,并设定它们的内存布局了,但我们还没有讨论该如何使用它们。

首先,我们需要调用 glGenBuffers,创建一个 Uniform 缓冲对象。一旦我们有了一个缓冲对象,我们需要将它绑定到 GL_UNIFORM_BUFFER 目标,并调用 glBufferData,分配足够的内存。

unsigned int uboExampleBlock;
glGenBuffers(1, &uboExampleBlock);
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
glBufferData(GL_UNIFORM_BUFFER, 152, NULL, GL_STATIC_DRAW); // 分配 152 字节的内存
glBindBuffer(GL_UNIFORM_BUFFER, 0);

现在,每当我们需要对缓冲更新或者插入数据,我们都会绑定到 uboExampleBlock,并使用 glBufferSubData 来更新它的内存。我们只需要更新这个 Uniform 缓冲一次,所有使用这个缓冲的着色器就都使用的是更新后的数据了。但是,如何才能让 OpenGL 知道哪个 Uniform 缓冲对应的是哪个 Uniform 块呢?

在 OpenGL 上下文中,定义了一些绑定点(Binding Point),我们可以将一个 Uniform 缓冲链接至它。在创建 Uniform 缓冲之后,我们将它绑定到其中一个绑定点上,并将着色器中的 Uniform 块绑定到相同的绑定点,把它们连接到一起。下面的图示展示了这个:

image.png

你可以看到,我们可以绑定多个 Uniform 缓冲到不同的绑定点上。因为着色器 A 和着色器 B 都有一个链接到绑定点 0 的 Uniform 块,它们的 Uniform 块将会共享相同的 uniform 数据,uboMatrices,前提条件是两个着色器都定义了相同的 Matrices Uniform 块。

为了将 Uniform 块绑定到一个特定的绑定点中,我们需要调用 glUniformBlockBinding 函数,它的第一个参数是一个程序对象,之后是一个 Uniform 块索引和链接到的绑定点。Uniform 块索引(Uniform Block Index)是着色器中已定义 Uniform 块的位置值索引。这可以通过调用 glGetUniformBlockIndex 来获取,它接受一个程序对象和 Uniform 块的名称。

glGetUniformBlockIndex(GLuint program, const GLchar *uniformBlockName);
glUniformBlockBinding(GLuint program, GLuint uniformBlockIndex, GLuint uniformBlockBinding);

我们可以用以下方式将图示中的 Lights Uniform 块链接到绑定点 2:

unsigned int lights_index = glGetUniformBlockIndex(shaderA.ID, "Lights");   
glUniformBlockBinding(shaderA.ID, lights_index, 2);

注意我们需要对每个着色器重复这一步骤。


从 OpenGL 4.2 版本起,你也可以添加一个布局标识符,显式地将 Uniform 块的绑定点储存在着色器中,这样就不用再调用 glGetUniformBlockIndexglUniformBlockBinding 了。下面的代码显式地设置了 Lights Uniform 块的绑定点。

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

接下来,我们还需要绑定 Uniform 缓冲对象到相同的绑定点上,这可以使用 glBindBufferBaseglBindBufferRange 来完成。

glBindBufferBase(GLenum target, GLuint index, GLuint buffer);
// offset必须是 GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT 的倍数,原因见下
glBindBufferRange(GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeiptr size);

glBindBufferBase(GL_UNIFORM_BUFFER, 2, uboExampleBlock); // 或
glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 152);

glBindbufferBase 需要一个目标、一个绑定点索引和一个 Uniform 缓冲对象作为它的参数。这个函数将 uboExampleBlock 链接到绑定点 2 上,自此,绑定点的两端都链接上了。你也可以使用 glBindBufferRange 函数,它需要一个附加的偏移量和大小参数,这样子你可以绑定 Uniform 缓冲的特定一部分到绑定点中。通过使用 glBindBufferRange 函数,你可以让多个不同的 Uniform 块绑定到同一个 Uniform 缓冲对象上。

现在,所有的东西都配置完毕了,我们可以开始向 Uniform 缓冲中添加数据了。只要我们需要,就可以使用 glBufferSubData 函数,用一个字节数组添加所有的数据,或者更新缓冲的一部分。要想更新 uniform 变量 boolean,我们可以用以下方式更新 Uniform 缓冲对象:

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

同样的步骤也能应用到 Uniform 块中其他的 uniform 变量上,但需要使用不同的范围参数。


评论区 jim jim指出了一个问题:

image.png

我测试了一下,确实如此,mark一下。测试用的代码如下:

unsigned int ubo;
glGenBuffers(1, &ubo);
glBindBuffer(GL_UNIFORM_BUFFER, ubo);
glBufferData(GL_UNIFORM_BUFFER, 512, NULL, GL_STATIC_DRAW);
float value = 1.0f;
glm::vec3 vector(0.0f, 1.0f, 0.0f);
glm::mat4 matrix;
matrix[0] = glm::vec4(1.0f, 0.0f, 0.0f, 0.0f);
matrix[1] = glm::vec4(0.0f, 1.0f, 0.0f, 0.0f);
matrix[2] = glm::vec4(0.0f, 0.0f, 1.0f, 0.0f);
matrix[3] = glm::vec4(0.0f, 0.0f, 0.0f, 1.0f);
float values[3] = { 1.0f, 2.0f, 3.0f };
bool boolean = true;
int integer = 1;

const char* uniformNames[] = { "value", "vector", "matrix", "values","boolean", "integer" };
GLuint indices[6];
// 获取uniform变量的索引
glGetUniformIndices(ourShader.ID, 6, uniformNames, indices);

// 查询每个uniform变量的偏移量
int offsets[6];
glGetActiveUniformsiv(ourShader.ID, 6, indices, GL_UNIFORM_OFFSET, offsets);

for (int i = 0; i < 6; ++i) {
	printf("Element %d of array 'values' has offset: %d\n", i, offsets[i]);
}

GLint uniformBufferOffsetAlign = 256;
//glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &uniformBufferOffsetAlign);
std::cout << "Uniform buffer offset alignment: " << uniformBufferOffsetAlign << std::endl;
glBufferSubData(GL_UNIFORM_BUFFER, offsets[0] + uniformBufferOffsetAlign, sizeof(float), &value);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[1] + uniformBufferOffsetAlign, sizeof(glm::vec3), &vector);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[2] + uniformBufferOffsetAlign, sizeof(glm::mat4), &matrix);
// 假设均匀分布
int perOffset = (offsets[4] - offsets[3]) / 3;
for (int i = 0; i < 3; ++i) {
	glBufferSubData(GL_UNIFORM_BUFFER, perOffset * i + offsets[3] + uniformBufferOffsetAlign, sizeof(float), &values[i]);
}

glBufferSubData(GL_UNIFORM_BUFFER, offsets[4] + uniformBufferOffsetAlign, sizeof(int), &boolean);
glBufferSubData(GL_UNIFORM_BUFFER, offsets[5] + uniformBufferOffsetAlign, sizeof(int), &integer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

GLuint blockIndex = glGetUniformBlockIndex(ourShader.ID, "ExampleBlock");
glUniformBlockBinding(ourShader.ID, blockIndex, 0);
glBindBufferRange(GL_UNIFORM_BUFFER, 0, ubo, uniformBufferOffsetAlign, 152);

一个简单的例子

我们来展示一个真正使用 Uniform 缓冲对象的例子。如果我们回头看看之前所有的代码例子,我们会不断地使用 3 个矩阵:投影、观察和模型矩阵。在所有的这些矩阵中,只有模型矩阵会频繁变动。如果我们有多个着色器使用了这同一组矩阵,那么使用 Uniform 缓冲对象可能会更好。

我们会将投影和模型矩阵存储到一个叫做 Matrices 的 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);
}

这里没什么特别的,除了我们现在使用的是一个 std140 布局的 Uniform 块。我们将在例子程序中,显示 4 个立方体,每个立方体都是使用不同的着色器程序渲染的。这 4 个着色器程序将使用相同的顶点着色器,但使用的是不同的片段着色器,每个着色器会输出不同的颜色。

首先,我们将顶点着色器的 Uniform 块设置为绑定点 0。注意我们需要对每个着色器都设置一遍。

unsigned int uniformBlockIndexRed    = glGetUniformBlockIndex(shaderRed.ID, "Matrices");
unsigned int uniformBlockIndexGreen  = glGetUniformBlockIndex(shaderGreen.ID, "Matrices");
unsigned int uniformBlockIndexBlue   = glGetUniformBlockIndex(shaderBlue.ID, "Matrices");
unsigned int uniformBlockIndexYellow = glGetUniformBlockIndex(shaderYellow.ID, "Matrices");  

glUniformBlockBinding(shaderRed.ID,    uniformBlockIndexRed, 0);
glUniformBlockBinding(shaderGreen.ID,  uniformBlockIndexGreen, 0);
glUniformBlockBinding(shaderBlue.ID,   uniformBlockIndexBlue, 0);
glUniformBlockBinding(shaderYellow.ID, uniformBlockIndexYellow, 0);

接下来,我们创建 Uniform 缓冲对象本身,并将其绑定到绑定点 0:

unsigned int uboMatrices;
glGenBuffers(1, &uboMatrices);

glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));

首先我们为缓冲分配了足够的内存,它等于 glm::mat4 大小的两倍。GLM 矩阵类型的大小直接对应于 GLSL 中的 mat4。接下来,我们将缓冲中的特定范围(在这里是整个缓冲)链接到绑定点 0。

剩余的就是填充这个缓冲了。如果我们将投影矩阵的视野(Field of View)值保持不变(所以摄像机就没有缩放了),我们只需要将其在程序中定义一次——这也意味着我们只需要将它插入到缓冲中一次。因为我们已经为缓冲对象分配了足够的内存,我们可以使用 glBufferSubData 在进入渲染循环之前存储投影矩阵:

glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(projection));
glBindBuffer(GL_UNIFORM_BUFFER, 0);

这里我们将投影矩阵储存在 Uniform 缓冲的前半部分。在每次渲染迭代中绘制物体之前,我们会将观察矩阵更新到缓冲的后半部分:

glm::mat4 view = camera.GetViewMatrix();           
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));
glBindBuffer(GL_UNIFORM_BUFFER, 0);

Uniform 缓冲对象的部分就结束了。每个包含了 Matrices 这个 Uniform 块的顶点着色器将会包含储存在 uboMatrices 中的数据。所以,如果我们现在要用 4 个不同的着色器绘制 4 个立方体,它们的投影和观察矩阵都会是一样的。

glBindVertexArray(cubeVAO);
shaderRed.use();
glm::mat4 model;
model = glm::translate(model, glm::vec3(-0.75f, 0.75f, 0.0f));  // 移动到左上角
shaderRed.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);        
// ... 绘制绿色立方体
// ... 绘制蓝色立方体
// ... 绘制黄色立方体 

唯一需要设置的 uniform 只剩 model uniform 了。在像这样的场景中使用 Uniform 缓冲对象会让我们在每个着色器中都剩下一些 uniform 调用。最终的结果会是这样的:

image.png

由于修改了模型矩阵,每个立方体都移动到了窗口的一边,并且由于使用了不同的片段着色器,它们的颜色也不同。这只是一个很简单的情景,我们可能需要使用 Uniform 缓冲对象,但任何大型的渲染程序都可能同时激活上百个着色器程序,这时 Uniform 缓冲对象的优势就会很大地体现出来了。

你可以在这里找到uniform例子程序的完整源代码。

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

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

本次项目源码:高级GLSL - GitCode


几何着色器

  • 原文链接:几何着色器 - LearnOpenGL CN

在顶点和片段着色器之间有一个可选的几何着色器(Geometry Shader)。几何着色器的输入是一个图元(如点或三角形)的一组顶点。几何着色器可以在顶点发送到下一着色器阶段之前对它们进行任意变换。然而,几何着色器最有趣的地方在于,它能够将(这一组)顶点变换为完全不同的图元,并且还能生成比原来更多的顶点。

废话不多说,我们直接先看一个几何着色器的例子:

#version 330 core
layout (points) in;
layout (line_strip, max_vertices = 2) out;

void main() {    
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0); 
    EmitVertex();

    gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
    EmitVertex();

    EndPrimitive();
}

在几何着色器的顶部,我们需要声明从顶点着色器输入的图元类型。这需要在 in 关键字前声明一个布局修饰符(Layout Qualifier)。这个输入布局修饰符可以从顶点着色器接收下列任何一个图元值:

  • points:绘制 GL_POINTS 图元时(1)。
  • lines:绘制 GL_LINESGL_LINE_STRIP 时(2)。
  • lines_adjacencyGL_LINES_ADJACENCYGL_LINE_STRIP_ADJACENCY(4)。
  • trianglesGL_TRIANGLESGL_TRIANGLE_STRIPGL_TRIANGLE_FAN(3)。
  • triangles_adjacencyGL_TRIANGLES_ADJACENCYGL_TRIANGLE_STRIP_ADJACENCY(6)。

以上是能提供给 glDrawArrays 渲染函数的几乎所有图元了。如果我们想要将顶点绘制为 GL_TRIANGLES,我们就需要将输入修饰符设置为 triangles。括号内的数字表示的是一个图元所包含的最小顶点数。

接下来,我们还需要指定几何着色器输出的图元类型,这需要在 out 关键字前面加一个布局修饰符。和输入布局修饰符一样,输出布局修饰符也可以接受几个图元值:

  • points
  • line_strip
  • triangle_strip

有了这 3 个输出修饰符,我们就可以使用输入图元创建几乎任意的形状了。要生成一个三角形的话,我们将输出定义为 triangle_strip,并输出 3 个顶点。

几何着色器同时希望我们设置一个它最大能够输出的顶点数量(如果你超过了这个值,OpenGL 将不会绘制多出的顶点),这个也可以在 out 关键字的布局修饰符中设置。在这个例子中,我们将输出一个 line_strip,并将最大顶点数设置为 2 个。


如果你不知道什么是线条(Line Strip):线条连接了一组点,形成一条连续的线,它最少要由两个点来组成。在渲染函数中每多加一个点,就会在这个点与前一个点之间形成一条新的线。在下面这张图中,我们有 5 个顶点:

image.png


为了生成更有意义的结果,我们需要某种方式来获取前一着色器阶段的输出。GLSL 提供给我们一个名为 gl_in内置(Built-in)变量,在内部看起来(可能)是这样的:

in gl_Vertex {
    vec4  gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
} gl_in[];

这里,它被声明为一个接口块(Interface Block,我们在上一节已经讨论过),它包含了几个很有意思的变量,其中最有趣的一个是 gl_Position,它是和顶点着色器输出非常相似的一个向量。

要注意的是,它被声明为一个数组,因为大多数的渲染图元包含多于 1 个的顶点,而几何着色器的输入是一个图元的所有顶点。

有了之前顶点着色器阶段的顶点数据,我们就可以使用 2 个几何着色器函数,EmitVertexEndPrimitive,来生成新的数据了。几何着色器希望你能够生成并输出至少一个定义为输出的图元。在我们的例子中,我们需要至少生成一个线条图元。

void main() {
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0); 
    EmitVertex();

    gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
    EmitVertex();

    EndPrimitive();
}

每次我们调用 EmitVertex 时,gl_Position 中的向量会被添加到图元中来。当 EndPrimitive 被调用时,所有发射出的(Emitted)顶点都会合成为指定的输出渲染图元。在一个或多个 EmitVertex 调用之后重复调用 EndPrimitive 能够生成多个图元。在这个例子中,我们发射了两个顶点,它们从原始顶点位置平移了一段距离,之后调用了 EndPrimitive,将这两个顶点合成为一个包含两个顶点的线条。

现在你(大概)了解了几何着色器的工作方式,你可能已经猜出这个几何着色器是做什么的了。它接受一个点图元作为输入,以这个点为中心,创建一条水平的线图元。如果我们渲染它,看起来会是这样的:

image.png

目前还并没有什么令人惊叹的效果,但考虑到这个输出是通过调用下面的渲染函数来生成的,它还是很有意思的:

glDrawArrays(GL_POINTS, 0, 4);

虽然这是一个比较简单的例子,它的确向你展示了如何能够使用几何着色器来(动态地)生成新的形状。在之后我们会利用几何着色器创建出更有意思的效果,但现在我们仍将从创建一个简单的几何着色器开始。

使用几何着色器

为了展示几何着色器的用法,我们将会渲染一个非常简单的场景,我们只会在标准化设备坐标的 z 平面上绘制四个点。这些点的坐标是:

float points[] = {
    -0.5f,  0.5f, // 左上
     0.5f,  0.5f, // 右上
     0.5f, -0.5f, // 右下
    -0.5f, -0.5f  // 左下
};

顶点着色器只需要在 z 平面绘制点就可以了,所以我们将使用一个最基本的顶点着色器:

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

void main() {
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); 
}

直接在片段着色器中硬编码,将所有的点都输出为绿色:

#version 330 core
out vec4 FragColor;

void main() {
    FragColor = vec4(0.0, 1.0, 0.0, 1.0);   
}

为点的顶点数据生成一个 VAO 和一个 VBO,然后使用 glDrawArrays 进行绘制:

shader.use();
glBindVertexArray(VAO);
glDrawArrays(GL_POINTS, 0, 4);

结果是在黑暗的场景中有四个(很难看见的)绿点:

image.png

但我们之前不是学过这些吗?是的,但是现在我们将会添加一个几何着色器,为场景添加活力。

出于学习目的,我们将会创建一个传递(Pass-through)几何着色器,它会接收一个点图元,并直接将它传递(Pass)到下一个着色器:

#version 330 core
layout (points) in;
layout (points, max_vertices = 1) out;

void main() {    
    gl_Position = gl_in[0].gl_Position; 
    EmitVertex();
    EndPrimitive();
}

现在这个几何着色器应该很容易理解了,它只是将它接收到的顶点位置不作修改直接发射出去,并生成一个点图元。

和顶点与片段着色器一样,几何着色器也需要编译和链接,但这次在创建着色器时我们将会使用 GL_GEOMETRY_SHADER 作为着色器类型:

geometryShader = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometryShader, 1, &gShaderCode, NULL);
glCompileShader(geometryShader);  
...
glAttachShader(program, geometryShader);
glLinkProgram(program);

着色器编译的代码和顶点与片段着色器代码都是一样的。记得要检查编译和链接错误!


修改 shader 类的构造函数以适应几何着色器的编译与链接:

Shader::Shader(const char* vertexPath, const char* fragmentPath, const char* geometryPath)
{
	// 1. 从文件路径中获取顶点/片段着色器
	std::string vertexCode;
	std::string fragmentCode;
	std::string geometryCode;
	std::ifstream vShaderFile;
	std::ifstream fShaderFile;
    std::ifstream gShaderFile;
	// 保证ifstream对象可以抛出异常:
	vShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
	fShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
    gShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
	try
	{
		// 打开文件
		vShaderFile.open(vertexPath);
		fShaderFile.open(fragmentPath);
		std::stringstream vShaderStream, fShaderStream;
		// 读取文件的缓冲内容到数据流中
		vShaderStream << vShaderFile.rdbuf();
		fShaderStream << fShaderFile.rdbuf();
		// 关闭文件处理器
		vShaderFile.close();
		fShaderFile.close();
		// 转换数据流到string
		vertexCode = vShaderStream.str();
		fragmentCode = fShaderStream.str();
		if (geometryPath != nullptr)
		{
            gShaderFile.open(geometryPath);
			std::stringstream gShaderStream;
            gShaderStream << gShaderFile.rdbuf();
            gShaderFile.close();
            geometryCode = gShaderStream.str();
		}
	}
	catch (std::ifstream::failure e)
	{
		std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
	}
	const char* vShaderCode = vertexCode.c_str();
	const char* fShaderCode = fragmentCode.c_str();
	// 2. 编译着色器
	unsigned int vertex, fragment, geometry;
	int success;
	char infoLog[512];

	// 顶点着色器
	vertex = glCreateShader(GL_VERTEX_SHADER);
	glShaderSource(vertex, 1, &vShaderCode, NULL);
	glCompileShader(vertex);
	// 打印编译错误(如果有的话)
	glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
	if (!success)
	{
		glGetShaderInfoLog(vertex, 512, NULL, infoLog);
		std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
	};

	// 片段着色器
	fragment = glCreateShader(GL_FRAGMENT_SHADER);
	glShaderSource(fragment, 1, &fShaderCode, NULL);
	glCompileShader(fragment);
	// 打印编译错误(如果有的话)
	glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
	if (!success)
	{
		glGetShaderInfoLog(fragment, 512, NULL, infoLog);
		std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
	}

	// 着色器程序
	ID = glCreateProgram();
	glAttachShader(ID, vertex);
	glAttachShader(ID, fragment);

	// 顶点着色器
	if (geometryPath != nullptr)
	{
		const char* gShaderCode = geometryCode.c_str();
		geometry = glCreateShader(GL_GEOMETRY_SHADER);
        glShaderSource(geometry, 1, &gShaderCode, NULL);
        glCompileShader(geometry);
        glGetShaderiv(geometry, GL_COMPILE_STATUS, &success);
        if (!success)
        {
            glGetShaderInfoLog(geometry, 512, NULL, infoLog);
            std::cout << "ERROR::SHADER::GEOMETRY::COMPILATION_FAILED\n" << infoLog << std::endl;
        }
		glAttachShader(ID, geometry);
	}

	glLinkProgram(ID);
	// 打印连接错误(如果有的话)
	glGetProgramiv(ID, GL_LINK_STATUS, &success);
	if (!success)
	{
		glGetProgramInfoLog(ID, 512, NULL, infoLog);
		std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
	}

	// 删除着色器,它们已经链接到我们的程序中了,已经不再需要了
	glDeleteShader(vertex);
	glDeleteShader(fragment);
    if (geometryPath != nullptr)
    {
        glDeleteShader(geometry);
    }
}

如果你现在编译并运行程序,会看到和下面类似的结果:

image.png

这和没使用几何着色器时是完全一样的!我承认这是有点无聊,但既然我们仍然能够绘制这些点,所以几何着色器是正常工作的,现在是时候做点更有趣的东西了!

造几个房子

绘制点和线并没有那么有趣,所以我们会使用一点创造力,利用几何着色器在每个点的位置上绘制一个房子。要实现这个,我们可以将几何着色器的输出设置为 triangle_strip,并绘制三个三角形:其中两个组成一个正方形,另一个用作房顶。

在 OpenGL 中,三角形带(Triangle Strip)是绘制三角形更高效的方式,它使用顶点更少。在第一个三角形绘制完之后,每个后续顶点将会在上一个三角形边上生成另一个三角形:每 3 个临近的顶点将会形成一个三角形。如果我们一共有 6 个构成三角形带的顶点,那么我们会得到这些三角形:(1, 2, 3)、(2, 3, 4)、(3, 4, 5) 和 (4, 5, 6),共形成 4 个三角形。一个三角形带至少需要 3 个顶点,并会生成 N-2 个三角形。使用 6 个顶点,我们创建了 6-2 = 4 个三角形。下面这幅图展示了这点:

image.png

通过使用三角形带作为几何着色器的输出,我们可以很容易创建出需要的房子形状,只需要以正确的顺序生成 3 个相连的三角形就行了。下面这幅图展示了顶点绘制的顺序,蓝点代表的是输入点:

image.png

几何着色器:

#version 330 core
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;

void build_house(vec4 position)
{    
    gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0);    // 1:左下
    EmitVertex();   
    gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0);    // 2:右下
    EmitVertex();
    gl_Position = position + vec4(-0.2,  0.2, 0.0, 0.0);    // 3:左上
    EmitVertex();
    gl_Position = position + vec4( 0.2,  0.2, 0.0, 0.0);    // 4:右上
    EmitVertex();
    gl_Position = position + vec4( 0.0,  0.4, 0.0, 0.0);    // 5:顶部
    EmitVertex();
    EndPrimitive();
}

void main() {    
    build_house(gl_in[0].gl_Position);
}

这个几何着色器生成了 5 个顶点,每个顶点都是原始点的位置加上一个偏移量,来组成一个大的三角形带。最终的图元会被光栅化,然后片段着色器会处理整个三角形带,最终在每个绘制的点处生成一个绿色房子:

image.png

image.png

你可以看到,每个房子实际上是由 3 个三角形组成——但是都由空间中一点变换从而绘制。这些绿房子看起来是有点无聊,所以我们会再给每个房子分配一个不同的颜色。为了实现这个,我们需要在顶点着色器中添加一个额外的顶点属性,表示颜色信息,将它传递至几何着色器,并再次发送到片段着色器中。

下面是更新后的顶点数据:

float points[] = {
    -0.5f,  0.5f, 1.0f, 0.0f, 0.0f, // 左上
     0.5f,  0.5f, 0.0f, 1.0f, 0.0f, // 右上
     0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 右下
    -0.5f, -0.5f, 1.0f, 1.0f, 0.0f  // 左下
};

然后我们更新顶点着色器,使用一个接口块将颜色属性发送到几何着色器中:

in VS_OUT {
    vec3 color;
} gs_in[];

因为几何着色器是作用于输入的一组顶点的,从顶点着色器发来输入数据总是会以数组的形式表示出来,即便我们现在只有一个顶点。


我们并不是必须要用接口块来向几何着色器传递数据。我们也可以这样写:

in vec3 vColor[];

如果顶点着色器发送的颜色向量是 out vec3 vColor,那这么写就没问题。然而,接口块在几何着色器这样的着色器中会更容易处理一点。实际上,几何着色器的输入能够变得非常大,将它们合并为一个大的接口块数组会更符合逻辑一点。


接下来,我们需要在片段着色器阶段声明一个输出颜色向量:

out vec3 fColor;

由于片段着色器只需要一个(经过插值的)颜色,因此发送多个颜色是没有意义的。所以,fColor 向量不是一个数组,而是一个单独的向量。当发射一个顶点时,每个顶点将使用最后存储在 fColor 中的值,该值将用于片段着色器的运行。对于我们的房子,我们只需要在发射第一个顶点之前,使用顶点着色器中的颜色填充 fColor 一次即可。

fColor = gs_in[0].color; // gs_in[0] 因为只有一个输入顶点
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0);    // 1:左下  
EmitVertex();   
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0);    // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2,  0.2, 0.0, 0.0);    // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2,  0.2, 0.0, 0.0);    // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0,  0.4, 0.0, 0.0);    // 5:顶部
EmitVertex();
EndPrimitive();

所有发射的顶点都将包含最后存储在 fColor 中的值,即顶点的颜色属性值。因此,所有房子都将具有它们自己的颜色:

image.png

仅仅是为了有趣,我们也可以假装这是冬天,将最后一个顶点的颜色设置为白色,给屋顶落上一些雪(颜色插值)。

fColor = gs_in[0].color; 
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0);    // 1:左下 
EmitVertex();   
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0);    // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2,  0.2, 0.0, 0.0);    // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2,  0.2, 0.0, 0.0);    // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0,  0.4, 0.0, 0.0);    // 5:顶部
fColor = vec3(1.0, 1.0, 1.0); // 设置为白色
EmitVertex();
EndPrimitive();

最终结果看起来是这样的:

image.png

你可以将你的代码与这里的 OpenGL 代码进行比对。

有了几何着色器,即使是最简单的图元也能呈现出丰富的视觉效果。这些形状在 GPU 的高速硬件中动态生成,相比于在顶点缓冲中手动定义模型,效率更高。因此,几何着色器是简单且重复形状的理想优化工具,例如体素世界中的方块以及户外场景中的植被。

爆破物体

绘制房子很有趣,但我们不会经常这样做。接下来,我们将深入探讨如何使用几何着色器来实现物体爆破效果。虽然这种效果不常用,但它能充分展示几何着色器的强大功能。

当我们说“爆破”一个物体时,并不是真的要炸毁顶点数据,而是指将每个三角形沿其法向量方向移动一段距离。这样,整个物体看起来就像沿着每个三角形的法线向量“爆炸”一样。这种效果在纳米模型的渲染中尤为常见,看起来就像这样:

注意,若是出现纹理混乱,请检测 Model:: loadModel 是否需要翻转 UV

image.png

这种几何着色器效果的一个优点是,无论物体的复杂程度如何,它都可以应用。

由于我们想沿着三角形的法向量位移每个顶点,因此首先需要计算该法向量。我们要做的就是计算垂直于三角形表面的向量,仅使用我们可以访问的 3 个顶点。你可能还记得在变换小节中,我们使用叉乘来获得垂直于其他两个向量的向量。如果我们能获得两个平行于三角形表面的向量 a 和 b,就可以对这两个向量进行叉乘来获得法向量。以下几何着色器函数正是这样做的,它使用 3 个输入顶点坐标来获得法向量:

vec3 GetNormal()
{
   vec3 a = vec3(gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
   vec3 b = vec3(gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
   return normalize(cross(a, b));
}

这里我们使用减法获得了两个平行于三角形表面的向量 a 和 b。由于两个向量相减可以得到这两个向量之间的差,并且三个点都位于三角平面上,因此对任意两个向量相减都可以得到一个平行于平面的向量。注意,如果我们交换了 cross 函数中 a 和 b 的位置,我们将得到一个指向相反方向的法向量——这里的顺序很重要!

既然知道了如何计算法向量,我们就可以创建一个 explode 函数了,它使用法向量和顶点位置向量作为参数。此函数将返回一个新向量,它是位置向量沿法线向量位移后的结果:

vec4 explode(vec4 position, vec3 normal) {
    float magnitude = 2.0;
    vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magnitude; 
    return position + vec4(direction, 0.0);
}

函数本身应该不复杂。sin 函数接收一个 time 参数,它根据时间返回一个 -1.0 到 1.0 之间的值。因为我们不想让物体向内“爆炸”(Implode),所以我们将 sin 值变换到 [0, 1] 的范围内。最终结果会乘以 normal 向量,并且最终的 direction 向量会添加到位置向量上。

当使用我们的模型加载器绘制模型时,爆炸效果的完整几何着色器如下所示:

#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;

in VS_OUT {
    vec2 texCoords;
} gs_in[];

out vec2 TexCoords; 

uniform float time;

vec4 explode(vec4 position, vec3 normal) { ... }

vec3 GetNormal() { ... }

void main() {    
    vec3 normal = GetNormal();

    gl_Position = explode(gl_in[0].gl_Position, normal);
    TexCoords = gs_in[0].texCoords;
    EmitVertex();
    gl_Position = explode(gl_in[1].gl_Position, normal);
    TexCoords = gs_in[1].texCoords;
    EmitVertex();
    gl_Position = explode(gl_in[2].gl_Position, normal);
    TexCoords = gs_in[2].texCoords;
    EmitVertex();
    EndPrimitive();
}

注意,我们在发射顶点之前输出了相应的纹理坐标。

同时别忘了在 OpenGL 代码中设置 time 变量:

shader.setFloat("time", glfwGetTime());

最终效果是,3D 模型看起来随着时间不断地“爆炸”其顶点,然后再恢复正常状态。虽然这不是很实用,但它确实展示了几何着色器更高级的用法。你可以将你的代码与这里的完整源代码进行比较。


摘自评论区 CasonCaly:

需要注意的是,几何着色器中的 gl_Position 是从顶点着色器中传过来的。也就是说顶点着色器中算出来的顶点是什么空间的,那么几何着色器中就是哪个空间的。

作者给的两个例子中,爆炸的例子是基于投影空间的,毛发的例子是基于观察空间的。
在爆炸的例子中,作者应该是偷懒了,直接用投影空间的顶点去算法线。从效果来看是没有问题的,但如果你拿一个立方体的箱子做试验,你就非常容易发现问题。正确且简单的做法是,直接把原始顶点传到几何着色器中。

PixPin_2025-02-18_17-50-46.gif

出错误的原因为在投影空间用顶点计算法线,然后偏移顶点坐标。那为什么不能在投影空间计算法线呢?你可能还记得在基础光照一小节中介绍了将法线变换到世界空间下不能简单地直接乘以模型矩阵,原因在这篇文章中进行了详细介绍(译文)。这里的原因很相似,问题在于投影矩阵对坐标进行了非正交变换,导致改变了顶点之间的相对距离与方向,使得最后计算出来的法线与物体表面不再垂直。正确的做法是在几何着色器中使用原始的顶点信息来进行法线计算。

在顶点着色器中,输出原始顶点位置信息:

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

out VS_OUT {
   vec2 texCoords;
   vec3 position;
} vs_out;

void main()
{
    vs_out.texCoords = aTexCoords;
    vs_out.position = aPos;
    gl_Position = vec4(aPos, 1.0);
}

在几何着色器中,使用原始顶点位置计算法线,直接偏移原始顶点位置,然后对偏移后的原始顶点位置进行 MVP 变换:

#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;

in VS_OUT {
   vec2 texCoords;
   vec3 position;
} gs_in[];

out vec2 TexCoords;

uniform float time;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

vec3 GetNormal()
{
   vec3 a = gs_in[0].position - gs_in[1].position;
   vec3 b = gs_in[2].position - gs_in[1].position;
   return normalize(cross(a, b));
}

vec4 explode(vec3 position, vec3 normal)
{
    float magnitude = 2.0;
    vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magnitude; 
    return vec4(position + direction, 1.f);
}

void main() {    
    vec3 normal = GetNormal();
    mat4 mvp = projection * view * model;
    gl_Position = mvp * explode(gs_in[0].position, normal);
    TexCoords = gs_in[0].texCoords;
    EmitVertex();
    gl_Position = mvp * explode(gs_in[1].position, normal);
    TexCoords = gs_in[1].texCoords;
    EmitVertex();
    gl_Position = mvp * explode(gs_in[2].position, normal);
    TexCoords = gs_in[2].texCoords;
    EmitVertex();
    EndPrimitive();
}

PixPin_2025-02-18_18-51-24.gif

项目源码:爆炸 - GitCode


法向量可视化

在这一部分中,我们将使用几何着色器来实现一个实用的例子:显示任意物体的法向量。在编写光照着色器时,你可能会遇到一些奇怪的视觉输出,但又很难确定问题的原因。光照错误通常是由于错误的法向量引起的,这可能是因为不正确地加载顶点数据、错误地将其定义为顶点属性或在着色器中不正确地管理所致。我们需要某种方式来检测提供的法向量是否正确。可视化法向量是检查其正确性的好方法,而几何着色器正是实现此目的的有用工具。

我们的思路是:首先,我们不使用几何着色器正常绘制场景。然后再次绘制场景,但这次只显示通过几何着色器生成的法向量。几何着色器接收一个三角形图元,并沿着法向量生成三条线——每个顶点一条法向量。伪代码如下所示:

shader.use();
DrawScene();

normalDisplayShader.use();
DrawScene();

这次,在几何着色器中,我们将使用模型提供的顶点法线,而不是自己生成。为了适应(观察和模型矩阵的)缩放和旋转,我们在将法线变换到观察空间坐标之前,先使用法线矩阵变换一次(几何着色器接收的位置向量是观察空间坐标,因此我们应该将法向量变换到相同的空间中)。这可以在顶点着色器中完成:

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

out VS_OUT {
    vec3 normal;
} vs_out;

uniform mat4 view;
uniform mat4 model;

void main() {
    gl_Position = view * model * vec4(aPos, 1.0); 
    mat3 normalMatrix = mat3(transpose(inverse(view * model)));
    vs_out.normal = normalize(vec3(vec4(normalMatrix * aNormal, 0.0)));
}

变换后的观察空间法向量会以接口块的形式传递到下一个着色器阶段。接下来,几何着色器会接收每个顶点(包括一个位置向量和一个法向量),并在每个位置向量处绘制一个法线向量:

#version 330 core
layout (triangles) in;
layout (line_strip, max_vertices = 6) out;

in VS_OUT {
    vec3 normal;
} gs_in[];

const float MAGNITUDE = 0.4;

uniform mat4 projection;

void GenerateLine(int index) {
    gl_Position = projection * gl_in[index].gl_Position;
    EmitVertex();
    gl_Position = projection * (gl_in[index].gl_Position + vec4(gs_in[index].normal, 0.0) * MAGNITUDE);
    EmitVertex();
    EndPrimitive();
}

void main() {    
    GenerateLine(0); // 第一个顶点法线
    GenerateLine(1); // 第二个顶点法线
    GenerateLine(2); // 第三个顶点法线
}

像这样的几何着色器应该很容易理解。注意,我们将法向量乘以了一个 MAGNITUDE 向量,来限制显示的法向量大小(否则它们会有点大)

由于法线的可视化通常用于调试目的,我们可以使用片段着色器将其显示为单色线(如果你愿意,也可以是非常漂亮的线):

#version 330 core
out vec4 FragColor;

void main() {
    FragColor = vec4(1.0, 1.0, 0.0, 1.0);
}

现在,首先使用普通着色器渲染模型,然后再使用特殊的法线可视化着色器渲染,你将看到这样的效果:

image.png

除了让我们的背包变得毛茸茸之外,它还能让我们很好地判断模型的法向量是否准确。你可以想象到,这样的几何着色器也经常用于给物体添加毛发(Fur)。

你可以在这里找到源码。

项目源码:法线可视化 - GitCode


实例化

  • 原文链接:实例化 - LearnOpenGL CN

基础

假设你有一个绘制了许多模型的场景,其中大部分模型包含同一组顶点数据,但应用了不同的世界空间变换。想象一个充满草的场景:每根草都是一个包含几个三角形的小模型。你可能需要绘制大量的草,最终可能需要在每帧中渲染数千甚至数万根草。由于每根草仅由几个三角形构成,因此渲染几乎是瞬间完成的,但数千个渲染函数调用会极大地影响性能。

渲染大量物体时的性能瓶颈

如果我们渲染大量物体,代码可能如下所示:

for (unsigned int i = 0; i < amount_of_models_to_draw; i++) {
    DoSomePreparations(); // 绑定 VAO、绑定纹理、设置 uniform 等
    glDrawArrays(GL_TRIANGLES, 0, amount_of_vertices);
}

像这样绘制模型的大量实例很快就会因为绘制调用过多而达到性能瓶颈。与绘制顶点本身相比,使用 glDrawArraysglDrawElements 函数告诉 GPU 绘制顶点数据会消耗更多性能,因为 OpenGL 在绘制顶点数据之前需要做很多准备工作(例如告诉 GPU 从哪个缓冲区读取数据,从哪里寻找顶点属性,而这些都是在相对缓慢的 CPU 到 GPU 总线(CPU to GPU Bus)上传输的)。因此,即使渲染顶点非常快,命令 GPU 渲染也未必如此。

实例化(Instancing)技术

如果我们能够将数据一次性发送给 GPU,然后使用一个绘制函数让 OpenGL 利用这些数据绘制多个物体,那就更方便了。这就是实例化(Instancing)

实例化技术允许我们使用一个渲染调用来绘制多个物体,从而节省每次绘制物体时 CPU -> GPU 的通信开销,它只需要一次即可。要使用实例化渲染,我们只需要将 glDrawArraysglDrawElements 的渲染调用分别改为 glDrawArraysInstancedglDrawElementsInstanced 即可。这些渲染函数的实例化版本需要一个额外的参数,称为实例数量(Instance Count),它用于设置我们需要渲染的实例个数。这样,我们只需要将必要的数据发送到 GPU 一次,然后使用一次函数调用告诉 GPU 它应该如何绘制这些实例。GPU 将直接渲染这些实例,而无需不断与 CPU 进行通信。

gl_InstanceID 内建变量

实例化渲染函数本身并没有什么特殊作用。渲染同一个物体一千次对我们并没有什么用处,因为每个物体都是完全相同的,而且还在同一个位置。我们只能看到一个物体!出于这个原因,GLSL 在顶点着色器中嵌入了另一个内建变量:gl_InstanceID

在使用实例化渲染调用时,gl_InstanceID 会从 0 开始,在每个实例被渲染时递增 1。例如,如果我们正在渲染第 43 个实例,那么顶点着色器中它的 gl_InstanceID 将是 42。由于每个实例都有唯一的 ID,我们可以创建一个数组,将 ID 与位置值对应起来,将每个实例放置在世界的不同位置。

实例化绘制示例

为了体验实例化绘制,我们将在标准化设备坐标系中使用一个渲染调用绘制 100 个 2D 四边形。我们会索引一个包含 100 个偏移向量的 uniform 数组,将偏移值添加到每个实例化的四边形上。最终的结果是一个排列整齐的四边形网格:

image.png

每个四边形由 2 个三角形组成,共有 6 个顶点。每个顶点包含一个 2D 的标准化设备坐标位置向量和一个颜色向量。以下是本例使用的顶点数据,为了填充整个屏幕,每个三角形都很小:

float quadVertices[] = {
    // 位置          // 颜色
    -0.05f,  0.05f,  1.0f, 0.0f, 0.0f,
     0.05f, -0.05f,  0.0f, 1.0f, 0.0f,
    -0.05f, -0.05f,  0.0f, 0.0f, 1.0f,

    -0.05f,  0.05f,  1.0f, 0.0f, 0.0f,
     0.05f, -0.05f,  0.0f, 1.0f, 0.0f,   
     0.05f,  0.05f,  0.0f, 1.0f, 1.0f                   
}; 

片段着色器将从顶点着色器接收颜色向量,并将其设置为颜色输出,以实现四边形的着色:

#version 330 core
out vec4 FragColor;

in vec3 fColor;

void main()
{
    FragColor = vec4(fColor, 1.0);
}

到目前为止,还没有什么新内容,但从顶点着色器开始,情况就变得有趣起来:

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

out vec3 fColor;

uniform vec2 offsets[100];

void main()
{
    vec2 offset = offsets[gl_InstanceID];
    gl_Position = vec4(aPos + offset, 0.0, 1.0);
    fColor = aColor;
}

这里我们定义了一个名为 offsets 的数组,它包含 100 个偏移向量。在顶点着色器中,我们将使用 gl_InstanceID 来索引 offsets 数组,获取每个实例的偏移向量。如果我们要实例化绘制 100 个四边形,仅使用此顶点着色器,我们就可以得到 100 个位于不同位置的四边形。

目前,我们仍然需要设置这些偏移位置。我们将在进入渲染循环之前使用一个嵌套的 for 循环来计算:

glm::vec2 translations[100];
int index = 0;
float offset = 0.1f;

for (int y = -10; y < 10; y += 2) {
    for (int x = -10; x < 10; x += 2) {
        glm::vec2 translation;
        translation.x = (float)x / 10.0f + offset;
        translation.y = (float)y / 10.0f + offset;
        translations[index++] = translation;
    }
}

在这里,我们创建了 100 个位移向量,表示 10x10 网格上的所有位置。除了生成 translations 数组之外,我们还需要将数据传输到顶点着色器的 uniform 数组中:

shader.use();

for (unsigned int i = 0; i < 100; i++) {
    shader.setVec2(("offsets[" + std::to_string(i) + "]"), translations[i]);
}

在这段代码中,我们将 for 循环的计数器 i 转换为一个字符串,该字符串可用于动态创建位置值的字符串,以索引 uniform 位置值。接下来,我们将为 offsets uniform 数组中的每一项设置相应的位移向量。

现在,所有准备工作都已完成,我们可以开始渲染四边形了。对于实例化渲染,我们使用 glDrawArraysInstancedglDrawElementsInstanced。由于我们没有使用索引缓冲区,因此我们将调用 glDrawArrays 版本的函数:

glBindVertexArray(quadVAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);

lDrawArraysInstanced 的参数与 glDrawArrays 完全相同,除了最后一个参数用于设置需要绘制的实例数量。因为我们想在 10x10 网格中显示 100 个四边形,所以我们将其设置为 100。运行代码后,您应该会看到熟悉的 100 个彩色四边形。

实例化数组

虽然之前的实现在目前情况下能够正常工作,但是如果我们要渲染远超 100 个实例时(这其实非常普遍),我们最终会超过最大能够发送至着色器的 uniform 数据大小上限。一个替代方案是实例化数组(Instanced Array),它被定义为一个顶点属性(可以让我们存储更多的数据),仅在顶点着色器渲染一个新实例时才会更新。

使用顶点属性时,顶点着色器的每次运行都会让 GLSL 获取一组适用于当前顶点的新属性。而当我们将顶点属性定义为一个实例化数组时,顶点着色器只需要对每个实例(而不是每个顶点)更新顶点属性的内容。这允许我们对逐顶点的数据使用普通的顶点属性,而对逐实例的数据使用实例化数组。

为了给你一个实例化数组的例子,我们将使用之前的例子,并将偏移量 uniform 数组设置为一个实例化数组。我们需要在顶点着色器中再添加一个顶点属性:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aOffset;

out vec3 fColor;

void main() {
    gl_Position = vec4(aPos + aOffset, 0.0, 1.0);
    fColor = aColor;
}

我们不再使用 gl_InstanceID,现在不需要索引一个 uniform 数组就能够直接使用 offset 属性了。

由于实例化数组和 positioncolor 变量一样,都是顶点属性,我们还需要将其内容存储在顶点缓冲对象中,并配置其属性指针。我们首先将(上一部分的)translations 数组存到一个新的缓冲对象中:

unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);

之后,我们还需要设置它的顶点属性指针,并启用顶点属性:

glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);   
glVertexAttribDivisor(2, 1);

这段代码有趣的地方在于最后一行,我们调用了 glVertexAttribDivisor。这个函数告诉 OpenGL 何时更新顶点属性的内容到新的一组数据。

  • 它的第一个参数是所需的顶点属性
  • 第二个参数是属性除数(Attribute Divisor)。
    • 默认情况下,属性除数是 0,告诉 OpenGL 我们需要在顶点着色器的每次迭代时更新顶点属性。
    • 将其设置为 1 时,我们告诉 OpenGL 我们希望在渲染一个新实例的时候更新顶点属性。
    • 而设置为 2 时,我们希望每 2 个实例更新一次属性,以此类推。

我们将属性除数设置为 1,是在告诉 OpenGL,位于位置 2 的顶点属性是一个实例化数组。如果我们现在使用 glDrawArraysInstanced 再次渲染四边形,会得到以下输出:

image.png

这和之前的例子完全相同,但这次是使用实例化数组实现的。这使我们能够将更多数据(只要内存允许)传递到顶点着色器,以用于实例化绘制。

为了更有趣一点,我们还可以使用 gl_InstanceID,从右上角到左下角逐渐缩小四边形:

void main() {
    vec2 pos = aPos * (gl_InstanceID / 100.0);
    gl_Position = vec4(pos + aOffset, 0.0, 1.0);
    fColor = aColor;
}

结果是,第一个四边形实例会非常小,随着绘制实例的增加,gl_InstanceID 会越来越接近 100,四边形也越来越接近原始大小。像这样将实例化数组与 gl_InstanceID 结合使用是完全可行的。

image.png

如果你还是不确定实例化渲染是如何工作的,或者想看看所有代码是如何组合起来的,你可以在这里找到程序的源代码。

虽然很有趣,但是这些例子并不是实例化的好例子。是的,它们的确让你知道实例化是怎么工作的,但是我们还没接触到它最有用的一点:绘制巨大数量的相似物体。出于这个原因,我们将会在下一部分进入太空探险,见识实例化渲染真正的威力。

小行星带

普通渲染

想象这样一个场景,在宇宙中有一个大的行星,它位于小行星带的中央。这样的小行星带可能包含成千上万的岩块,在很不错的显卡上也很难完成这样的渲染。实例化渲染正是适用于这样的场景,因为所有的小行星都可以使用一个模型来表示。每个小行星可以再使用不同的变换矩阵来进行少许的变化。

为了展示实例化渲染的作用,我们首先会不使用实例化渲染,来渲染小行星绕着行星飞行的场景。这个场景将会包含一个大的行星模型,它可以在这里下载,以及很多环绕着行星的小行星。小行星的岩石模型可以在这里下载。

在代码例子中,我们将使用在模型加载小节中定义的模型加载器来加载模型。

为了得到想要的效果,我们将为每个小行星生成一个变换矩阵,作为其模型矩阵。变换矩阵首先将小行星位移到小行星带中的某个位置,我们还会添加一个小的随机偏移值到该位移上,使圆环看起来更自然。然后,我们应用一个随机缩放,并以一个随机旋转向量为轴进行随机旋转。最终的变换矩阵不仅能将小行星变换到行星周围,还能使其看起来更自然,与其他小行星区分开来。最终结果是一个布满小行星的圆环,每个小行星都独一无二。

unsigned int amount = 1000;
glm::mat4 *modelMatrices;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime()); // 初始化随机种子    
float radius = 50.0f;
float offset = 2.5f;

for (unsigned int i = 0; i < amount; i++) {
    glm::mat4 model(1.f);

    // 1. 位移:分布在半径为 'radius' 的圆形上,偏移范围为 [-offset, offset]
    float angle = (float)i / (float)amount * 360.0f;
    float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float x = sin(angle) * radius + displacement;
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float y = displacement * 0.4f; // 让行星带的高度比 x 和 z 的宽度要小
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float z = cos(angle) * radius + displacement;
    model = glm::translate(model, glm::vec3(x, y, z));

    // 2. 缩放:在 0.05 和 0.25f 之间缩放
    float scale = (rand() % 20) / 100.0f + 0.05f;
    model = glm::scale(model, glm::vec3(scale));

    // 3. 旋转:绕着一个(半)随机选择的旋转轴向量进行随机旋转
    float rotAngle = (rand() % 360);
    model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));

    // 4. 添加到矩阵数组中
    modelMatrices[i] = model;
}

这段代码可能看起来有点复杂,但我们只是将小行星的 x 和 z 位置变换到一个半径为 radius 的圆形上,并在半径的基础上偏移了 -offsetoffset。我们让 y 偏移的影响更小一点,使小行星带更扁平。然后,我们应用了缩放和旋转变换,并将最终的变换矩阵存储在 modelMatrices 中,该数组的大小为 amount。这里,我们总共生成 1000 个模型矩阵,每个小行星一个。

加载完行星和岩石模型,并编译完着色器后,渲染代码如下所示:

// 绘制行星
shader.use();
glm::mat4 model(1.f);
model = glm::translate(model, glm::vec3(0.0f, -3.0f, 0.0f));
model = glm::scale(model, glm::vec3(4.0f, 4.0f, 4.0f));
shader.setMat4("model", model);
planet.Draw(shader);

// 绘制小行星
for (unsigned int i = 0; i < amount; i++) {
    shader.setMat4("model", modelMatrices[i]);
    rock.Draw(shader);
}

我们首先绘制了行星模型,并对其进行位移和缩放,以适应场景。然后,我们绘制了 amount 个岩石模型。在绘制每个岩石之前,我们需要在着色器内设置相应的模型变换矩阵。

最终结果是一个看起来像太空的场景,环绕行星的是一个看起来很自然的小行星带:

image.png

这个场景每帧包含1001次渲染调用,其中1000个是岩石模型。你可以在这里找到源代码。

当我们开始增加这个数字的时候,你很快就会发现场景不再能够流畅运行了,帧数也下降很厉害。当我们将amount设置为2000的时候,场景就已经慢到移动都很困难的程度了。


我们计算帧数显示在窗口标题上,首先在渲染循环外定义几个新变量:

int frameCount = 0; // 帧计数器
double fpsInterval = 0.5; // 计算FPS的时间间隔,单位为秒
double timeElapsed = 0.0; // 自上次计算FPS以来经过的时间

然后在渲染循环内计算 fps 并显示在标题上:

float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;

timeElapsed += deltaTime;
frameCount++;
if (timeElapsed >= fpsInterval)
{
	// 计算FPS
	double fps = frameCount / timeElapsed;

	glfwSetWindowTitle(window, ("LearnOpenGL    FPS: " + std::to_string(frameCount)).c_str());

	// 重置计数器
	frameCount = 0;
	timeElapsed -= fpsInterval;
}

整体场景较大,调整了 camera 的移动速度为 15.f,并设定初始位置为 glm::vec3(0.0f, 10.f, 80.0f)

当 rock 调整到 10000,fps 就只有 13 左右了:

image.png

项目源码:正常渲染 - GitCode


实例化渲染

现在,我们尝试使用实例化渲染来渲染相同的场景。我们首先对顶点着色器进行一点修改:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in mat4 instanceMatrix;

out vec2 TexCoords;

uniform mat4 projection;
uniform mat4 view;

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

我们不再使用模型 uniform 变量,而是改用一个 mat4 类型的顶点属性,这样我们就可以存储一个实例化数组的变换矩阵。但是,当顶点属性的类型大于 vec4 时,需要多进行一步处理。顶点属性允许的最大数据大小等于一个 vec4。由于一个 mat4 本质上是 4 个 vec4,我们需要为这个矩阵预留 4 个顶点属性。因为我们将其位置值设置为 3,所以矩阵每一列的顶点属性位置值就是 3、4、5 和 6。

接下来,我们需要为这 4 个顶点属性设置属性指针,并将它们设置为实例化数组:

// 顶点缓冲对象
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, amount * sizeof(glm::mat4), &modelMatrices[0], GL_STATIC_DRAW);

for (unsigned int i = 0; i < rock.meshes.size(); i++) {
    unsigned int VAO = rock.meshes[i].VAO;
    glBindVertexArray(VAO);

    // 顶点属性
    GLsizei vec4Size = sizeof(glm::vec4);

    glEnableVertexAttribArray(3); 
    glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0);
    glEnableVertexAttribArray(4); 
    glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(1 * vec4Size));
    glEnableVertexAttribArray(5); 
    glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size));
    glEnableVertexAttribArray(6); 
    glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size));

    glVertexAttribDivisor(3, 1);
    glVertexAttribDivisor(4, 1);
    glVertexAttribDivisor(5, 1);
    glVertexAttribDivisor(6, 1);

    glBindVertexArray(0);
}

注意,这里我们将 Mesh 的 VAO 从私有变量更改为公共变量,以便我们能够访问其顶点数组对象。这不是最佳解决方案,只是为了配合本节的一个简单更改。除此之外,代码应该很清楚了。我们告诉 OpenGL 如何解释每个缓冲顶点属性的缓冲区,并告诉它这些顶点属性是实例化数组。

接下来,我们再次使用网格的 VAO,这一次使用 glDrawElementsInstanced 进行绘制:

// 绘制小行星
instanceShader.use();

for (unsigned int i = 0; i < rock.meshes.size(); i++) {
    glBindVertexArray(rock.meshes[i].VAO);
    glDrawElementsInstanced(
        GL_TRIANGLES, rock.meshes[i].indices.size(), GL_UNSIGNED_INT, 0, amount
    );
}

这里,我们绘制与之前相同数量 (amount) 的小行星,但使用的是实例渲染。结果应该非常相似,但如果你开始增加 amount 变量,你就能看到实例化渲染的效果了。没有实例化渲染时,我们只能流畅渲染 1000 到 1500 个小行星。而使用实例化渲染后,我们可以将这个值设置为 100000,每个岩石模型有 576 个顶点,每帧加起来大概要绘制 5700 万个顶点,但性能却没有受到任何影响!

image.png

上面这幅图渲染了10万个小行星(在某些机器上,10 万个小行星可能会太多了,所以尝试修改这个值,直到达到一个你能接受的帧率),半径为 150.0f,偏移量等于 25.0f。你可以在这里找到实例化渲染的代码。

可以看到,在合适的环境下,实例化渲染能够大大增加显卡的渲染能力。正是出于这个原因,实例化渲染通常会用于渲染草、植被、粒子,以及上面这样的场景,基本上只要场景中有很多重复的形状,都能够使用实例化渲染来提高性能。

项目源码:实例化渲染 - GitCode


抗锯齿

  • 原文链接:抗锯齿 - LearnOpenGL CN

在学习渲染的旅途中,你可能会时不时遇到模型边缘有锯齿的情况。这些锯齿边缘(Jagged Edges)的产生和光栅器将顶点数据转化为片段的方式有关。在下面的例子中,你可以看到,我们只是绘制了一个简单的立方体,你就能注意到它存在锯齿边缘了:

image.png

可能不是非常明显,但如果你离近仔细观察立方体的边缘,你就应该能够看到锯齿状的图案。如果放大的话,你会看到下面的图案:

image.png

显然,这不是我们在最终程序中想要实现的效果。你可以清楚地看到边缘形成的像素。这种现象称为走样(Aliasing)。有许多种抗锯齿(Anti-aliasing,也称为反走样)技术可以帮助我们缓解这种现象,从而产生更平滑的边缘。

最初,我们有一种称为超采样抗锯齿(Super Sample Anti-aliasing,SSAA)的技术,它会使用比正常分辨率更高的分辨率(即超采样)来渲染场景,当图像输出在帧缓冲区中更新时,分辨率会被下采样(Downsample)至正常分辨率。这些额外的分辨率被用来防止锯齿边缘的产生。虽然它确实能够解决走样问题,但是由于这样比平时要绘制更多的片段,它也会带来很大的性能开销。所以这项技术只拥有了短暂的辉煌。

然而,在这项技术的基础上也诞生了更为现代的技术,称为多重采样抗锯齿(Multisample Anti-aliasing,MSAA)。它借鉴了 SSAA 背后的理念,但却以更高效的方式实现了抗锯齿。我们将在本节中深入讨论 OpenGL 中内置的 MSAA 技术。

多重采样

要理解多重采样(Multisampling)及其解决锯齿问题的原理,我们需要深入了解 OpenGL 光栅化器的工作方式。光栅化器是位于顶点处理阶段之后、片段着色器之前的整个算法和过程的总和。它以图元的所有顶点作为输入,并将其转换为一系列片段。顶点坐标理论上可以取任意值,但片段坐标受限于窗口分辨率,因此只能取离散值。由于顶点坐标与片段坐标之间几乎不存在一一映射关系,因此光栅化器必须决定每个顶点最终对应的片段/屏幕坐标。

image.png

这里我们可以看到一个屏幕像素的网格,每个像素的中心包含一个采样点(Sample Point),它被用来决定三角形是否覆盖了某个像素。图中红色的采样点被三角形覆盖,在每个被覆盖的像素处都会生成一个片段。虽然三角形边缘的一些部分也覆盖了某些屏幕像素,但是这些像素的采样点并没有被三角形内部覆盖,所以它们不会受到片段着色器的影响。

现在你可能已经清楚走样(Aliasing)的原因了。完整渲染后的三角形在屏幕上会是这样的:

image.png

由于屏幕像素总量的限制,一些边缘像素可以被渲染出来,而另一些则不会。结果是我们使用不光滑的边缘来渲染图元,导致之前讨论的锯齿边缘。

多重采样(Multisampling)所做的正是将单一采样点变为多个采样点(这也是它名称的由来)。我们不再使用像素中心的单一采样点,而是使用以特定图案排列的 4 个子采样点(Subsample)取而代之。我们将用这些子采样点来决定像素的覆盖度

image.png

上图左侧展示了正常情况下判定三角形是否覆盖像素的方式。在示例中,该像素不会运行片段着色器(因此它将保持空白),因为它的采样点未被三角形覆盖。上图右侧展示了实施多重采样后的版本,每个像素包含 4 个采样点。这里,只有两个采样点覆盖了三角形。

采样点的数量可以是任意的,更多的采样点能带来更精确的覆盖率。

从这里开始,多重采样就变得有趣起来了。我们知道三角形只覆盖了 2 个子采样点,所以下一步是决定这个像素的颜色。你可能会猜测,我们对每个被覆盖的子采样点运行一次片段着色器,最后将每个像素所有子采样点的颜色平均一下。在这个例子中,我们需要在两个子采样点上对被插值的顶点数据运行两次片段着色器,并将结果颜色存储在这些采样点中。(幸运的是)这并不是它的工作方式,因为这本质上说还是需要运行更多次的片段着色器,会显著降低性能。

MSAA 真正的工作方式是,无论三角形覆盖了多少个子采样点,(每个图元中)每个像素只运行一次片段着色器。片段着色器所使用的顶点数据来自像素中心的插值,然后,MSAA 使用更大的深度/模板缓冲区来确定子采样点的覆盖率。被覆盖的子采样点数量将决定像素颜色对帧缓冲区的影响程度。因为上图的 4 个采样点中只有 2 个被覆盖,所以三角形的颜色会有一半与帧缓冲区的颜色(这里是无色)进行混合,最终形成一种淡蓝色。

这样做之后,颜色缓冲区中所有图元边缘将产生更平滑的图形。让我们来看看前面三角形的多重采样会是什么样子:

image.png

这里,每个像素包含 4 个子采样点(未标注不相关的采样点),蓝色采样点被三角形覆盖,而灰色采样点则未被覆盖。对于三角形内部的像素,片段着色器只会运行一次,颜色输出将存储到所有 4 个子采样点中。而在三角形边缘,并非所有子采样点都被覆盖,因此片段着色器的结果将仅存储到部分子采样点中。根据被覆盖的子采样点数量,最终像素颜色将由三角形颜色与其他子采样点中存储的颜色决定。

简单来说,一个像素中被三角形覆盖的采样点越多,该像素的颜色就越接近三角形的颜色。如果我们给上面的三角形填充颜色,就能得到以下效果:

image.png

三角形不平滑的边缘被稍浅的颜色包围后,从远处观察会显得更平滑。

深度值和模板值会按各个子采样点存储。当多个三角形重叠在单个像素上时,即使我们只运行一次片段着色器,颜色值也依然会按子采样点存储。对于深度测试,在运行深度测试之前,每个顶点的深度值会被插值到各个子采样点中。对于模板测试,我们会为每个子采样点存储模板值,这意味着缓冲区的大小会根据每个像素的子采样点数量而增加。

我们目前讨论的都是多重采样抗锯齿背后的原理,光栅化器背后的实际逻辑比目前讨论的要复杂。但你现在应该可以理解多重采样抗锯齿的大体概念和逻辑了。

(译者注: 如果看到这里还是对原理似懂非懂,可以简单看看知乎上 @文刀秋二对抗锯齿技术的精彩介绍)

OpenGL中的MSAA

如果我们想在 OpenGL 中使用 MSAA,就必须使用一个能在每个像素中存储多个颜色值的颜色缓冲区(因为多重采样需要我们为每个采样点都存储一个颜色)。因此,我们需要一种新的缓冲区类型来存储特定数量的多重采样样本,它被称为多重采样缓冲区(Multisample Buffer)。

大多数窗口系统都提供了一个多重采样缓冲区,用于代替默认的颜色缓冲区。GLFW 同样提供了此功能,我们所要做的只是提示 GLFW,我们希望使用一个包含 N 个样本的多重采样缓冲区。这可以在创建窗口之前调用 glfwWindowHint 来完成。

glfwWindowHint(GLFW_SAMPLES, 4);

现在再调用 glfwCreateWindow 创建渲染窗口时,每个屏幕坐标就会使用一个包含 4 个子采样点的颜色缓冲区了。GLFW 会自动创建一个每像素 4 个子采样点的深度和模板缓冲区。这也意味着所有缓冲区的大小都增长了 4 倍。

现在我们已经向 GLFW 请求了多重采样缓冲区,还需要调用 glEnable 并启用 GL_MULTISAMPLE,来启用多重采样。在大多数 OpenGL 驱动上,多重采样都是默认启用的,所以这个调用可能会有点多余,但显式地调用一下会更保险一点。这样子不论是什么 OpenGL 的实现都能够正常启用多重采样了。

glEnable(GL_MULTISAMPLE);

由于多重采样的算法都在 OpenGL 驱动的光栅化器中实现,我们不需要再多做什么。如果现在再来渲染本节一开始的那个绿色立方体,我们应该能看到更平滑的边缘:

image.png

这个箱子看起来的确要平滑多了,如果在场景中有其它的物体,它们也会看起来平滑很多。你可以在这里找到这个简单例子的源代码。

离屏MSAA

由于 GLFW 负责创建多重采样缓冲区,启用 MSAA 非常简单。然而,如果我们想使用自己的帧缓冲区进行离屏渲染,就必须自己生成多重采样缓冲区。现在,我们确实需要自己创建多重采样缓冲区。

有两种方式可以创建多重采样缓冲区,并将其作为帧缓冲区的附件:纹理附件和渲染缓冲区附件,这与在帧缓冲教程中讨论的普通附件非常相似。

多重采样纹理附件

为了创建一个支持存储多个采样点的纹理,我们使用 glTexImage2DMultisample 来替代 glTexImage2D,其纹理目标为 GL_TEXTURE_2D_MULTISAMPLE

glTexImage2DMultisample(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLboolean fixedsamplelocations);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, samples, GL_RGB, width, height, GL_TRUE);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, 0);

其第二个参数设置的是纹理所拥有的样本个数。如果最后一个参数为 GL_TRUE,图像将为每个纹素使用相同的样本位置以及相同数量的子采样点。

我们同样使用 glFramebufferTexture2D 将多重采样纹理附加到帧缓冲区上,但这里纹理类型使用的是 GL_TEXTURE_2D_MULTISAMPLE

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0);

当前绑定的帧缓冲区现在拥有了一个纹理图像形式的多重采样颜色缓冲区。

多重采样渲染缓冲对象

与纹理类似,创建一个多重采样渲染缓冲区对象并不困难。我们所要做的只是在指定(当前绑定的)渲染缓冲区的内存存储时,将 glRenderbufferStorage 的调用替换为 glRenderbufferStorageMultisample 即可。

glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, width, height);

在此函数中,渲染缓冲区对象后的参数用于设置样本数量,在本例中为 4。

渲染到多重采样帧缓冲

渲染到多重采样帧缓冲区对象的过程非常简单。只要我们在帧缓冲区绑定时绘制任何内容,光栅化器就会负责所有的多重采样运算。我们最终会得到一个多重采样颜色缓冲区以及/或深度和模板缓冲区。因为多重采样缓冲区有点特别,我们不能直接将它们的缓冲区图像用于其他运算,比如在着色器中对它们进行采样

一个多重采样的图像包含比普通图像更多的信息,我们所要做的是缩小或还原(Resolve)图像。多重采样帧缓冲区的还原通常通过 glBlitFramebuffer 来完成,它可以将一个帧缓冲区中的某个区域复制到另一个帧缓冲区中,并且将多重采样缓冲区还原。

glBlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter)
  • srcX0srcY0: 指定源帧缓冲区矩形区域的一个角的x和y坐标。
  • srcX1srcY1: 指定源帧缓冲区矩形区域对角位置的x和y坐标。这两个坐标与前两个一起定义了从哪个区域读取数据。
  • dstX0dstY0: 指定目标帧缓冲区矩形区域的一个角的x和y坐标。
  • dstX1dstY1: 指定目标帧缓冲区矩形区域对角位置的x和y坐标。这两个坐标与前两个一起定义了数据将被写入哪个区域。
  • mask: 一个位字段,指定要复制哪些缓冲区。它可以是 GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BIT 和 GL_STENCIL_BUFFER_BIT 的任意组合,分别表示颜色缓冲区、深度缓冲区和模板缓冲区。
  • filter: 指定使用的插值过滤器。可以是 GL_NEAREST 或 GL_LINEARGL_NEAREST 表示使用最邻近插值,而 GL_LINEAR 则表示使用线性插值。

glBlitFramebuffer 会将一个用 4 个屏幕空间坐标定义的源区域复制到一个同样用 4 个屏幕空间坐标定义的目标区域中。你可能记得在帧缓冲区教程中,当我们绑定到 GL_FRAMEBUFFER 时,我们是同时绑定了读取和绘制的帧缓冲区目标。我们也可以将帧缓冲区分开绑定至 GL_READ_FRAMEBUFFERGL_DRAW_FRAMEBUFFERglBlitFramebuffer 函数会根据这两个目标,决定哪个是源帧缓冲区,哪个是目标帧缓冲区。

接下来,我们可以将图像位块传送(Blit)到默认的帧缓冲区中,将多重采样的帧缓冲区传送到屏幕上。

glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampledFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);

如果现在再来渲染这个程序,我们会得到与之前完全一样的结果:一个使用 MSAA 显示出来的橄榄绿色立方体,且锯齿边缘明显减少。

image.png

项目源码:离屏MSAA - GitCode


但如果我们想使用多重采样帧缓冲区的纹理输出来进行后期处理等操作呢?我们不能直接在片段着色器中使用多重采样纹理。但我们可以将多重采样缓冲区位块传输到一个未使用多重采样纹理附件的 FBO 中。然后使用这个普通的颜色附件进行后期处理,从而达到我们的目的。然而,这也意味着我们需要生成一个新的 FBO,作为中介帧缓冲区对象,将多重采样缓冲区还原为一个可以在着色器中使用的普通 2D 纹理。这个过程的伪代码如下:

unsigned int msFBO = CreateFBOWithMultiSampledAttachments();
// 使用普通的纹理颜色附件创建一个新的FBO
...
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, screenTexture, 0);
...
while(!glfwWindowShouldClose(window))
{
    ...

    glBindFramebuffer(msFBO);
    ClearFrameBuffer();
    DrawScene();
    // 将多重采样缓冲还原到中介FBO上
    glBindFramebuffer(GL_READ_FRAMEBUFFER, msFBO);
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, intermediateFBO);
    glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
    // 现在场景是一个2D纹理缓冲,可以将这个图像用来后期处理
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    ClearFramebuffer();
    glBindTexture(GL_TEXTURE_2D, screenTexture);
    DrawPostProcessingQuad();  

    ... 
}

如果现在再实现帧缓冲区教程中的后期处理效果,我们就能够在几乎没有锯齿的场景纹理上进行后期处理了。如果将图像灰度化,效果如下:

image.png

由于屏幕纹理又变回了一个只有单一采样点的普通纹理,像边缘检测这样的后期处理滤镜会重新导致锯齿。为了补偿这个问题,你可以之后对纹理进行模糊处理,或者设计你自己的抗锯齿算法。

你可以看到,如果将多重采样与离屏渲染结合起来,我们需要自己负责一些额外的细节。但所有这些细节都值得付出额外的努力,因为多重采样能够显著提升场景的视觉质量。当然,要注意,如果使用的采样点非常多,启用多重采样会显著降低程序的性能。在本节写作时,通常采用 4 采样点的 MSAA。

你可以在这里找到源代码。


当通过glBlitFramebuffer将多重采样源FBO复制到非多重采样目标FBO时,OpenGL会自动执行解析操作:

  • 合并方式:将每个像素的多个样本(如4个)按规则(如求平均)合并为单个颜色值。
  • 结果:生成一个平滑的抗锯齿图像,存储在普通2D纹理中。
// 示例:将4x MSAA的FBO解析到普通FBO
glBindFramebuffer(GL_READ_FRAMEBUFFER, msaa_fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolve_fbo);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);

多重采样并进行后期处理的流程:

原始渲染(4x MSAA)
  → [多重采样FBO(每个像素4个样本)]
  → glBlitFramebuffer(解析)
  → [普通FBO(每个像素1个合并值)]
  → 后期处理(片段着色器直接采样)

项目源码:后期处理 - GitCode

同时需要注意一个限制条件(见官方文档):当源帧缓冲区(GL_READ_FRAMEBUFFER)或目标帧缓冲区(GL_DRAW_FRAMEBUFFER)是多重采样缓冲区(即它们的 GL_SAMPLE_BUFFERS 属性为1)时:
- 必须满足源区域目标区域宽度和高度完全相同
- 否则:会触发 GL_INVALID_OPERATION 错误,操作失败。

多重采样缓冲区的本质是每个像素包含多个样本(如4x MSAA时每个像素有4个样本)。当使用glBlitFramebuffer进行复制时:

  • 如果源或目标是多重采样的:OpenGL需要确保样本数据能正确解析(Resolve)或映射到目标缓冲区。
  • 缩放或拉伸(如将1024x768的源复制到512x384的目标)会涉及像素插值或重采样,但多重采样的样本分布与几何覆盖密切相关,直接缩放会导致样本解析逻辑混乱,无法保证抗锯齿效果的正确性。
  • 因此,OpenGL强制要求:当涉及多重采样缓冲区时,复制区域的尺寸必须严格一致,避免因缩放引入未定义行为。

场景1:解析多重采样缓冲区(常见操作)

  • 源:4x MSAA的多重采样FBO(尺寸1920x1080)
  • 目标:普通2D纹理FBO(尺寸1920x1080)
  • 操作合法:通过glBlitFramebuffer将多重采样的源解析到非多重采样目标,尺寸一致。

场景2:尝试缩放多重采样缓冲区

  • 源:4x MSAA的多重采样FBO(尺寸1920x1080)
  • 目标:普通2D纹理FBO(尺寸960x540)
  • 操作非法:触发GL_INVALID_OPERATION,因为目标尺寸与源不同。

如果需要将多重采样缓冲区的输出缩放到不同尺寸,需分两步操作:

  1. 解析到相同尺寸的非多重采样缓冲区
    // 步骤1:解析多重采样到普通纹理(尺寸一致)
    glBlitFramebuffer(0, 0, 1920, 1080, 0, 0, 1920, 1080, GL_COLOR_BUFFER_BIT, GL_LINEAR);
    
  2. 对普通纹理进行缩放
    // 步骤2:将普通纹理复制到更小的目标(此时允许缩放)
    glBlitFramebuffer(0, 0, 1920, 1080, 0, 0, 960, 540, GL_COLOR_BUFFER_BIT, GL_LINEAR);
    

自定义抗锯齿算法

将多重采样的纹理图像直接传递给着色器,而不进行还原操作,也是可行的。GLSL 提供了这样的选项,允许我们对纹理图像的每个子采样点进行采样,因此我们可以创建自己的抗锯齿算法。在大型图形应用程序中,通常会采用这种方法。

要获取每个子采样点的颜色值,你需要将纹理 uniform 采样器设置为 sampler2DMS,而不是常用的 sampler2D

uniform sampler2DMS screenTextureMS;

使用 texelFetch 函数可以获取每个子采样点的颜色值:

vec4 colorSample = texelFetch(screenTextureMS, TexCoords, 3); // 第 4 个子采样点

我们不会深入探讨自定义抗锯齿技术的细节,这里仅提供一些启发。


http://www.kler.cn/a/553297.html

相关文章:

  • Linux 多Python版本统一和 PySpark 依赖 python 包方案
  • 如何在自定义组件中使用v-model实现双向绑定
  • JupyterNotebook高级使用:常用魔法命令
  • MyBatis-Plus之通用枚举
  • 懒人精灵本地离线卡密验证系统教程(不联网、安全稳定、省钱、永久免费、无任何限制)
  • 15.1 Process(进程)类
  • 智信BI:解决Power BI全面兼容问题的新选择
  • 强化学习能让小模型多恐怖?
  • 【Go语言快速上手】第二部分:Go语言进阶之测试与性能优化
  • 大语言模型中one-hot编码和embedding之间的区别?
  • docker 安装 Rabbitmq 详解
  • linux使用
  • 【C语言】fwrite函数用法介绍
  • Kafka消息服务之Java工具类
  • 【组态PLC】基于博图V16和组态王六层双部电梯组态设计【含PLC组态源码 M008期】
  • 全新的Android UI框架Jetpack Compose
  • 深度学习在遥感影像分析中的应用:地物分类、目标识别、图像分割
  • Bigemap Pro如何设置经纬网出图网格设置
  • 玩机日记 10 群晖开启文件服务挂载到手机/电脑,测试传输性能
  • 用 Biome 替代 ESLint 和 Prettier