Learn OpenGL(二):模型加载与高级OpenGL

从手动设置顶点坐标到加载模型以及OpenGL高级技巧

模型加载

通过一些建模软件,比如Blender,可以方便地对一些复杂物体进行建模并导出为模型,而这些模型文件包含许多信息,比如顶点坐标,法线以及纹理坐标.

不同的模型文件格式封装的信息也不同.常见的有.obj.fbx,s.tl等等

通常使用Assimp加载模型,Assimp能够导入很多种不同的模型文件格式(并也能够导出部分的格式),它会将所有的模型数据加载至Assimp的通用数据结构中。当Assimp加载完模型之后就能够从Assimp的数据结构中提取我们所需的所有数据了。

由于Assimp的数据结构保持不变,不论导入的是什么种类的文件格式,它都能够将我们从这些不同的文件格式中抽象出来,用同一种方式访问需要的数据。

当使用Assimp导入一个模型的时候,它通常会将整个模型加载进一个场景(Scene)对象,它会包含导入的模型/场景中的所有数据。Assimp会将场景载入为一系列的节点(Node),每个节点包含了场景对象中所储存数据的索引,每个节点都可以有任意数量的子节点。

img

  • 和材质,网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中。Scene对象也包含了场景根节点的引用。
  • 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引。Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引。
  • 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
  • 一个网格包含了多个面。Face代表的是物体的渲染图元(Primitive)(三角形、方形、点)。一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的
  • 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)

使用Assimp可以加载不同的模型到程序中,但是载入后它们都被储存为Assimp的数据结构。最终仍要将这些数据转换为OpenGL能够理解的格式,这样才能渲染这个物体。我们从上一节中学到,网格(Mesh)代表的是单个的可绘制实体

image-20241228205757945

一个网格应该至少需要一系列的顶点,每个顶点包含一个位置向量、一个法向量和一个纹理坐标向量。一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图)。

1
2
3
4
5
6
7
8
9
10
struct Vertex {
glm::vec3 position;
glm::vec3 norm;
glm::vec2 textcoord;
};
struct Texture {
unsigned int id;
std::string type;
aiString dir;
}
1
2
3
4
5
6
7
struct Mesh {
public:
std::vector<Vertex> vertices;
std::vector<Texture> textures;
std::vector<unsigned int> indices;
// 处理方法
}
1
2
3
4
5
6
struct Model{
public:
std::vector<Mesh> meshes;
std::vector<Textures> loadad_textures;
// 处理方法
}

深度测试

提前深度测试

提前深度测试允许深度测试在片段着色器之前运行。只要清楚一个片段永远不会是可见的(它在其他物体之后),我们就能提前丢弃这个片段。

片段着色器通常开销都是很大的,所以我们应该尽可能避免运行它们。当使用提前深度测试时,片段着色器的一个限制是你不能写入片段的深度值。如果一个片段着色器对它的深度值进行了写入,提前深度测试是不可能的。OpenGL不能提前知道深度值。

深度缓冲就像颜色缓冲(Color Buffer)(储存所有的片段颜色:视觉输出)一样,在每个片段中储存了信息,并且(通常)和颜色缓冲有着一样的宽度和高度。深度缓冲是由窗口系统自动创建的,它会以16、24或32位float的形式储存它的深度值。在大部分的系统中,深度缓冲的精度都是24位的。

​ 当深度测试(Depth Testing)被启用的时候,OpenGL会将一个片段的深度值与深度缓冲的内容进行对比。OpenGL会执行一个深度测试,如果这个测试通过了的话,深度缓冲将会更新为新的深度值。如果深度测试失败了,片段将会被丢弃。

深度缓冲是在片段着色器运行之后(以及模板测试(Stencil Testing)运行之后)在屏幕空间中运行的。屏幕空间坐标与通过OpenGL的glViewport所定义的视口密切相关,并且可以直接使用GLSL内建变量gl_FragCoord从片段着色器中直接访问。gl_FragCoord的x和y分量代表了片段的屏幕空间坐标(其中(0, 0)位于左下角)。gl_FragCoord中也包含了一个z分量,它包含了片段真正的深度值。z值就是需要与深度缓冲内容所对比的那个值。

深度测试默认是禁用的,所以如果要启用深度测试的话用GL_DEPTH_TEST选项来启用它

1
glEnable(GL_DEPTH_TEST);

当它启用的时候,如果一个片段通过了深度测试的话,OpenGL会在深度缓冲中储存该片段的z值;如果没有通过深度缓冲,则会丢弃该片段。如果你启用了深度缓冲,你还应该在每个渲染迭代之前使用GL_DEPTH_BUFFER_BIT来清除深度缓冲,否则会仍在使用上一次渲染迭代中的写入的深度值

1
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

在某些情况下你会需要对所有片段都执行深度测试并丢弃相应的片段,但希望更新深度缓冲。基本上来说,你在使用一个只读的(Read-only)深度缓冲。OpenGL允许禁用深度缓冲的写入,只需要设置它的深度掩码(Depth Mask)设置为GL_FALSE就可以了:

1
2
glEnable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE);

注意这只在深度测试被启用的时候才有效果。

OpenGL允许修改深度测试中使用的比较运算符。这允许我们来控制OpenGL什么时候该通过或丢弃一个片段,什么时候去更新深度缓冲。可以调用glDepthFunc函数来设置比较运算符(或者说深度函数(Depth Function))

1
glDepthFunc(GL_LESS);

默认情况下使用的深度函数是GL_LESS,它将会丢弃深度值大于等于当前深度缓冲值的所有片段

函数描述
GL_ALWAYS永远通过深度测试
GL_NEVER永远不通过深度测试
GL_LESS在片段深度值小于缓冲的深度值时通过测试
GL_EQUAL在片段深度值等于缓冲区的深度值时通过测试
GL_LEQUAL在片段深度值小于等于缓冲区的深度值时通过测试
GL_GREATER在片段深度值大于缓冲区的深度值时通过测试
GL_NOTEQUAL在片段深度值不等于缓冲区的深度值时通过测试
GL_GEQUAL在片段深度值大于等于缓冲区的深度值时通过测试

深度值精度

深度缓冲包含了一个介于0.0和1.0之间的深度值,它将会与观察者视角所看见的场景中所有物体的z值进行比较。观察空间的z值可能是投影平截头体的近平面(Near)和远平面(Far)之间的任何值。

在透视矩阵中的znear和zfar决定了哪些坐标在视锥体中,我们将处在这个范围的顶点坐标z值转换到[0,1]之间. 一种简单的方式就是使用线性变换

img

在实践中是几乎永远不会使用这样的线性深度缓冲(Linear Depth Buffer)的。要想有正确的投影性质,需要使用一个非线性的深度方程,它是与 1/z 成正比的。它做的就是在z值很小的时候提供非常高的精度,而在z值很远的时候提供更少的精度

深度值很大一部分是由很小的z值所决定的,这给了近处的物体很大的深度精度。这个(从观察者的视角)变换z值的方程是嵌入在投影矩阵中的,所以当我们想将一个顶点坐标从观察空间至裁剪空间的时候这个非线性方程就被应用了。

img

可以将本身的非线性深度值转为线性深度值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
out vec4 FragColor;

float near = 0.1;
float far = 100.0;

float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // back to NDC
return (2.0 * near * far) / (far + near - z * (far - near));
}

void main()
{
float depth = LinearizeDepth(gl_FragCoord.z) / far; // 为了演示除以 far
FragColor = vec4(vec3(depth), 1.0);
}

深度冲突

一个很常见的视觉错误会在两个平面或者三角形非常紧密地平行排列在一起时会发生,深度缓冲没有足够的精度来决定两个形状哪个在前面。结果就是这两个形状不断地在切换前后顺序,这会导致很奇怪的花纹。这个现象叫做深度冲突(Z-fighting),因为它看起来像是这两个形状在争夺(Fight)谁该处于顶端。

​ 箱子被放置在地板的同一高度上,这也就意味着箱子的底面和地板是共面的(Coplanar)。这两个面的深度值都是一样的,所以深度测试没有办法决定应该显示哪一个。

​ 深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值比较大的时候有着更小的精度)。深度冲突不能够被完全避免,但一般会有一些技巧有助于在你的场景中减轻或者完全避免深度冲突

防止深度冲突

第一个也是最重要的技巧是永远不要把多个物体摆得太靠近,以至于它们的一些三角形会重叠。通过在两个物体之间设置一个用户无法注意到的偏移值,你可以完全避免这两个物体之间的深度冲突。在箱子和地板的例子中,可以将箱子沿着正y轴稍微移动一点。箱子位置的这点微小改变将不太可能被注意到,但它能够完全减少深度冲突的发生。然而,这需要对每个物体都手动调整,并且需要进行彻底的测试来保证场景中没有物体会产生深度冲突。

第二个技巧是尽可能将近平面设置远一些。精度在靠近平面时是非常高的,所以如果我们将近平面远离观察者,我们将会对整个平截头体有着更大的精度。然而,将近平面设置太远将会导致近处的物体被裁剪掉,所以这通常需要实验和微调来决定最适合你的场景的平面距离。

另外一个很好的技巧是牺牲一些性能,使用更高精度的深度缓冲。大部分深度缓冲的精度都是24位的,但现在大部分的显卡都支持32位的深度缓冲,这将会极大地提高精度。所以,牺牲掉一些性能,你就能获得更高精度的深度测试,减少深度冲突。

模板测试

​ 当片段着色器处理完一个片段之后,模板测试(Stencil Test)会开始执行,和深度测试一样,它也可能会丢弃片段。接下来,被保留的片段会进入深度测试,它可能会丢弃更多的片段。模板测试是根据又一个缓冲来进行的,它叫做模板缓冲(Stencil Buffer),可以在渲染的时候更新它来获得一些很有意思的效果。

一个模板缓冲中,(通常)每个模板值(Stencil Value)是8位的。所以每个像素/片段一共能有256种不同的模板值。我们可以将这些模板值设置为我们想要的值,然后当某一个片段有某一个模板值的时候,我们就可以选择丢弃或是保留这个片段了

每个窗口库都需要为你配置一个模板缓冲。GLFW自动做了这件事,所以我们不需要告诉GLFW来创建一个,但其它的窗口库可能不会默认给你创建一个模板库,所以记得要查看库的文档。

模板缓冲操作允许在渲染片段时将模板缓冲设定为一个特定的值。通过在渲染时修改模板缓冲的内容,我们写入了模板缓冲。在同一个(或者接下来的)渲染迭代中,可以读取这些值,来决定丢弃还是保留某个片段。使用模板缓冲的时候你可以尽情发挥,但大体的步骤如下:

  • 启用模板缓冲的写入。
  • 渲染物体,更新模板缓冲的内容。
  • 禁用模板缓冲的写入。
  • 渲染(其它)物体,这次根据模板缓冲的内容丢弃特定的片段。
1
2
glEnable(GL_STENCIL_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

和深度测试的glDepthMask函数一样,模板缓冲也有一个类似的函数。

1
2
glStencilMask(0xFF); // 每一位写入模板缓冲时都保持原样
glStencilMask(0x00); // 每一位在写入模板缓冲时都会变成0(禁用写入)

glStencilMask允许我们设置一个位掩码(Bitmask),它会与将要写入缓冲的模板值进行与(AND)运算。默认情况下设置的位掩码所有位都为1,不影响输出,但如果我们将它设置为0x00,写入缓冲的所有模板值最后都会变成0.这与深度测试中的glDepthMask(GL_FALSE)是等价的

模板函数

一共有两个函数能够用来配置模板测试:glStencilFunc和glStencilOp

glStencilFunc(GLenum func, GLint ref, GLuint mask)一共包含三个参数:

  • func:设置模板测试函数(Stencil Test Function)。这个测试函数将会应用到已储存的模板值上和glStencilFunc函数的ref值上。可用的选项有:GL_NEVER、GL_LESS、GL_LEQUAL、GL_GREATER、GL_GEQUAL、GL_EQUAL、GL_NOTEQUAL和GL_ALWAYS。它们的语义和深度缓冲的函数类似。
  • ref:设置了模板测试的参考值(Reference Value)。模板缓冲的内容将会与这个值进行比较。
  • mask:设置一个掩码,它将会与参考值和储存的模板值在测试比较它们之前进行与(AND)运算。初始情况下所有位都为1

如何更新缓冲就需要glStencilOp函数

glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)一共包含三个选项,能够设定每个选项应该采取的行为:

  • sfail:模板测试失败时采取的行为。
  • dpfail:模板测试通过,但深度测试失败时采取的行为。
  • dppass:模板测试和深度测试都通过时采取的行为。
行为描述
GL_KEEP保持当前储存的模板值
GL_ZERO将模板值设置为0
GL_REPLACE将模板值设置为glStencilFunc函数设置的ref
GL_INCR如果模板值小于最大值则将模板值加1
GL_INCR_WRAP与GL_INCR一样,但如果模板值超过了最大值则归零
GL_DECR如果模板值大于最小值则将模板值减1
GL_DECR_WRAP与GL_DECR一样,但如果模板值小于0则将其设置为最大值
GL_INVERT按位翻转当前的模板缓冲值

默认情况下glStencilOp是设置为(GL_KEEP, GL_KEEP, GL_KEEP)的,所以不论任何测试的结果是如何,模板缓冲都会保留它的值。默认的行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你需要至少对其中一个选项设置不同的值。

通过使用glStencilFunc和glStencilOp,我们可以精确地指定更新模板缓冲的时机与行为了,我们也可以指定什么时候该让模板缓冲通过,即什么时候片段需要被丢弃

物体轮廓

模板测试的一个重要应用就是人为选择需要绘制的区域. 如果要绘制一个物体的轮廓,画这个物体时更新模板缓冲为1,然后画一个更大的物体,设置模板缓冲函数,让模板测试是不等于1的位置. 则只会显示轮廓区域.

重要的几点:

  1. 先进行模板测试后进行深度测试. 在这两个测试都开启时且不使用glDepthMask则在后面的物体是无法通过模板函数显示在前面的
  2. 模板测试的glStencilFunc设置的是模板测试成功条件,也就是怎样才能显示图形以及显示哪一部分图形. 比如设置当模板缓冲的值大于1时则模板测试通过. glStencilOp设置的是如何更新模板缓冲.

注意:

  1. 模板掩码(Stencil Mask)对 glClear 的影响:
1
2
3
4
5
6
// 设置模板掩码
glStencilMask(0xFF); // 允许写入所有位
glClear(GL_STENCIL_BUFFER_BIT); // 正常清除

glStencilMask(0x00); // 禁止写入
glClear(GL_STENCIL_BUFFER_BIT); // 不会清除模板缓冲
  1. 清除模板缓冲区的值设置:
1
2
3
4
5
6
// 设置清除值
glClearStencil(1); // 设置清除值为1
glClear(GL_STENCIL_BUFFER_BIT); // 用1填充模板缓冲区

// 多缓冲区同时清除
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
  1. ```c++
    // 错误示范
    glStencilMask(0x00);
    glClear(GL_STENCIL_BUFFER_BIT); // 无效的清除

    // 正确做法
    glStencilMask(0xFF); // 确保清除前设置正确的掩码
    glClear(GL_STENCIL_BUFFER_BIT);
    glStencilMask(0x00); // 之后再改回所需的掩码值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30

    ## 混合

    ​ OpenGL中,混合(Blending)通常是实现物体透明度(Transparency)的一种技术。透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。

    ​ **一个有色玻璃窗是一个透明的物体,玻璃有它自己的颜色,但它最终的颜色还包含了玻璃之后所有物体的颜色**。这也是混合这一名字的出处,混合(Blend)(不同物体的)多种颜色为一种颜色。所以透明度能让我们看穿物体。 Alpha通道表示不透明度,值越大越不透明.

    ### 丢弃片段

    如果你有一个纹理,其中某些部分是完全透明的,你可以根据纹理的颜色 alpha 分量来决定是否丢弃片段。例如,对于 alpha 值小于某个阈值的片段,你可以选择将其丢弃,从而实现不规则形状的物体渲染。

    此外,有时你可能想要根据一些条件裁剪掉不需要的部分。比如,在绘制树叶或草地的时候,可能会根据距离相机的距离或者其他条件来决定是否绘制特定的片段。

    加载纹理之后,如果纹理带有alpha通道,可以通过通道值选择是否渲染这个片段.

    ```glsl
    #version 330 core
    out vec4 FragColor;

    in vec2 TexCoords;

    uniform sampler2D texture1;

    void main()
    {
    vec4 texColor = texture(texture1, TexCoords);
    if(texColor.a < 0.1)
    discard;
    FragColor = texColor;
    }

设置距离丢弃条件,太远就丢弃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#version 400 core
in vec2 TextCoord;
in vec3 Normal;
in vec3 FragPos;
uniform vec3 viewPos;
out vec4 FragColor;
uniform sampler2D texture1;
void main(){
vec4 textColor = texture(texture1,TextCoord);
if (textColor.a < 0.1) {
discard;
}
float dis = distance(FragPos,viewPos);
if(dis> 15.0){
discard;
}
FragColor = textColor;
}

注意,当采样纹理的边缘的时候,OpenGL会对边缘的值和纹理下一个重复的值进行插值(因为我们将它的环绕方式设置为了GL_REPEAT。这通常是没问题的,但是由于我们使用了透明值,纹理图像的顶部将会与底部边缘的纯色值进行插值。这样的结果是一个半透明的有色边框,你可能会看见它环绕着你的纹理四边形。要想避免这个,每当你alpha纹理的时候,请将纹理的环绕方式设置为GL_CLAMP_TO_EDGE:

1
2
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

混合

虽然直接丢弃片段很好,但它不能让我们渲染半透明的图像。我们要么渲染一个片段,要么完全丢弃它。要想渲染有多个透明度级别的图像,我们需要启用混合(Blending)。和OpenGL大多数的功能一样,可以启用GL_BLEND来启用混合

OpenGL中的混合是通过下面这个方程来实现的:

img

  • C_source:源颜色向量。这是源自纹理的颜色向量
  • C_destination:目标颜色向量。这是当前储存在颜色缓冲中的颜色向量
  • F_source:源因子值。指定了alpha值对源颜色的影响。
  • F_destination:目标因子值。指定了alpha值对目标颜色的影响

使用glBlendFunc设置因子值,glBlendFunc(GLenum sfactor, GLenum dfactor)函数接受两个参数,来设置源和目标因子。OpenGL为我们定义了很多个选项,我们将在下面列出大部分最常用的选项。注意常数颜色向量C¯constantC¯constant可以通过glBlendColor函数来另外设置。

选项
GL_ZERO因子等于00
GL_ONE因子等于11
GL_SRC_COLOR因子等于源颜色向量C¯sourceC¯source
GL_ONE_MINUS_SRC_COLOR因子等于1−C¯source1−C¯source
GL_DST_COLOR因子等于目标颜色向量C¯destinationC¯destination
GL_ONE_MINUS_DST_COLOR因子等于1−C¯destination1−C¯destination
GL_SRC_ALPHA因子等于C¯sourceC¯source的alphaalpha分量
GL_ONE_MINUS_SRC_ALPHA因子等于1−1− C¯sourceC¯source的alphaalpha分量
GL_DST_ALPHA因子等于C¯destinationC¯destination的alphaalpha分量
GL_ONE_MINUS_DST_ALPHA因子等于1−1− C¯destinationC¯destination的alphaalpha分量
GL_CONSTANT_COLOR因子等于常数颜色向量C¯constantC¯constant
GL_ONE_MINUS_CONSTANT_COLOR因子等于1−C¯constant1−C¯constant
GL_CONSTANT_ALPHA因子等于C¯constantC¯constant的alphaalpha分量
GL_ONE_MINUS_CONSTANT_ALPHA因子等于1−1− C¯constantC¯constant的alphaalpha分量

注意常数颜色向量C_constant可以通过glBlendColor函数来另外设置。

为了获得之前两个方形的混合结果,我们需要使用源颜色向量的alpha作为源因子,使用1−alpha作为目标因子。这将会产生以下的glBlendFunc:

1
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

也可以使用glBlendFuncSeparate为RGB和alpha通道分别设置不同的选项:

1
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);

允许我们改变方程中源和目标部分的运算符。当前源和目标是相加的,但如果愿意的话,我们也可以让它们相减。glBlendEquation(GLenum mode)允许我们设置运算符,它提供了三个选项:

  • GL_FUNC_ADD:默认选项,将两个分量相加:
  • GL_FUNC_SUBTRACT:将两个分量相减:
  • GL_FUNC_REVERSE_SUBTRACT:将两个分量相减,但顺序相反:

通常都可以省略调用glBlendEquation

渲染半透明纹理

初始化时我们启用混合,并设定相应的混合函数:

1
2
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

由于启用了混合,我们就不需要丢弃片段了,所以我们把片段着色器还原:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

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

但是可能跟绘图顺序有关.深度测试和混合一起使用的话会产生一些麻烦。当写入深度缓冲时,深度缓冲不会检查片段是否是透明的,所以透明的部分会和其它值一样写入到深度缓冲中。结果就是窗户的整个四边形不论透明度都会进行深度测试。即使透明的部分应该显示背后的窗户,深度测试仍然丢弃了它们。 也就是说

要想保证窗户中能够显示它们背后的物体,我们需要首先绘制背后的这部分物体。这也就是说在绘制的时候,必须先手动将窗户按照最远到最近来排序,再按照顺序渲染.

注意,对于草这种全透明的物体,可以选择丢弃透明的片段而不是混合它们,这样就解决了这些头疼的问题(没有深度问题)

不要打扰顺序

要想让混合在多个物体上工作,需要最先绘制最远的物体,最后绘制最近的物体

​ 普通不需要混合的物体仍然可以使用深度缓冲正常绘制,所以它们不需要排序。但仍要保证它们在绘制(排序的)透明物体之前已经绘制完毕了。当绘制一个有不透明和透明物体的场景的时候,大体的原则如下:

  1. 先绘制所有不透明的物体。

  2. 对所有透明的物体排序。

  3. 按顺序绘制所有透明的物体

​ 排序透明物体的一种方法是,从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得

​ 接下来把距离和它对应的位置向量存储到一个STL库的map数据结构中。map会自动根据键值(Key)对它的值排序,所以只要添加了所有的位置,并以它的距离作为键,它们就会自动根据距离值排序了。

​ 在渲染的时候以逆序(从远到近)从map中获取值,之后以正确的顺序绘制对应的窗户.

​ 虽然按照距离排序物体这种方法对这个场景能够正常工作,但它并没有考虑旋转、缩放或者其它的变换,奇怪形状的物体需要一个不同的计量,而不是仅仅一个位置向量。

​ 在场景中排序物体是一个很困难的技术,很大程度上由场景的类型所决定,更别说它额外需要消耗的处理能力了。完整渲染一个包含不透明和透明物体的场景并不是那么容易。更高级的技术还有次序无关透明度(Order Independent Transparency, OIT)

面剔除

想象任何一个闭合形状,它的每一个面都有两侧,每一侧要么面向用户,要么背对用户。如果我们能够只绘制面向观察者的面呢?

这正是面剔除(Face Culling)所做的。OpenGL能够检查所有面向(Front Facing)观察者的面,并渲染它们,而丢弃那些背向(Back Facing)的面,节省我们很多的片段着色器调用(它们的开销很大!)。但仍要告诉OpenGL哪些面是正向面(Front Face),哪些面是背向面(Back Face)。OpenGL使用了一个很聪明的技巧,分析顶点数据的环绕顺序(Winding Order)。

环绕顺序

当我们定义一组三角形顶点时,我们会以特定的环绕顺序来定义它们,可能是顺时针(Clockwise)的,也可能是逆时针(Counter-clockwise)的。每个三角形由3个顶点所组成,会从三角形中间来看,为这3个顶点设定一个环绕顺序。

每组组成三角形图元的三个顶点就包含了一个环绕顺序。OpenGL在渲染图元的时候将使用这个信息来决定一个三角形是一个正向三角形还是背向三角形。

默认情况下,逆时针顶点所定义的三角形将会被处理为正向三角形。当你定义顶点顺序的时候,你应该想象对应的三角形是面向你的,所以你定义的三角形从正面看去应该是逆时针的。这样定义顶点很棒的一点是,实际的环绕顺序是在光栅化阶段进行的,也就是顶点着色器运行之后。这些顶点就是从观察者视角所见的了。

面剔除

OpenGL能够丢弃那些渲染为背向三角形的三角形图元。既然已经知道如何设置顶点的环绕顺序了,我们就可以使用OpenGL的面剔除选项了,它默认是禁用状态的。

在之前教程中使用的立方体顶点数据并不是按照逆时针环绕顺序定义的,所以顶点数据反映了环绕顺序从而反应是正向面和背向面.

要想启用面剔除,我们只需要启用OpenGL的GL_CULL_FACE选项:

1
glEnable(GL_CULL_FACE);

从这一句代码之后,所有背向面都将被丢弃(尝试飞进立方体内部,看看所有的内面是不是都被丢弃了)。目前我们在渲染片段的时候能够节省50%以上的性能,但注意这只对像立方体这样的封闭形状有效。

OpenGL允许我们改变需要剔除的面的类型。我们可以调用glCullFace来定义这一行为:

1
glCullFace(GL_FRONT);

glCullFace函数有三个可用的选项:

  • GL_BACK:只剔除背向面。
  • GL_FRONT:只剔除正向面。
  • GL_FRONT_AND_BACK:剔除正向面和背向面。

glCullFace的初始值是GL_BACK。除了需要剔除的面之外,也可以通过调用glFrontFace,告诉OpenGL我们希望将顺时针的面(而不是逆时针的面)定义为正向面:

1
glFrontFace(GL_CCW);

默认值是GL_CCW,它代表的是逆时针的环绕顺序,另一个选项是GL_CW,它(显然)代表的是顺时针顺序。

我们可以来做一个实验,告诉OpenGL现在顺时针顺序代表的是正向面:

1
2
3
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glFrontFace(GL_CW);

帧缓冲

​ 现在已经使用了很多屏幕缓冲了:用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲(Framebuffer)

​ 它被储存在GPU内存中的某处。OpenGL允许我们定义我们自己的帧缓冲,也就是说能够定义我们自己的颜色缓冲,甚至是深度缓冲和模板缓冲

​ 目前所做的所有操作都是在默认帧缓冲的渲染缓冲上进行的。默认的帧缓冲是在你创建窗口的时候生成和配置的(GLFW帮我们做了这些)。通过创建自己的帧缓冲,我们可以获得额外的渲染目标(target)。

渲染你的场景到不同的帧缓冲能够让我们在场景中加入类似镜子的东西,或者做出很酷的后期处理效果

创建帧缓冲

1
2
3
Gluint fbo;
glGenFramebuffers(1,&fbo);
glBindFrameBuffer(GL_FRAMEBUFFER,fbo);

在绑定到GL_FRAMEBUFFER目标之后,所有的读取写入帧缓冲的操作将会影响当前绑定的帧缓冲。也可以使用GL_READ_FRAMEBUFFER或GL_DRAW_FRAMEBUFFER,将一个帧缓冲分别绑定到读取目标或写入目标。绑定到GL_READ_FRAMEBUFFER的帧缓冲将会使用在所有像是glReadPixels的读取操作中,而绑定到GL_DRAW_FRAMEBUFFER的帧缓冲将会被用作渲染、清除等写入操作的目标。大部分情况你都不需要区分它们,通常都会使用GL_FRAMEBUFFER,绑定到两个上

一个完整的帧缓冲需要满足以下的条件:

  • 附加至少一个缓冲(颜色、深度或模板缓冲)。
  • 至少有一个颜色附件(Attachment)。
  • 所有的附件都必须是完整的(保留了内存)。
  • 每个缓冲都应该有相同的样本数(sample)

​ 需要为帧缓冲创建一些附件,并将附件附加到帧缓冲上。在完成所有的条件之后,我们可以以GL_FRAMEBUFFER为参数调用glCheckFramebufferStatus,检查帧缓冲是否完整。它将会检测当前绑定的帧缓冲,并返回规范中这些值的其中之一。如果它返回的是GL_FRAMEBUFFER_COMPLETE,帧缓冲就是完整的了

之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中。由于帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响。出于这个原因,渲染到一个不同的帧缓冲被叫做离屏渲染(Off-screen Rendering)。要保证所有的渲染操作在主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到0

​ 在完整性检查执行之前,需要给帧缓冲附加一个附件。附件是一个内存位置,它能够作为帧缓冲的一个缓冲,可以将它想象为一个图像。当创建一个附件的时候我们有两个选项:纹理或渲染缓冲对象(Renderbuffer Object)。

纹理附件

想象帧缓冲(FBO)是一个画框,纹理附件就是可以放进这个画框的”画布”。这些”画布”可以用来:

  • 存储颜色(类似照片)
  • 存储深度(物体的远近信息)
  • 存储模板值(用于特效)

当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,之后可以在着色器中很方便地使用它。

1
2
3
4
5
6
7
8
unsigned int texture;
glGenTextures(1,&texture);
glBindTexture(GL_TEXTURE_2D,texture);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGB,800,600,0,GL_RGB,GL_UNSIGNED_BYTE,NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 添加颜色纹理附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

对于深度和模板缓冲格式不同

1
2
3
glTexImage2D(GL_TEXTURE_2D,0,GL_DEPTH_COMPONENT,800,600,0,GL_DEPTH_COMPONENT,GL_UNSIGNED_BYTE,NULL);
// 添加深度纹理附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
1
2
3
glTexImage2D(GL_TEXTURE_2D,0,GL_STENCIL_INDEX,800,600,0,GL_STENCIL_INDEX,GL_UNSIGNED_BYTE,NULL);
// 添加模板纹理附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);

渲染缓冲对象附件

​ 和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生的渲染格式,它是为离屏渲染到帧缓冲优化过的。

渲染缓冲对象直接将所有的渲染数据储存到它的缓冲中,不会做任何针对纹理格式的转换,让它变为一个更快的可写储存介质。然而,渲染缓冲对象通常都是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素。

1
2
3
4
5
unsigned int rbo;
glGenRenderbuffers(1,&rbo);
glBindRenderbuffer(GL_RENDERBUFFER,rbo);
glRenderbufferStorage(GL_RENDERBUFFER,GL_DEPTH24_STENCIL8,800,600);
glFramebufferRenderbuffer(GL_FRAMEBUFFER,GL_DEPTH_STENCIL_ATTACHMENT,GL_RENDERBUFFER,rbo);

由于渲染缓冲对象通常都是只写的,它们会经常用于深度和模板附件,因为大部分时间都不需要从深度和模板缓冲中读取值,只关心深度和模板测试。我们需要深度和模板值用于测试,但不需要对它们进行采样,所以渲染缓冲对象非常适合它们。当我们不需要从这些缓冲中采样的时候,通常都会选择渲染缓冲对象,因为它会更优化一点。

渲染到纹理

要想绘制场景到一个纹理上,我需要采取以下的步骤:

  1. 将新的帧缓冲绑定为激活的帧缓冲,和往常一样渲染场景
  2. 绑定默认的帧缓冲
  3. 绘制一个横跨整个屏幕的四边形,将帧缓冲的颜色缓冲作为它的纹理。

将帧缓冲的颜色渲染到默认缓冲的纹理上

反相

将纹理颜色反相

1
2
3
4
void main()
{
FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}

灰度

1
2
3
4
5
6
void main()
{
FragColor = texture(screenTexture, TexCoords);
float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
FragColor = vec4(average, average, average, 1.0);
}

核效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const float offset = 1.0 / 300.0;  

void main()
{
vec2 offsets[9] = vec2[](
vec2(-offset, offset), // 左上
vec2( 0.0f, offset), // 正上
vec2( offset, offset), // 右上
vec2(-offset, 0.0f), // 左
vec2( 0.0f, 0.0f), // 中
vec2( offset, 0.0f), // 右
vec2(-offset, -offset), // 左下
vec2( 0.0f, -offset), // 正下
vec2( offset, -offset) // 右下
);

float kernel[9] = float[](
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
);

vec3 sampleTex[9];
for(int i = 0; i < 9; i++)
{
sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
}
vec3 col = vec3(0.0);
for(int i = 0; i < 9; i++)
col += sampleTex[i] * kernel[i];

FragColor = vec4(col, 1.0);
}

此外还有模糊、边缘检测等效果,都是利用卷积核.

立方体贴图

立方体贴图(Cube Map)是将多个纹理组合起来映射到一张纹理上的一种纹理类型.

立方体贴图的坐标至关重要,通过一个方向向量进行索引. 方向向量原点位于立方体中心

img

假设将这样的立方体贴图应用到一个立方体上,采样立方体贴图所使用的方向向量将和立方体(插值的)顶点位置非常相像。这样子,只要立方体的中心位于原点,我们就能使用立方体的实际位置向量来对立方体贴图进行采样了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

int width, height, nrChannels;
unsigned char *data;
for(unsigned int i = 0; i < textures_faces.size(); i++)
{
data = stbi_load(textures_faces[i].c_str(), &width, &height, &nrChannels, 0);
glTexImage2D(
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

立方体纹理最常用的就是天空盒,而天空盒的核心就是将贴图用在一个立方体上并通过深度测试将贴图作为背景. 一个简单的方法就是利用glDepthMask,首先渲染天空盒,在绘制其他物体之前再启动Mask,更新深度缓冲.

1
2
3
4
5
6
7
8
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... 设置观察和投影矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 绘制剩下的场景

注意到天空盒的大部分可能会被其他物体覆盖,通过提前深度测试,将已知的深度较大的物体在之后绘制,这样在片段着色器运行之前就直到被其他物体””盖住”了. 因为天空盒总是在最后,设置其z在透视变换后为1即可,在片段着色器中设置其z为w.

1
2
vec4 pos = projection * view * vec4(position, 1.0); // view矩阵取消了移动,projection矩阵只对z进行了缩放,而之后通过透视除法将z设置为了1
gl_Position = pos.xyww;

环境映射

我们现在将整个环境映射到了一个纹理对象上了,能利用这个信息的不仅仅只有天空盒。通过使用环境的立方体贴图,可以给物体反射和折射的属性。这样使用环境立方体贴图的技术叫做环境映射(Environment Mapping),其中最流行的两个是反射(Reflection)和折射(Refraction)。

img

反射通过眼睛与物体法线向量得到的反射向量对立方体纹理采样

1
2
3
4
5
6
7
uniform vec3 viewPos;
uniform samplerCube skybox;
in vec3 Normal;
in vec3 FragPos;
vec3 CalcEnvReflectLight(vec3 viewDir,vec3 norm) {
return vec3(texture(skybox,reflect(-viewDir,norm)));
}

当反射应用到一整个物体上时,这个物体看起来就像是钢或者铬这样的高反射性材质. 在现实中大部分的模型都不具有完全反射性。可以引入反射贴图(Reflection Map),来给模型更多的细节。与漫反射和镜面光贴图一样,反射贴图也是可以采样的纹理图像,它决定这片段的反射性。通过使用反射贴图,可以知道模型的哪些部分该以什么强度显示反射。

image-20250105144159047

环境映射的另一种形式是折射,它和反射很相似。折射是光线由于传播介质的改变而产生的方向变化。在常见的类水表面上所产生的现象就是折射,光线不是直直地传播,而是弯曲了一点。将你的半只胳膊伸进水里,观察出来的就是这种效果。

img

折射率是光在真空中的传播速度与光在该介质中的传播速度之比,光从介质1射入介质2发生折射时,入射角与折射角的正弦之比叫做介质2相对介质1的折射率. 折射率越大,光线扭曲程度越大. 有一个观察向量I¯,一个法向量N¯,而这次是折射向量R¯。

观察向量的方向轻微弯曲了。弯折后的向量R¯R¯将会用来从立方体贴图中采样。折射可以使用GLSL的内建refract函数来轻松实现,它需要一个法向量、一个观察方向和两个材质之间的折射率(Refractive Index)。

假设看向水面,折射率就是在空气中的折射率/在水中的折射率.

image-20250105144121316

动态环境贴图

​ 现在使用的都是静态图像的组合来作为天空盒,看起来很不错,但它没有在场景中包括可移动的物体。我们一直都没有注意到这一点,因为我们只使用了一个物体。如果我们有一个镜子一样的物体,周围还有多个物体,镜子中可见的只有天空盒,看起来就像它是场景中唯一一个物体一样。

通过使用帧缓冲能够为物体的6个不同角度创建出场景的纹理,并在每个渲染迭代中将它们储存到一个立方体贴图中。之后我们就可以使用这个(动态生成的)立方体贴图来创建出更真实的,包含其它物体的,反射和折射表面了。这就叫做动态环境映射(Dynamic Environment Mapping),因为动态创建了物体周围的立方体贴图,并将其用作环境贴图。

​ 但它有一个很大的缺点:需要为使用环境贴图的物体渲染场景6次,这是对程序是非常大的性能开销。现代的程序通常会尽可能使用天空盒,并在可能的时候使用预编译的立方体贴图,只要它们能产生一点动态环境贴图的效果。虽然动态环境贴图是一个很棒的技术,但是要想在不降低性能的情况下让它工作还是需要非常多的技巧的。

高级数据

缓冲有不同的类型,比如GL_ARRAY_BUFFER,GL_ELEMENT_BUFFER. 不同缓冲有不同用处. 当glGenBuffers()创建缓冲对象后,需要绑定相应类型才有意义.

填充缓冲数据

glBufferData函数来填充缓冲对象所管理的内存,这个函数会分配一块内存,并将数据添加到这块内存中。如果我们将它的data参数设置为NULL,那么这个函数将只会分配内存,但不进行填充。这在我们需要预留(Reserve)特定大小的内存,之后回到这个缓冲一点一点填充的时候会很有用。

1
2
glBufferData(GL_ARRAY_BUFFER,,sizeof(vertices),NULL,GL_STATIC_DRAW);
glBufferSubData(GL_ARRAY_BUFFER,24,sizeof(data),&data);

除了使用一次函数调用填充整个缓冲之外,也可以使用glBufferSubData,填充缓冲的特定区域。这个函数需要一个缓冲目标、一个偏移量、数据的大小和数据本身作为它的参数。这个函数不同的地方在于,我们可以提供一个偏移量,指定从何处开始填充这个缓冲。这能够让我们插入或者更新缓冲内存的某一部分。要注意的是,缓冲需要有足够的已分配内存,所以对一个缓冲调用glBufferSubData之前必须要先调用glBufferData。

此外可以获得指向内存的指针,通过一些内存函数进行操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);

// in modern c++
auto ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_READ_WRITE);
std::copy(std::begin(skyboxVertices), std::end(skyboxVertices),
static_cast<float *>(ptr));
glUnmapBuffer(GL_ARRAY_BUFFER);

使用glUnmapBuffer函数,告诉OpenGL我们已经完成指针操作之后,OpenGL就会知道你已经完成了。在解除映射(Unmapping)之后,指针将会不再可用,并且如果OpenGL能够成功将您的数据映射到缓冲中,这个函数将会返回GL_TRUE

分批顶点属性

假设数据如下,表示一个点的三种属性,通过glBufferData加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float vertices[] = {
// positions // normals // texture coords
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.5f, -0.5f,
-0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.5f, 0.5f, -0.5f, 0.0f,
0.0f, -1.0f, 1.0f, 1.0f, 0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
1.0f, 1.0f, -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f,
}
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STREAM_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void *)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void *)(3 * sizeof(float)));
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float),(void *)(6 * sizeof(float)));

glVertexAttribPointer设置了解析这些数据的方式,其中3表示这个属性有几个值,8*sizeof(float)表示下一个点的相同属性的距离,0是偏移量.

假设读入数据布局不同,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
float vertices[] = {
// positions
-0.5f, -0.5f, -0.5f,
-0.5f, 0.0f, 0.0f,
0.5f, 0.5f, -0.5f,
// normals
0.0f, 0.0f, -1.0f,
0.0f, 0.0f, 0.5f,
-0.5f, -1.0f, 1.0f,
// texture coords
-1.0f, 1.0f,
0.5f, 0.5f,
0.0f, 0.0f, ,}
1
2
3
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float),(void *)0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float),(void *)(9*sizeof(float)));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float),(void *)(18*sizeof(float)));

此外可以通过glBufferSubData将数据从分别的数据加载到缓冲中

1
2
3
4
5
6
7
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);

复制缓冲

当你的缓冲已经填充好数据之后,你可能会想与其它的缓冲共享其中的数据,或者想要将缓冲的内容复制到另一个缓冲当中。 这可以通过刚才的glMapBuffer实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GLuint vbo1, vbo2;
glGenBuffers(1, &vbo1);
glGenBuffers(1, &vbo2);
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
float data[] ={1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f};
auto ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
std::copy(std::begin(data), std::end(data), static_cast<float *>(ptr));
glUnmapBuffer(GL_ARRAY_BUFFER);
glBindBuffer(GL_ARRAY_BUFFER, vbo2);
ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_READ_ONLY);
float data1[12];
std::copy(data1, data1 + 12, static_cast<float *>(ptr));
glUnmapBuffer(GL_ARRAY_BUFFER);
ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
std::copy(std::begin(data), std::end(data), static_cast<float *>(ptr));
glUnmapBuffer(GL_ARRAY_BUFFER);

但通过glCopyBufferSubData更方便.

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

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

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

1
2
3
4
float vertexData[] = { ... };
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缓冲绑定为新的缓冲目标类型之一:

1
2
3
4
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));

几何着色器

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

首先定义在c++中绘画命令指定的图元类型,比如GLDrawArrays(GL_POINTS,0,4)绘画点图元,而几何着色器能够作为顶点和片段着色器之间的桥梁,能够增删/修改点的属性. out定义输出的图元以及最多的定点数.

1
2
3
4
5
6
7
8
9
10
11
12
13
#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:用于单个点。图元所包含的最小顶点数 1
  • lines:用于线段或线带。
  • lines_adjacency:用于带有相邻信息的线段或线带。
  • triangles:用于三角形、三角形带或三角形扇。
  • triangles_adjacency:用于带有相邻信息的三角形或三角形带。

括号中的数字表示构成该图元所需的最小顶点数。

对于输出图元类型,在out关键字前同样使用布局修饰符,可设置为:

  • points
  • line_strip
  • triangle_strip

​ 这些输出图元允许几何着色器创建各种形状。例如,为了生成一个三角形,可以将输出定义为triangle_strip并提供3个顶点。

此外,几何着色器还要求指定其能输出的最大顶点数量,防止超出限制导致OpenGL忽略额外的顶点。此最大值也在out布局修饰符中设定。例如,若要输出一条线段,则应将最大顶点数设为2。

线条(Line Strip)是由一系列点组成的连续线段,至少需要两个点来形成。每增加一个点,就会与前一个点之间形成一条新的线段。例如,如果有5个顶点,它们会依次相连形成4条线段。

​ 当使用几何着色器时,如果最大输出顶点数设为2,则只能输出一条线段。为了生成更复杂的形状,几何着色器可以通过GLSL提供的内置变量gl_in[]访问来自上一阶段(如顶点着色器)的顶点数据。gl_in[]是一个接口块,它包含了每个输入顶点的位置和其他信息,如gl_Position等。

​ 几何着色器通过调用EmitVertex()函数来发射一个新的顶点,并最终通过EndPrimitive()函数来完成一个图元的定义。代码修改了原始顶点的位置,创建了两个新位置,然后发射了这两个顶点,最后通过调用EndPrimitive()将它们合成为一个线条图元。这样就创建了一条从原始顶点位置向左和向右各平移0.1单位的新线段。

线条是由连续的点组成,几何着色器可以访问先前阶段的顶点数据并通过发射顶点和结束图元的方法来创建新的几何图形

爆破物体

当我们说爆破一个物体时,我们并不是指要将宝贵的顶点集给炸掉,我们是要将每个三角形沿着法向量的方向移动一小段时间。效果就是,整个物体看起来像是沿着每个三角形的法线向量爆炸一样。

做法就是在几何着色器中计算一个三角形面的法向量(通过面上的三个点得到两个向量并计算叉乘),在几何着色器中改变顶点位置.

法向量可视化

​ 可以作为一种Debug工具,当编写光照着色器时,你可能会最终会得到一些奇怪的视觉输出,但又很难确定导致问题的原因。光照错误很常见的原因就是法向量错误,这可能是由于不正确加载顶点数据、错误地将它们定义为顶点属性或在着色器中不正确地管理所导致的。想要的是使用某种方式来检测提供的法向量是正确的。检测法向量是否正确的一个很好的方式就是对它们进行可视化,几何着色器正是实现这一目的非常有用的工具。

​ 首先不使用几何着色器正常绘制场景。然后再次绘制场景,但这次只显示通过几何着色器生成法向量。几何着色器接收一个三角形图元,并沿着法向量生成三条线——每个顶点一个法向量。

实例化

如果我们需要渲染大量物体时,代码看起来会像这样:

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

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

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

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道